diff --git a/README.md b/README.md index 6543cca50..971f7723f 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,16 @@ The repository includes a `band-python-kit` base image with the Band SDK in an isolated, read-only virtual environment, automatic sandbox proxy-CA trust wiring, and support for both arm64 and x86_64. -See the [band-python-kit image guide](docker/band_python_kit/README.md) for -build options, framework extras, image layout, and direct Docker usage. The -declarative kit and published GHCR image are separate release deliverables. +Choose the guide that matches what you need: + +- [Run an agent with the kit](docker/band_python_kit/README.md) — customer + quickstart, configuration, credentials, and sandbox behavior. +- [Customize the echo-agent starter workspace](docker/band_python_kit/echo-agent/README.md) + — adapt `main.py`, dependencies, or start from a repository. +- [Understand or maintain the launcher](src/band/docker/launcher/README.md) + — internal launch phases, safety rules, and module map. + +The declarative kit and published GHCR image are separate release deliverables. --- diff --git a/docker/band_python_kit/README.md b/docker/band_python_kit/README.md index a653110cb..9a6eb4891 100644 --- a/docker/band_python_kit/README.md +++ b/docker/band_python_kit/README.md @@ -1,113 +1,195 @@ -# band-python-kit base image +# Band Python kit for Docker Sandboxes -Base image for the Band Docker Sandboxes kit: a customer's existing framework -agent (LangGraph, CrewAI, Anthropic, etc.) boots inside a sandbox already -connected to Band. This image provides the immutable Band SDK layer; the -customer's own framework lives in a separate venv created at sandbox runtime -(not part of this image). +Run your Python agent on [Band](https://band.ai) inside a Docker +[Sandbox](https://docs.docker.com/ai/sandboxes/): an isolated microVM with a +default-deny egress allowlist, where your project's locked dependencies are +installed automatically and your agent starts headlessly — no manual SDK +installation, no host pollution. -The publishing workflow is a separate release deliverable. This README covers -building and running the image directly. +Your workspace stays a plain `uv` project (`pyproject.toml` + committed +`uv.lock`); the kit brings the Band SDK, the launcher, and the network +policy. Tested with `sbx` v0.34.0. -## Build +## Why use this kit? -Core SDK only, no framework extras: +It turns adopting Band into a small application task instead of an +infrastructure project. You bring your agent code, its locked Python +dependencies, and your Band credentials. The kit provides the SDK, a +repeatable startup process, and sandbox network safeguards. -```bash -docker build -f docker/band_python_kit/Dockerfile -t band-python-kit . -``` +There are two separate Python environments inside the sandbox: -Bake in exactly one framework extra (e.g. for a kit variant dedicated to a -single framework): +- The kit environment runs the stable launcher. It validates configuration, + prepares the agent environment, and reports startup failures. +- Your agent environment contains the dependencies from your project's + `uv.lock` and runs your `main.py`. -```bash -docker build -f docker/band_python_kit/Dockerfile \ - --build-arg SDK_EXTRA=langgraph \ - -t band-python-kit:langgraph . -``` +This means changing your agent's adapter or packages cannot overwrite the +launcher that starts it. If a dependency update fails, the launcher is still +available to show the error and rebuild the agent environment on the next +start. Your host workspace remains source code and configuration rather than +a collection of sandbox-created virtualenv files, caches, and logs. -`SDK_EXTRA` accepts any single extra from `pyproject.toml`'s -`[project.optional-dependencies]` (`langgraph`, `anthropic`, `claude_sdk`, -`crewai`, `pydantic_ai`, ...). Only one at a time: some extras conflict and -cannot resolve into the same venv (`crewai` vs. `parlant`/`pydantic_ai` — see -the Dependency Conflicts section of the repo's `CLAUDE.md`). Any framework not -baked in here is installed into the customer's own venv at sandbox runtime, -isolated from this one. +## Quickstart -Multi-arch (arm64 + x86_64): +You need [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/) (`sbx`), +Docker, and a registered Band agent (its id and API key). ```bash -docker buildx build --platform linux/amd64,linux/arm64 \ - -f docker/band_python_kit/Dockerfile -t band-python-kit . +# 1. Build the kit image and make it visible to the sandbox runtime +# (the sandbox VM cannot see host-local Docker images). +docker build -f docker/band_python_kit/Dockerfile -t band-python-kit:local . +docker save band-python-kit:local | sbx template load /dev/stdin + +# 2. Start your workspace from the echo-agent starter (see echo-agent/README.md). +cp -r docker/band_python_kit/echo-agent ~/my-band-agent +cd ~/my-band-agent +# - set agent.id in band.yaml +# - create .band/secrets.env from secrets.env.example (chmod 600) + +# 3. Create the sandbox — your agent starts immediately. +sbx create --name my-band-agent \ + --kit /path/to/band-sdk-python/docker/band_python_kit \ + band-python-kit ~/my-band-agent ``` -## Image layout +Mention your agent in a Band room — it replies from inside the sandbox. To +make the echo-agent starter your own — swap the echo adapter for a real +framework adapter and set up credentials — see +[`echo-agent/README.md`](echo-agent/README.md). -| Path / env var | What it is | +## Your workspace + +Your workspace is a plain `uv` project plus `band.yaml`; the +[`echo-agent/`](echo-agent/README.md) starter is a complete template with +the file map and authoring guide. + +The launcher runs `uv sync --locked` with the image's pinned `uv` into a +sandbox-owned environment (never inside your mounted workspace, never into +the SDK's own venv), then executes your entrypoint with that environment's +interpreter. A missing or stale `uv.lock` fails the launch with a clear +error — update it with `uv lock` and recreate. + +### Configuration + +`band.yaml` is strict: unknown fields fail the launch. See +`echo-agent/band.yaml` for the full annotated shape. Environment variables +override the file: + +| Variable | Overrides | |---|---| -| `$BAND_SDK_HOME` (`/opt/band`) | Root of the baked SDK install. Read-only to every user, including root, after build (`chmod -R a-w`). | -| `$BAND_SDK_PYTHON` (`/opt/band/venv/bin/python`) | Fixed, absolute interpreter for the Band SDK. **Not on `PATH`** — the SDK venv can never shadow the customer's own venv or interpreter. Invoke the SDK only via this path. | -| `$BAND_SDK_UV` (`/opt/band/bin/uv`) | The build's digest-pinned `uv`, for the sandbox launcher's locked customer-venv sync at runtime. **Not on `PATH`**, read-only like the rest of `$BAND_SDK_HOME`; never downloaded or upgraded at runtime. | -| `agent` (uid 1000, `$HOME=/home/agent`) | The non-root user every process ends up running as. Matches the sandbox's `$HOME`-mounted workspace convention. | - -## CA trust - -Docker Sandboxes generates a per-session proxy CA and exposes it to the -container as base64 in `PROXY_CA_CERT_B64`. The entrypoint: - -1. Decodes `PROXY_CA_CERT_B64` (if set) into - `/usr/local/share/ca-certificates/sandbox-proxy-ca.crt` and runs - `update-ca-certificates` — this needs root, which is why the container - starts as root before dropping privileges (see below). -2. Relies on `SSL_CERT_FILE`/`REQUESTS_CA_BUNDLE`, baked in as image `ENV` - and pointed at the system bundle - (`/etc/ssl/certs/ca-certificates.crt`) — this is what `httpx` needs, - since it trusts only its vendored `certifi` bundle by default and - ignores the system trust store otherwise. `websockets` already defaults - to `ssl.create_default_context()`, so it needs no extra wiring. - -Both are additive (pointed at the *whole* system bundle, not a narrowed, -kit-private CA file) — never override these to trust only an internal CA; -that breaks the credential proxy's own TLS termination path. - -If `PROXY_CA_CERT_B64` is unset (plain allowlisted egress, no credential -injection), the entrypoint skips the install step entirely and every real, -publicly-trusted upstream cert still verifies normally. - -## Privilege drop - -The container's default process starts as **root** — required for the CA -install step above, which only root can perform. The entrypoint -(`entrypoint.sh`) always ends by dropping to `agent` (uid 1000) via -`setpriv` before `exec`-ing the real command: +| `BAND_AGENT_ID`, `BAND_API_KEY` | agent identity / key | +| `BAND_REST_URL`, `BAND_WS_URL` | Band endpoints (default: production) | +| `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN` | LLM backend credentials | +| `BAND_KIT_CONFIG_PATH` | where to find `band.yaml` (every other path is configured inside it) | + +### Credentials + +`.band/secrets.env` is an explicit opt-in +(`credentials.acknowledgePlaintextInSandbox: true` in `band.yaml`): the +plaintext keys exist in both your workspace and the sandbox VM, and the +launcher enforces the guardrails around the file. Setup and the accepted +names: [`echo-agent/README.md`](echo-agent/README.md#credentials) and its +`secrets.env.example`. + +## How the launch works + +At every sandbox start (creation and each restart — no attach session +needed), the kit's startup command runs the image entrypoint as root to +install the sandbox's per-session proxy CA, drops to the non-root `agent` +user (uid 1000), and hands off to the Band launcher, which: + +1. loads `band.yaml` and environment overrides (strict validation), +2. optionally loads missing credentials from your opt-in env file, +3. validates every path (no traversal, no symlink escapes, runtime storage + outside the workspace and SDK venv), +4. optionally clones your project from Git into the project path (the + `repo:` section in `band.yaml` — see the annotated echo-agent starter; + existing checkouts are validated and reused, never re-cloned), +5. syncs your locked dependencies into the sandbox-owned environment, and +6. replaces itself with your entrypoint (`os.execve`) — signals like + `sbx stop`'s SIGTERM reach your code directly (the echo-agent starter + handles them with `band.runtime.shutdown.run_with_graceful_shutdown`). + +Troubleshooting: startup output lands in `/var/log/sbx-kit-startup.log` +inside the sandbox, launcher diagnostics under your configured +`runtime.logPath`, and `sbx policy log ` shows every allowed and +blocked network request. Launch errors name their failing phase (`[config]`, +`[credentials]`, `[paths]`, `[repo]`, `[sync]`, ...) and never contain +secret values. + +## Network access + +The kit allows only what the launch flow and the supported LLM backends +(OpenAI, Anthropic/Claude, GitHub Copilot) need: Band, PyPI, and each +backend's API hosts. Everything else is denied by the sandbox proxy. + +To reach an additional host — or a non-production Band deployment — grant it +per sandbox instead of editing the kit: ```bash -exec setpriv --reuid=agent --regid=agent --init-groups -- "$@" +sbx policy allow network --sandbox my-band-agent platform.dev.band.ai ``` -Nothing meant to run as the sandbox's agent user ever executes as root — -verify with `docker top `, not `docker exec ... whoami` (a fresh -`docker exec` process defaults to root regardless of what PID 1 dropped to). +Do not set `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` yourself: the sandbox +runtime manages them, and overriding them bypasses the network policy. -## Run +Kit changes apply only at creation (`--kit` on `sbx create`); to pick up a +modified kit, remove and recreate the sandbox. Do not rely on post-create +`sbx kit add`: it applies files and startup commands but silently skips the +kit's network and credential configuration. -```bash -docker run --rm band-python-kit bash -c '$BAND_SDK_PYTHON -c "import band; print(band.__version__)"' -``` +## Image reference -With a proxy CA (mirrors what a real sandbox session sets): +### Build variants + +The default build bakes the core Band SDK only. To pre-bake one framework +extra into the image (your workspace venv is unaffected either way): ```bash -docker run --rm -e PROXY_CA_CERT_B64="$(base64 -i proxy-ca.pem)" band-python-kit \ - bash -c '$BAND_SDK_PYTHON -c "import httpx; print(httpx.get(\"https://your-injected-domain\").status_code)"' +docker build -f docker/band_python_kit/Dockerfile \ + --build-arg SDK_EXTRA=langgraph \ + -t band-python-kit:langgraph . ``` -## Out of scope here +`SDK_EXTRA` accepts any single extra from `pyproject.toml`'s +`[project.optional-dependencies]`. One at a time — some extras have +conflicting dependencies and cannot share a venv (e.g. `crewai` conflicts +with `parlant`/`pydantic_ai`). Multi-arch builds work with +`docker buildx build --platform linux/amd64,linux/arm64`. -- **Customer venv creation** — created at sandbox runtime from the - workspace's own dependency declaration, not part of this image. -- **`spec.yaml`** (`kind: sandbox`, `caps.network.allow`, credentials) — a - separate deliverable; this image is what it points at. -- **`sbx kit validate` / `sbx run --kit`** — requires the `spec.yaml` above - and a real Docker Sandboxes-capable machine; not runnable in CI (no - nested virtualization on GitHub-hosted runners). +### Layout + +| Path / env var | What it is | +|---|---| +| `$BAND_SDK_HOME` (`/opt/band`) | Root of the baked SDK install. Read-only to every user, including root, after build. | +| `$BAND_SDK_PYTHON` (`/opt/band/venv/bin/python`) | Fixed interpreter for the Band SDK and launcher. **Not on `PATH`** — it can never shadow your project's own venv. Invoke the SDK only via this path. | +| `$BAND_SDK_UV` (`/opt/band/bin/uv`) | The build's digest-pinned `uv`, used for the locked dependency sync. **Not on `PATH`**, read-only, never downloaded or upgraded at runtime. | +| `agent` (uid 1000, `$HOME=/home/agent`) | The non-root user your agent runs as. | + +### CA trust and privilege drop + +Docker Sandboxes generates a per-session proxy CA and passes it to the +container as base64 in `PROXY_CA_CERT_B64`. The entrypoint decodes it into +the system trust store (`update-ca-certificates` needs root, which is why +the container starts as root) and then always drops to `agent` (uid 1000) +via `setpriv` before executing anything else — nothing of yours ever runs +as root. To verify the drop, inspect the process table (`docker top`, or +`ps` inside the sandbox) rather than `docker exec ... whoami`: a fresh +`exec` process defaults to root regardless of what PID 1 dropped to. + +`SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` are baked in pointing at the +*whole* system bundle so `httpx` (which otherwise trusts only its vendored +certifi bundle) sees the proxy CA too; `websockets` already defaults to +`ssl.create_default_context()` and needs no extra wiring. Never override +them to a narrower CA file — public TLS verification must keep working +alongside the proxy. +When `PROXY_CA_CERT_B64` is unset (e.g. a plain `docker run` outside a +sandbox), the install step is skipped and standard TLS verification applies. + +### Running the image directly + +```bash +docker run --rm band-python-kit:local \ + bash -c '$BAND_SDK_PYTHON -c "import band; print(band.__version__)"' +``` diff --git a/docker/band_python_kit/echo-agent/.gitignore b/docker/band_python_kit/echo-agent/.gitignore new file mode 100644 index 000000000..31d210cf0 --- /dev/null +++ b/docker/band_python_kit/echo-agent/.gitignore @@ -0,0 +1,5 @@ +# Plaintext credential custody (band.yaml credentials.path) — never commit. +.band/ +# The customer venv is created OUTSIDE the workspace by the launcher, but +# guard against a stray local one anyway. +.venv/ diff --git a/docker/band_python_kit/echo-agent/README.md b/docker/band_python_kit/echo-agent/README.md new file mode 100644 index 000000000..77221a2f5 --- /dev/null +++ b/docker/band_python_kit/echo-agent/README.md @@ -0,0 +1,92 @@ +# Echo-agent starter workspace + +A complete, runnable starter workspace for the +[Band Python kit](../README.md): an echo agent you can copy and grow into a +real one. Building the kit image and creating the sandbox is the kit README's +quickstart; this page covers the workspace itself. + +## Files + +| File | What it is | +|---|---| +| `band.yaml` | Agent identity, endpoints, credentials opt-in, runtime paths — annotated, strict (unknown fields fail the launch) | +| `main.py` | The entrypoint the launcher execs — here an echo agent with graceful shutdown | +| `pyproject.toml` + `uv.lock` | A plain `uv` project. The committed lock is required — the launcher never resolves at startup | +| `secrets.env.example` | Template for the opt-in credential file | +| `.gitignore` | Keeps `.band/` (secrets) and stray venvs out of Git | + +## Set your identity + +Put your registered agent's id in `band.yaml` (`agent.id`), or export +`BAND_AGENT_ID`. Endpoints default to production; the annotated `band.yaml` +shows how to target another Band deployment. + +## Credentials + +Create the opt-in credential file from the template: + +```bash +mkdir -p .band +cp secrets.env.example .band/secrets.env +chmod 600 .band/secrets.env # the launcher rejects wider permissions +``` + +Fill in `BAND_API_KEY` and uncomment the LLM keys your agent uses. Only the +documented names in the template are accepted — anything else fails the +launch. Values already present in the process environment always win; the +file only fills gaps. + +The launcher enforces the guardrails (gitignored, never Git-tracked, +owner-only, no symlinks), and `band.yaml`'s +`credentials.acknowledgePlaintextInSandbox: true` records that the plaintext +keys exist in both your workspace and the sandbox VM. Never commit `.band/`. + +## Make it yours + +`main.py`'s `EchoAdapter` echoes every message. Swap it for any +`band.adapters.*` framework adapter (LangGraph, Anthropic, CrewAI, ...), add +the matching `band-sdk` extra to `pyproject.toml`, and refresh the lock with +`uv lock`. + +The whole change is three files. For Anthropic: + +```diff + # pyproject.toml — add the framework extra +-dependencies = ["band-sdk>=1.1.0"] ++dependencies = ["band-sdk[anthropic]>=1.1.0"] +``` + +```diff + # main.py — swap the adapter (the EchoAdapter class and its imports can go) +-from band.core.simple_adapter import SimpleAdapter ++from band.adapters.anthropic import AnthropicAdapter + ... + agent = Agent.create( +- adapter=EchoAdapter(), ++ adapter=AnthropicAdapter(system_prompt="You are a helpful Band agent."), +``` + +```diff + # .band/secrets.env — provide the backend key the adapter reads +-# ANTHROPIC_API_KEY= ++ANTHROPIC_API_KEY=sk-ant-... +``` + +Then `uv lock` and restart the sandbox. Every framework follows the same +shape: the extra in `pyproject.toml`, the adapter in `main.py`, its key in +the secrets file (or environment). + +Workspace edits apply live (the workspace is a mount); dependency changes +take effect at the next sandbox restart, when the launcher re-syncs the +lock. Kit-level changes need a recreate — see the +[kit README](../README.md#network-access). + +## Start from a repository + +Instead of shipping the project in the workspace, the launcher can clone +it at startup: keep only `band.yaml` (and `.band/secrets.env`) at the +workspace root, set `project.path` to a subdirectory (e.g. `app`), and +uncomment the annotated `repo:` block in `band.yaml` with your HTTPS +repository URL. The clone must contain the usual project shape +(`main.py`, `pyproject.toml`, committed `uv.lock`). On restart the +existing checkout is validated and reused, never re-cloned. diff --git a/docker/band_python_kit/echo-agent/band.yaml b/docker/band_python_kit/echo-agent/band.yaml new file mode 100644 index 000000000..c9166e314 --- /dev/null +++ b/docker/band_python_kit/echo-agent/band.yaml @@ -0,0 +1,46 @@ +schemaVersion: "1" + +agent: + id: replace-with-agent-id + entrypoint: main.py + +# Production endpoints. For another Band deployment (e.g. dev), override +# here or via BAND_REST_URL / BAND_WS_URL, and grant the host to the sandbox: +# sbx policy allow network --sandbox +band: + restUrl: https://app.band.ai + wsUrl: wss://app.band.ai/api/v1/socket/websocket + +project: + path: . + +# Optional: materialize the project from Git instead of shipping it in the +# workspace. The repository is cloned into project.path at startup (before +# dependency sync), so set project.path to a subdirectory (e.g. `app`) — +# the workspace root holds this file. On restart an existing checkout is +# validated and reused, never re-cloned. HTTPS URLs are the sandbox path +# (SSH needs ~/.ssh, which sandboxes don't mount); non-GitHub hosts need +# `sbx policy allow network`. +# repo: +# url: https://github.com/your-org/your-agent-project.git +# branch: main # optional +# index: false # optional: write context index files under statePath + +# Opt-in temporary credential custody: the launcher fills credentials that +# are missing from the environment from this gitignored, owner-only (600) +# dotenv file — create it from secrets.env.example. The acknowledgement is +# required because the plaintext keys exist in both the host workspace and +# the sandbox VM. +credentials: + source: workspace-env-file + path: .band/secrets.env + acknowledgePlaintextInSandbox: true + +# Sandbox-owned storage: must live outside the mounted workspace (a direct +# mount is the host directory — a Linux venv written there would pollute the +# host) and outside the immutable SDK venv. +runtime: + environmentPath: /home/agent/.local/share/band-kit/venv + statePath: /home/agent/.local/share/band-kit/state + cachePath: /home/agent/.local/share/band-kit/cache + logPath: /home/agent/.local/share/band-kit/logs diff --git a/docker/band_python_kit/echo-agent/main.py b/docker/band_python_kit/echo-agent/main.py new file mode 100644 index 000000000..cb7316328 --- /dev/null +++ b/docker/band_python_kit/echo-agent/main.py @@ -0,0 +1,54 @@ +"""Minimal Band agent for the band-python-kit echo-agent starter workspace. + +The kit's launcher execs this file with the workspace's own locked venv +interpreter; Band identity, endpoints, and credentials arrive as environment +variables. This example echoes every message — swap ``EchoAdapter`` for any +framework adapter (``band.adapters.*``) and add the matching ``band-sdk`` +extra to pyproject.toml to run a real LLM agent. +""" + +from __future__ import annotations + +import asyncio +import os +from typing import TYPE_CHECKING + +from band import Agent +from band.core.simple_adapter import SimpleAdapter +from band.runtime.shutdown import run_with_graceful_shutdown + +if TYPE_CHECKING: + from band.core.protocols import AgentToolsProtocol + from band.core.types import ChatMessage, PlatformMessage + + +class EchoAdapter(SimpleAdapter[str]): + async def on_message( + self, + msg: PlatformMessage, + tools: AgentToolsProtocol, + history: list[ChatMessage], + participants_msg: str | None, + contacts_msg: str | None, + *, + is_session_bootstrap: bool, + room_id: str, + ) -> None: + await tools.send_message(f"echo: {msg.content}", mentions=[msg.sender_id]) + + +async def main() -> None: + agent = Agent.create( + adapter=EchoAdapter(), + agent_id=os.environ["BAND_AGENT_ID"], + api_key=os.environ["BAND_API_KEY"], + ws_url=os.environ["BAND_WS_URL"], + rest_url=os.environ["BAND_REST_URL"], + ) + # Signals reach this process directly (the launcher execs into it), so + # SIGTERM from `sbx stop` shuts the agent down gracefully. + await run_with_graceful_shutdown(agent) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docker/band_python_kit/echo-agent/pyproject.toml b/docker/band_python_kit/echo-agent/pyproject.toml new file mode 100644 index 000000000..31017c2ed --- /dev/null +++ b/docker/band_python_kit/echo-agent/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "band-kit-example" +version = "0.1.0" +description = "Example customer workspace for the Band Python Sandbox kit." +requires-python = ">=3.11" +dependencies = ["band-sdk>=1.1.0"] diff --git a/docker/band_python_kit/echo-agent/secrets.env.example b/docker/band_python_kit/echo-agent/secrets.env.example new file mode 100644 index 000000000..b847e0ae1 --- /dev/null +++ b/docker/band_python_kit/echo-agent/secrets.env.example @@ -0,0 +1,21 @@ +# Template for the opt-in credential file band.yaml points at +# (credentials.path: .band/secrets.env). To use it: +# +# mkdir -p .band +# cp secrets.env.example .band/secrets.env +# chmod 600 .band/secrets.env # the launcher rejects wider permissions +# +# then fill in your values. Only the documented names below are accepted — +# any other name fails the launch. Values already present in the process +# environment always win; this file only fills the gaps. Never commit +# .band/secrets.env (this example's .gitignore already covers .band/). + +# Required unless provided via the environment. +BAND_API_KEY= + +# LLM backend keys — uncomment only what your agent uses. +# OPENAI_API_KEY= +# ANTHROPIC_API_KEY= +# COPILOT_GITHUB_TOKEN= +# GH_TOKEN= +# GITHUB_TOKEN= diff --git a/docker/band_python_kit/echo-agent/uv.lock b/docker/band_python_kit/echo-agent/uv.lock new file mode 100644 index 000000000..adcfec931 --- /dev/null +++ b/docker/band_python_kit/echo-agent/uv.lock @@ -0,0 +1,598 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "band-client-rest" +version = "0.0.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/e0/600527e9da77b0bda58ba6e48d16e6070ced930e699a656b609f360318b6/band_client_rest-0.0.10.tar.gz", hash = "sha256:d3a753d9cf479949264f7092b550b2616e93da68ab32f23acc2a06a6b69019c7", size = 106806, upload-time = "2026-06-12T15:26:44.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/2c/6c6d4a76d0bbe5a63103add4b50ebdc4184c28afe64b1f7271725087d4f1/band_client_rest-0.0.10-py3-none-any.whl", hash = "sha256:7b900a6fefd25f25677e20fb77378294d5f34e29d583ef5b197fe96724834d7a", size = 246079, upload-time = "2026-06-12T15:26:43.302Z" }, +] + +[[package]] +name = "band-kit-example" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "band-sdk" }, +] + +[package.metadata] +requires-dist = [{ name = "band-sdk", specifier = ">=1.1.0" }] + +[[package]] +name = "band-sdk" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "band-client-rest" }, + { name = "cryptography" }, + { name = "phoenix-channels-python-client" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/cd/7de37be949948dffb838767ed8f3f22d6adcab6482d8d6e88c3bad832769/band_sdk-1.2.0.tar.gz", hash = "sha256:959b5935a48b2d5d5ead91be86ecb50f0f45378dc86b62c65793c970e74c0e88", size = 424097, upload-time = "2026-07-08T10:34:58.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e7/9c714c3068ed89de7f032848963ec0127cb55de04ed55562f98fd0697a28/band_sdk-1.2.0-py3-none-any.whl", hash = "sha256:21a3e1d90146fba240b6c586649a383c5a92d345c290c1901fa97697ba312d85", size = 443366, upload-time = "2026-07-08T10:34:56.234Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "phoenix-channels-python-client" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/53/dbabc21137c7f9657d5f02ac020aa1fab97747a409b393dca9a1ba752945/phoenix_channels_python_client-0.2.1.tar.gz", hash = "sha256:a5ff329d4ed0ce850c3568e39ec29beaf82540d18016d4934837374a612151a1", size = 29245, upload-time = "2026-04-12T12:46:38.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/06/1a37e48ead532b123ed71c5a5e4fd78d4fc93c11622887f41e18d14fedb6/phoenix_channels_python_client-0.2.1-py3-none-any.whl", hash = "sha256:24606551dfbcae35f3052f1573091c25b4da1deded4d6f62d171ff2820ed8ad4", size = 23930, upload-time = "2026-04-12T12:46:39.593Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +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/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.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +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/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "websockets" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/13/d47429afcc2c28616c32640009c84ea3f95660dab805766345b9682468e0/websockets-16.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a9b1d7a63cba8e6b9b77e499a81eab29d31100298d090ad4507d1048c0b9cae0", size = 179770, upload-time = "2026-07-10T06:30:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c7/2f0a722039a1e0107be73ed672ba604449b4956e48733e8e6b8a005aea42/websockets-16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bedbc5efeb96621aa2921d2d92608246691399418cac22acba427eb11877ea1f", size = 177455, upload-time = "2026-07-10T06:30:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/43/6a/c26b0ae449e93d256ce5cdd50d5fe97b575a63e8dcd311a1faa972fd6bc6/websockets-16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd847ab82133015afe65d778e7966ab42dba16bd7ad2e5b8a7918db6539f3f94", size = 177731, upload-time = "2026-07-10T06:30:49.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/3f/381550b344a02f0d2f84cda25e79b54575291bc7022128a41163fe8ba5b0/websockets-16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2fb33ccb16ee40a95cc676d7b0ff451a9a2632f11a0dbc2e666326892b2e1de", size = 187066, upload-time = "2026-07-10T06:30:50.505Z" }, + { url = "https://files.pythonhosted.org/packages/4a/87/5ab1ec2086910f23cfb9ec0c1c29fbcc24a9d190b5198b1557c00ce4a47e/websockets-16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f15b6d9ea9c2eaf6ccab964a082b09bfa6634a495bb0c2e9e7ee6943f58976", size = 188301, upload-time = "2026-07-10T06:30:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/75/4b/bbbb8e6fac4cfc53d7aaa69a3d531bf10799354b0021f4b58914aced8c1a/websockets-16.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:638cf57c48b4ad8ac1ff1e453f4f97db2426b690ddc111e6da96b27b4a340bc3", size = 191594, upload-time = "2026-07-10T06:30:53.229Z" }, + { url = "https://files.pythonhosted.org/packages/5c/da/6c0c349443d6e999f481e3d9a0e57e7ac2956d75d6391bec24b92af3fe13/websockets-16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c1c85f61bc9d5eac57ce705d848dc2d2ce3680638300bf4e1da7d749e2cf4ce", size = 188862, upload-time = "2026-07-10T06:30:54.744Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ea/a368d37c010425a5451f42052fe804e754e23333e8448aef5d55c8a8d64f/websockets-16.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:eeab6d27f51c7e579023c971f5e6dff200deadf01faf6831beaecd32052dfaef", size = 187633, upload-time = "2026-07-10T06:30:56.055Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4e/2ecd59add10d0855ec03dbdedfcdacdbd1aaabcd44b7dcbeda27538662e9/websockets-16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ed64e5a97b0b97a0b66e18bfe281317a75fbbd5afe692f939ea8d14a4292f2c", size = 185089, upload-time = "2026-07-10T06:30:57.444Z" }, + { url = "https://files.pythonhosted.org/packages/6f/eb/c6c3dcd7a01097bb0d42f4e9ef21a2c2a491d36b77cd0870ab59f9e8e77f/websockets-16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9b3b021d0ed4bc16eea9775f62c9fa71acdacba0fc790b38581754dedf29ca60", size = 187790, upload-time = "2026-07-10T06:30:58.731Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3e/775d36885d5e48ab8020aaf377de0ff5fbeb8bc2682a7e46419e4a14521c/websockets-16.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6eb604a4167f0a0d53c2243dfc667a29f0b43c3436057184e070bb82a1000fa2", size = 186381, upload-time = "2026-07-10T06:31:00.355Z" }, + { url = "https://files.pythonhosted.org/packages/ad/90/6305c00812a92e47d0582604c02bd759db0118bbafc13f707d712dbcf898/websockets-16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9a3f125e44c3e34d61d111652e608e0f5b85ce08c225c8d56ad0eb822fa40030", size = 188193, upload-time = "2026-07-10T06:31:01.677Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/96bf8302c81d961585b4d34a2ddd3f229782f9b8c57bc78bbf98f1b1a4ac/websockets-16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8fdf0b00d0d1f30d1f06a92cab46fe542eec3eb302a7aee7163f142d0780f216", size = 185771, upload-time = "2026-07-10T06:31:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1f/e8fe44b1d2dc417d740d9959d28fd2a846f268e7df38a686c04ac7dfe947/websockets-16.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67b56828712f5fa7852de4c0265c28827311a657a4d275b7312ed0d1a918bee4", size = 186803, upload-time = "2026-07-10T06:31:04.34Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/b07d3a4e1eb2ab03e94e7f53f0c7a628e85fde6ad86011f7afd08f27b985/websockets-16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39c7e7730be33b8f0cd6f0aa8e8c82f9cdd1813f159765e073b2ece65f4824b5", size = 187041, upload-time = "2026-07-10T06:31:05.567Z" }, + { url = "https://files.pythonhosted.org/packages/a6/fd/e0abb8acc435642ac4a671490f6cf781c882f3fe682cdced9080ea455ab5/websockets-16.1-cp311-cp311-win32.whl", hash = "sha256:c54fe94fb2f11e11b48920c5f971e298cec73ac35db56efe57a49db63dfc95d4", size = 180158, upload-time = "2026-07-10T06:31:06.929Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/85574d9458d3b913090087b817df0cc47b68e9a01dd0ab6ac04b77f49b0a/websockets-16.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9f4fb9ae8b802e55609685db98382d48fd3feb1397804e1e774968dea0f28c7", size = 180456, upload-time = "2026-07-10T06:31:08.247Z" }, + { url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" }, + { url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" }, + { url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" }, + { url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" }, + { url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" }, + { url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/d9/63/df158b155420b566f025e75613424ad9649a24bcb0e9f259321ab3d58bea/websockets-16.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47", size = 179791, upload-time = "2026-07-10T06:31:33.108Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/00fe9414dfeafa6fe54eae9f5716c8c8e9ac59d192be3b893c096d395846/websockets-16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b", size = 177472, upload-time = "2026-07-10T06:31:34.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/76/b10633424d40681b4e892ffd08ca5226322b2426e62d4ab71eae484c3a32/websockets-16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37", size = 177737, upload-time = "2026-07-10T06:31:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/d3bb03b2229bb1afd72008742d586cf1ea240dce64dd48c71c8c7fd3294c/websockets-16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881", size = 187403, upload-time = "2026-07-10T06:31:37.496Z" }, + { url = "https://files.pythonhosted.org/packages/26/16/cc2e80478f688fc3c39c67dc1fac6a0783858058914ebc2489917462cb42/websockets-16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600", size = 188639, upload-time = "2026-07-10T06:31:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/15/d6/ad87b2507e57de1cbf897a56c963f2925962ed5e85fbe06aaa83ced27acd/websockets-16.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83", size = 190078, upload-time = "2026-07-10T06:31:40.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1a/5b37b3fd335d5811f29fc829f2646a3e6d1463a4bf09c3100708684c766e/websockets-16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045", size = 189267, upload-time = "2026-07-10T06:31:41.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/98/06afc33e9450d4230f94c664db78875d90f5f6a5fb77f0bc6ec15ae74e1c/websockets-16.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9", size = 188022, upload-time = "2026-07-10T06:31:42.838Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/42fef5d5887c18cf2d148b02debf56cecb9cfbffc68027cde9b12c8f432c/websockets-16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf", size = 185435, upload-time = "2026-07-10T06:31:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9b/8021c133add5fe40ed40312553a6cd1408c069d7efe3444ad483d4973ed3/websockets-16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057", size = 188080, upload-time = "2026-07-10T06:31:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/69/54/1e37384f395eaa127383aab15c1c45e200890a7d7b99db5c312233d193e0/websockets-16.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d", size = 186678, upload-time = "2026-07-10T06:31:47.449Z" }, + { url = "https://files.pythonhosted.org/packages/68/79/1caeacab5bc2081e4519288d248bc8bd2de30652e6eaa94be6be09a1fe5b/websockets-16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb", size = 188554, upload-time = "2026-07-10T06:31:48.886Z" }, + { url = "https://files.pythonhosted.org/packages/ee/83/b3dca5fad71487b726e31cb0acf56f226792c1cc34e6ab18cbf146bd2d74/websockets-16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7", size = 186109, upload-time = "2026-07-10T06:31:50.508Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0b/8f246c3712f07f207b52ea5fb47f3b2b66fafec7303162644c74aed51c6a/websockets-16.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9", size = 187061, upload-time = "2026-07-10T06:31:51.861Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/27d6c92a01696b6495386af4fc941d7d0a13f2eab2bf9c336111d7321491/websockets-16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d", size = 187347, upload-time = "2026-07-10T06:31:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d5/eeee439921f55d5eaeabcea18d0f7ce32cdc39cb8fc1e185431a094c5c7b/websockets-16.1-cp313-cp313-win32.whl", hash = "sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4", size = 180149, upload-time = "2026-07-10T06:31:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/971e98d4a4864cf263f9e94c5b2b7c9a9b7682d77bfbba4e732c55ee85a9/websockets-16.1-cp313-cp313-win_amd64.whl", hash = "sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5", size = 180458, upload-time = "2026-07-10T06:31:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" }, + { url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" }, + { url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" }, + { url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" }, + { url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" }, + { url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" }, + { url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" }, + { url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" }, + { url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" }, + { url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" }, + { url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" }, + { url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" }, + { url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" }, + { url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f4/84ef884775bbe77c46cce79bc7d705ea3bc6574cc00acf81af89754c077d/websockets-16.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7289d899c79e763e6221c8dcb8959361cb43274418538d7c7ad16a43b01d12f9", size = 177387, upload-time = "2026-07-10T06:32:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d9/6831ec6f65e1eeac770375f4f4b604f23df9bafaa1b47004bc5f9488d513/websockets-16.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e22e9e3719f5131bd62da4db63c8da63eb8c91cc99e16c1cbd122f130e1ae07a", size = 177663, upload-time = "2026-07-10T06:32:50.043Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/21d4922fa7fe855813a8b38f181a0ecf02a586e16c1f095fd05471f78cc2/websockets-16.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83bdabafef431247e6b11a9aab8a0893fd8e82e1ed95b32e0373625b03ffce4a", size = 178501, upload-time = "2026-07-10T06:32:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/91/87/7a0320df854dacd09507ca972cb04a4dc5aae279583cc5b80ad5f5819533/websockets-16.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b8d13ceabc5c60995f201b5211d76876e17e68706ebf5d3bc666b32eefff1a6", size = 179397, upload-time = "2026-07-10T06:32:52.892Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/0da1eb8c8da2ace7b578c8523d32618af85e62a9ebad56051d4a14a38a1c/websockets-16.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81495f9c0085361c582efbc3207fb877174cfe03370f17d9cd70624404aa526f", size = 180546, upload-time = "2026-07-10T06:32:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" }, +] diff --git a/docker/band_python_kit/spec.yaml b/docker/band_python_kit/spec.yaml new file mode 100644 index 000000000..0f2950e38 --- /dev/null +++ b/docker/band_python_kit/spec.yaml @@ -0,0 +1,57 @@ +schemaVersion: "1" +kind: sandbox +name: band-python-kit +displayName: Band Python Agent +description: Run a workspace Python agent on Band inside a Docker Sandbox. + +sandbox: + image: band-python-kit:local + aiFilename: AGENTS.md + +# The agent launches headlessly at every sandbox start (no attach needed): +# the startup command chains through the base image entrypoint (proxy-CA +# refresh as root, then a setpriv drop to uid 1000) into the SDK launcher, +# which syncs the workspace's locked dependencies into a sandbox-owned venv +# and execs the configured customer entrypoint. Startup runs are idempotent; +# output lands in /var/log/sbx-kit-startup.log inside the sandbox. +# Paths below assume the image's default layout (BAND_SDK_HOME=/opt/band); +# a build that overrides --build-arg BAND_SDK_HOME needs a matching kit. +commands: + startup: + - command: + - /usr/local/bin/band-entrypoint.sh + - /opt/band/venv/bin/python + - -m + - band.docker.launcher + user: "0" + background: true + description: Launch the Band agent + +# Minimal egress for Band, locked dependency sync (PyPI), and the supported +# LLM backends (OpenAI, Anthropic, GitHub Copilot). The Copilot set is the +# empirically minimal one for the Copilot CLI (release-assets serves its +# self-download; the IDE-only copilot-proxy/origin-tracker hosts are not +# needed). Non-production Band hosts are granted per sandbox with +# `sbx policy allow network --sandbox `, never baked in here. +caps: + network: + allow: + - app.band.ai + - pypi.org + - files.pythonhosted.org + - api.openai.com + - api.anthropic.com + - github.com + - api.github.com + - release-assets.githubusercontent.com + - "*.githubcopilot.com" + - "*.individual.githubcopilot.com" + - "*.business.githubcopilot.com" + - "*.enterprise.githubcopilot.com" + +agentContext: | + This workspace contains a Python agent launched by the Band Sandbox kit. + Agent configuration is in band.yaml. + Dependency changes must update both pyproject.toml and uv.lock. + Supported LLM backends are OpenAI, Anthropic/Claude, and GitHub Copilot. + Never print or commit configured credentials. diff --git a/src/band/adapters/crewai_flow.py b/src/band/adapters/crewai_flow.py index ced416c0a..28bde02fa 100644 --- a/src/band/adapters/crewai_flow.py +++ b/src/band/adapters/crewai_flow.py @@ -15,8 +15,10 @@ from __future__ import annotations import asyncio +import hashlib import inspect import logging +import re from collections import OrderedDict from collections.abc import Awaitable, Mapping from contextvars import ContextVar @@ -1171,8 +1173,6 @@ async def execute_delegations( # Decide whether to prefix correlation token. content = item.content if target_counts.get(normalized, 0) > 1: - import hashlib - token = hashlib.sha256(side_effect_key.encode()).hexdigest()[:8] content = f"[ref:{token}] {content}" @@ -2206,8 +2206,6 @@ def _extract_tagged_peer_keys( participants: list[CrewAIFlowParticipantSnapshot], ) -> list[str]: """Return normalized keys of room participants tagged via @handle.""" - import re - if not content: return [] tokens = re.findall(r"@([A-Za-z0-9_./-]+)", content) @@ -2386,9 +2384,6 @@ def _candidates_matching_token( content: str, candidates: list[tuple[str, str, "CrewAIFlowMetadata"]], ) -> list[tuple[str, str, "CrewAIFlowMetadata"]] | None: - import hashlib - import re - if not content: return None match = re.search(r"\[ref:([0-9a-f]{8})\]", content) diff --git a/src/band/docker/launcher/README.md b/src/band/docker/launcher/README.md new file mode 100644 index 000000000..e11ab7a8e --- /dev/null +++ b/src/band/docker/launcher/README.md @@ -0,0 +1,69 @@ +# Sandbox kit launcher + +Boots a customer's Python workspace as a Band agent inside a +[`band-python-kit`](../../../../docker/band_python_kit/README.md) Docker +Sandbox. The kit's startup command runs it headlessly on every sandbox +start — after the base image entrypoint has installed the proxy CA and +dropped to the non-root agent user: + +```bash +$BAND_SDK_PYTHON -m band.docker.launcher +``` + +It is not an API for application code: customers configure it entirely +through the workspace's `band.yaml` (see the kit README and the +[echo-agent starter workspace](../../../../docker/band_python_kit/echo-agent/) +for the authoring guide); the SDK imports nothing from it. + +## What it does + +1. **`config`** — read `band.yaml` (strict: unknown fields fail) and the + documented environment overrides. Precedence: env → `band.yaml` → + production defaults. +2. **`paths`** — resolve and fence every configurable path: workspace + paths must stay inside the workspace (symlink escapes fail), runtime + paths (venv, state, cache, logs) must live outside both the mounted + workspace and the immutable SDK home. +3. **`credentials`** — optionally fill missing keys from the opt-in + workspace env file, after the `GUARDS` checklist in + [`credentials.py`](credentials.py) passes. The process environment + always wins; values are never logged. +4. **`repo`** — optionally materialize the project from Git (reuses + `band.docker.repo_init`: clone-or-validate under a file lock, optional + context indexing). The clone destination is always the fenced project + path — never configured separately — and must be a workspace + subdirectory; state/context live under the runtime state path. +5. **`sync`** — `uv sync --locked` with the image's pinned uv into a + sandbox-owned venv, serialized under a file lock. No lockfile, no + launch — resolution never happens at startup. +6. **`run`** — assemble the above into a `ResolvedLaunch`, then + `os.execve` the customer entrypoint with the customer venv's + interpreter, so signals reach customer code directly. + +Every failure is a `LaunchError` naming its phase, e.g. +`[credentials] credentials file must be gitignored: …`. No error, log +line, or diagnostic ever contains secret values. + +```python +from band.docker.launcher import LaunchError, ResolvedLaunch, resolve_launch + +assert callable(resolve_launch) +assert issubclass(LaunchError, ValueError) +assert "credentials" in ResolvedLaunch.model_fields +``` + +## Module map + +| Module | Concern | +|---|---| +| `config.py` | `band.yaml` models (strict) + `LauncherEnv` overrides | +| `paths.py` | path resolution and fencing rules | +| `credentials.py` | opt-in secrets file and its guard checklist | +| `bootstrap.py` | optional repository bootstrap via `repo_init` | +| `sync.py` | locked dependency sync via the pinned uv | +| `run.py` | phase assembly, child environment, exec, `main()` | +| `launch.py` | `ResolvedLaunch` — the model the phases hand around | +| `errors.py` | `LaunchError` (phase-attributed) | + +Tests live in `tests/docker/launcher/` (one file per concern), with the +kit-drift tests in `tests/docker/test_kit_spec.py`. diff --git a/src/band/docker/launcher/__init__.py b/src/band/docker/launcher/__init__.py new file mode 100644 index 000000000..960d22f9f --- /dev/null +++ b/src/band/docker/launcher/__init__.py @@ -0,0 +1,71 @@ +"""Sandbox kit launcher: boots a locked customer Python project as a Band agent. + +Runs as `$BAND_SDK_PYTHON -m band.docker.launcher` from the kit's startup +command, after the base image entrypoint has installed the proxy CA and +dropped to the non-root agent user. The flow, one module per concern: + +1. `config` — read `band.yaml` (strict) and the supported env overrides. +2. `paths` — resolve and fence every configurable path. +3. `credentials` — optionally fill missing keys from the opt-in workspace + env file, with its safety checks. +4. `bootstrap` — optionally materialize the project from Git into the + fenced project path (reuses `band.docker.repo_init`). +5. `sync` — `uv sync --locked` into a sandbox-owned venv, under a lock. +6. `run` — assemble the above, then `os.execve` the customer + entrypoint so signals reach customer code directly. + +`launch` holds the `ResolvedLaunch` model the phases hand to each other. + +Every failure is a :class:`LaunchError` naming its phase; no error, log +line, or diagnostic ever contains secret values. +""" + +from __future__ import annotations + +from band.docker.launcher.bootstrap import bootstrap_repository +from band.docker.launcher.config import ( + AGENT_HOME, + DEFAULT_REST_URL, + DEFAULT_WS_URL, + LauncherEnv, + RepoSection, + WorkspaceConfig, + load_workspace_config, +) +from band.docker.launcher.credentials import ( + CredentialName, + load_file_credentials, + resolve_credentials, +) +from band.docker.launcher.errors import LaunchError +from band.docker.launcher.launch import ResolvedLaunch +from band.docker.launcher.run import ( + AGENT_UID, + build_child_environment, + execute, + main, + resolve_launch, +) +from band.docker.launcher.sync import sync_customer_environment + +__all__ = [ + "AGENT_HOME", + "AGENT_UID", + "CredentialName", + "DEFAULT_REST_URL", + "DEFAULT_WS_URL", + "LaunchError", + "LauncherEnv", + "RepoSection", + "ResolvedLaunch", + "WorkspaceConfig", + "bootstrap_repository", + "build_child_environment", + "execute", + "load_file_credentials", + "load_workspace_config", + "main", + "resolve_credentials", + "resolve_launch", + "sync_customer_environment", +] diff --git a/src/band/docker/launcher/__main__.py b/src/band/docker/launcher/__main__.py new file mode 100644 index 000000000..f03f59f7e --- /dev/null +++ b/src/band/docker/launcher/__main__.py @@ -0,0 +1,7 @@ +"""Entry point for `python -m band.docker.launcher`.""" + +from __future__ import annotations + +from band.docker.launcher.run import main + +main() diff --git a/src/band/docker/launcher/bootstrap.py b/src/band/docker/launcher/bootstrap.py new file mode 100644 index 000000000..64a83da54 --- /dev/null +++ b/src/band/docker/launcher/bootstrap.py @@ -0,0 +1,55 @@ +"""Optional repository bootstrap: materialize the project from Git. + +Reuses :mod:`band.docker.repo_init` — clone into an empty destination, +validate remote/branch on an existing checkout, all under a file lock, with +optional context indexing. The clone destination is never configured +directly: it is always the resolved project path, which `paths` has already +fenced inside the workspace, so a workspace config cannot direct clone +writes anywhere else in the sandbox. State and context live under the +sandbox-owned runtime state path, never repo_init's `/workspace` defaults. +""" + +from __future__ import annotations + +import logging + +from band.docker.launcher.errors import LaunchError +from band.docker.launcher.launch import ResolvedLaunch +from band.docker.repo_init import initialize_repo + +logger = logging.getLogger(__name__) + +REPO_LOCK_TIMEOUT_S = 120.0 + + +def bootstrap_repository(launch: ResolvedLaunch) -> None: + """Clone or validate the configured repository at the project path.""" + repo = launch.repo + if repo is None: + return + config = { + "repo": { + "url": repo.url, + "path": str(launch.project), + "branch": repo.branch, + "index": repo.index, + } + } + try: + result = initialize_repo( + config, + agent_key=launch.agent_id, + state_dir=launch.state_path, + context_dir=launch.state_path / "context", + lock_timeout_s=REPO_LOCK_TIMEOUT_S, + ) + except LaunchError: + raise + except ValueError as exc: + raise LaunchError("repo", str(exc)) from exc + logger.info( + "Repository bootstrap at %s: cloned=%s indexed=%s", + launch.project, + result.cloned, + result.indexed, + ) diff --git a/src/band/docker/launcher/config.py b/src/band/docker/launcher/config.py new file mode 100644 index 000000000..f31a5bda0 --- /dev/null +++ b/src/band/docker/launcher/config.py @@ -0,0 +1,223 @@ +"""Workspace configuration: `band.yaml` (strict) and environment overrides. + +`band.yaml` is the customer's declaration of who the agent is and where +things live; every model forbids unknown fields so a typo fails the launch +instead of being silently ignored. `LauncherEnv` is the *only* supported +process-environment surface — a documented set of override variables plus +the values the sandbox runtime and image provide. + +Precedence for every value: environment override → `band.yaml` → documented +production defaults (endpoints only) → fail. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal +from urllib.parse import urlsplit + +import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from band.docker.launcher.errors import LaunchError +from band.docker.repo_init import is_https_url, is_ssh_url + +DEFAULT_CONFIG_FILENAME = "band.yaml" +DEFAULT_REST_URL = "https://app.band.ai" +DEFAULT_WS_URL = "wss://app.band.ai/api/v1/socket/websocket" +DEFAULT_SDK_HOME = "/opt/band" +# The startup chain inherits root's HOME across the privilege drop; every +# process the launcher starts must see the agent user's home instead. +AGENT_HOME = "/home/agent" + + +class AgentSection(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + id: str = "" + entrypoint: str = "main.py" + + +class BandSection(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + rest_url: str = Field(default="", alias="restUrl") + ws_url: str = Field(default="", alias="wsUrl") + + +class ProjectSection(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + path: str = "." + + +class RepoSection(BaseModel): + """Optional repository bootstrap: the project is cloned into the fenced + project path before dependency sync instead of arriving with the mount.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + url: str + branch: str | None = None + index: bool = False + + @field_validator("url") + @classmethod + def url_must_be_supported(cls, value: str) -> str: + """A structurally complete SSH/HTTPS remote with no embedded secrets. + + A blank URL must not slip through: repo_init normalizes it to None + and would silently fall into its URL-less local-only mode. Host and + path must parse so a malformed remote fails here, not at git time. + Userinfo tokens are rejected outright — band.yaml is a committed + file, and repo_init logs the URL and embeds it in git error text. + """ + cleaned = value.strip() + if not (is_ssh_url(cleaned) or is_https_url(cleaned)): + raise ValueError( + "must be an SSH (git@… / ssh://…) or https:// repository URL" + ) + if cleaned.startswith("git@"): + host, sep, path = cleaned.removeprefix("git@").partition(":") + if not (host and sep and path): + raise ValueError("an SCP-form SSH remote must look like git@host:path") + return cleaned + parts = urlsplit(cleaned) + _ = parts.port # force the lazy port parse + if not parts.hostname or len(parts.path) <= 1: + raise ValueError("must include a host and a repository path") + # ssh://git@host/… is the canonical SSH login user, not a secret; + # everything else in userinfo is a credential and never belongs in + # band.yaml — use the environment or the opt-in credential file. + if parts.password or (parts.scheme == "https" and parts.username): + raise ValueError( + "must not embed credentials; use the environment or the " + "opt-in credential file" + ) + return cleaned + + +class CredentialsSection(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + source: str + path: str + acknowledge_plaintext_in_sandbox: bool = Field( + default=False, alias="acknowledgePlaintextInSandbox" + ) + + +class RuntimeSection(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + environment_path: str = Field(alias="environmentPath") + state_path: str = Field(alias="statePath") + cache_path: str = Field(alias="cachePath") + log_path: str = Field(alias="logPath") + + +class WorkspaceConfig(BaseModel): + """The customer's ``band.yaml``.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + # The one schema version this launcher implements. A file declaring any + # other version was written for different semantics and must not be + # interpreted with this model. + schema_version: Literal["1"] = Field(alias="schemaVersion") + agent: AgentSection = AgentSection() + band: BandSection = BandSection() + project: ProjectSection = ProjectSection() + repo: RepoSection | None = None + credentials: CredentialsSection | None = None + runtime: RuntimeSection + + +class LauncherEnv(BaseSettings): + """Supported environment overrides plus runtime-provided values. + + Field name == env var name (case-insensitive); everything else in the + process environment is ignored here and passed through to the customer + process untouched. + """ + + model_config = SettingsConfigDict(extra="ignore", case_sensitive=False) + + # Identity / endpoints / credentials overrides. + band_agent_id: str = "" + band_api_key: str = "" + band_rest_url: str = "" + band_ws_url: str = "" + openai_api_key: str = "" + anthropic_api_key: str = "" + copilot_github_token: str = "" + gh_token: str = "" + github_token: str = "" + + # The one path override: where to find band.yaml. Every other path is + # workspace configuration — band.yaml owns it. + band_kit_config_path: str = "" + + # Image / sandbox-runtime contract values. + workspace_dir: str = "" + band_sdk_uv: str = "" + band_sdk_home: str = DEFAULT_SDK_HOME + + +def load_workspace_config(config_path: Path) -> WorkspaceConfig: + """Load and strictly validate ``band.yaml``.""" + if not config_path.is_file(): + raise LaunchError("config", f"workspace configuration not found: {config_path}") + try: + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise LaunchError("config", f"invalid YAML in {config_path}: {exc}") from exc + if not isinstance(raw, dict): + raise LaunchError("config", f"{config_path} must contain a YAML mapping") + try: + return WorkspaceConfig.model_validate(raw) + except ValidationError as exc: + details = "; ".join( + f"{'.'.join(str(x) for x in err['loc'])}: {err['msg']}" + for err in exc.errors() + ) + raise LaunchError("config", f"invalid {config_path}: {details}") from exc + + +def require_url(value: str, *, scheme: str, name: str) -> str: + """A structurally valid endpoint: the right scheme AND a hostname, so a + bad URL fails this phase instead of the first connect after the sync.""" + try: + parts = urlsplit(value) + # netloc is truthy for host-less forms like "https://:443" and + # "wss://user@" — only hostname proves a host. Reading port forces + # its lazy parse so "https://host:not-a-port" fails here too. + hostname, _ = parts.hostname, parts.port + except ValueError as exc: + raise LaunchError("config", f"{name} is not a valid URL: {value!r}") from exc + if parts.scheme != scheme or not hostname: + raise LaunchError( + "config", f"{name} must be a {scheme}:// URL with a host, got {value!r}" + ) + return value + + +def resolve_endpoints(config: WorkspaceConfig, env: LauncherEnv) -> tuple[str, str]: + """Resolve REST/WS URLs: env override → band.yaml → production defaults.""" + rest = env.band_rest_url or config.band.rest_url or DEFAULT_REST_URL + ws = env.band_ws_url or config.band.ws_url or DEFAULT_WS_URL + return ( + require_url(rest, scheme="https", name="BAND_REST_URL"), + require_url(ws, scheme="wss", name="BAND_WS_URL"), + ) + + +def resolve_agent_id(config: WorkspaceConfig, env: LauncherEnv) -> str: + agent_id = env.band_agent_id or config.agent.id + if not agent_id: + raise LaunchError( + "config", + "agent id missing: set agent.id in band.yaml or BAND_AGENT_ID", + ) + return agent_id diff --git a/src/band/docker/launcher/credentials.py b/src/band/docker/launcher/credentials.py new file mode 100644 index 000000000..9939d0e69 --- /dev/null +++ b/src/band/docker/launcher/credentials.py @@ -0,0 +1,313 @@ +"""Opt-in temporary credential custody from a workspace secrets file. + +Disabled unless `band.yaml` selects `source: workspace-env-file` AND +acknowledges that plaintext keys exist in both the host workspace and the +sandbox VM. `SecretsFile.locate` guarantees the configured path stays inside +the workspace; every other safeguard is one named guard in `GUARDS` — the +tuple below *is* the security policy, in the order it is enforced. Values +already present in the process environment always win (the file only fills +gaps) and are never logged. +""" + +from __future__ import annotations + +import logging +import os +import stat +import subprocess +import tempfile +from dataclasses import dataclass +from enum import StrEnum +from functools import cached_property +from pathlib import Path +from typing import Callable + +from dotenv import dotenv_values + +from band.docker.launcher.config import ( + CredentialsSection, + LauncherEnv, + WorkspaceConfig, +) +from band.docker.launcher.errors import LaunchError +from band.docker.launcher.paths import resolve_inside + +logger = logging.getLogger(__name__) + + +class CredentialName(StrEnum): + """The only names the secrets file may define. + + Anything else is rejected, never silently dropped, so a typo'd or + smuggled variable is caught before customer code runs. + """ + + BAND_API_KEY = "BAND_API_KEY" + OPENAI_API_KEY = "OPENAI_API_KEY" + ANTHROPIC_API_KEY = "ANTHROPIC_API_KEY" + COPILOT_GITHUB_TOKEN = "COPILOT_GITHUB_TOKEN" + GH_TOKEN = "GH_TOKEN" + GITHUB_TOKEN = "GITHUB_TOKEN" + + +@dataclass +class SecretsFile: + """The opt-in plaintext secrets file inside the customer workspace.""" + + raw_path: Path # as configured, before symlink resolution + path: Path # fully resolved + workspace: Path + + @classmethod + def locate(cls, workspace: Path, configured: str) -> SecretsFile: + """Resolve the configured path, guaranteeing workspace containment.""" + resolved = resolve_inside( + workspace, configured, name="credentials file", phase="credentials" + ) + raw = Path(configured) + return cls( + raw_path=raw if raw.is_absolute() else workspace / raw, + path=resolved, + workspace=workspace, + ) + + @cached_property + def values(self) -> dict[str, str]: + """Parsed name -> value pairs (dotenv: no execution, no sourcing).""" + return { + key: value + for key, value in dotenv_values(self.path).items() + if value is not None + } + + +def never_traverses_a_symlink(secrets: SecretsFile) -> None: + """A link anywhere below the workspace is an unexpected indirection.""" + for candidate in [secrets.raw_path, *secrets.raw_path.parents]: + if candidate == secrets.workspace: + return + if candidate.is_symlink(): + raise LaunchError( + "credentials", + f"credentials file path must not traverse a symlink: {candidate}", + ) + + +def is_a_file(secrets: SecretsFile) -> None: + if not secrets.path.is_file(): + raise LaunchError("credentials", f"credentials file not found: {secrets.path}") + + +def has_owner_only_permissions(secrets: SecretsFile) -> None: + """Group/other access to plaintext keys is always a mistake.""" + mode = stat.S_IMODE(secrets.path.stat().st_mode) + if mode & 0o077: + raise LaunchError( + "credentials", + f"credentials file must be owner-only (e.g. 600), got {mode:o}: " + f"{secrets.path}", + ) + + +def run_git(workspace: Path, *args: str) -> subprocess.CompletedProcess[str]: + """Run git against the workspace, tolerating a foreign-uid bind mount. + + `-c safe.directory=`: a bind-mounted workspace is often owned + by a different uid than the agent user, which trips Git's dubious- + ownership guard and would otherwise make every git call here fail. + + `LC_ALL=C` keeps git's diagnostics untranslated: the not-a-repository + verdict shares its exit code (128) with every other fatal git error and + is recognizable only by message text. + """ + command = ["git", "-c", f"safe.directory={workspace}", "-C", str(workspace), *args] + return subprocess.run( + command, + capture_output=True, + text=True, + check=False, + env={**os.environ, "LC_ALL": "C"}, + ) + + +def require_never_tracked(secrets: SecretsFile) -> None: + """A committed secrets file leaks with every clone.""" + tracked = run_git( + secrets.workspace, "ls-files", "--error-unmatch", str(secrets.path) + ) + if tracked.returncode == 0: + raise LaunchError( + "credentials", + f"credentials file is tracked by Git — never commit it: {secrets.path}", + ) + if tracked.returncode != 1: + raise LaunchError( + "credentials", + "git could not determine the credentials file's tracked state: " + + (tracked.stderr or "").strip(), + ) + + +def require_gitignored(secrets: SecretsFile, *git_options: str) -> None: + """An unignored secrets file is one `git add .` away from a leak.""" + ignored = run_git( + secrets.workspace, *git_options, "check-ignore", "-q", str(secrets.path) + ) + if ignored.returncode == 1: + raise LaunchError( + "credentials", + f"credentials file must be gitignored: {secrets.path}", + ) + if ignored.returncode != 0: + raise LaunchError( + "credentials", + "git could not determine the credentials file's ignore state: " + + (ignored.stderr or "").strip(), + ) + + +def require_gitignored_before_init(secrets: SecretsFile) -> None: + """No repository yet, but the ignore rule must already exist — a later + `git init` + `git add .` would otherwise commit the plaintext file. A + scratch git dir lets check-ignore evaluate the workspace's ignore rules + without initializing anything inside the workspace.""" + with tempfile.TemporaryDirectory(prefix="band-launcher-scratch-git-") as scratch: + repo = Path(scratch) / "repo" + created = run_git(secrets.workspace, "init", "-q", str(repo)) + if created.returncode != 0: + raise LaunchError( + "credentials", + "git could not evaluate the workspace ignore rules: " + + (created.stderr or "").strip(), + ) + require_gitignored( + secrets, + "--git-dir", + str(repo / ".git"), + "--work-tree", + str(secrets.workspace), + ) + + +# Git's own verdict for a genuinely non-repo directory — the only outcome +# allowed to relax this guard (to the pre-init ignore-rule check). Any other +# failure fails closed. +NOT_A_REPOSITORY = "not a git repository" + + +def is_gitignored_and_never_tracked(secrets: SecretsFile) -> None: + """Inside a repository, the file must be gitignored and untracked. + Outside one, nothing can be tracked yet, but the ignore rule is still + required so a later `git init` cannot expose the file. Any other outcome + — git unable to answer (corrupt or unreadable metadata) — fails the + launch (closed); this guard must never be silently disabled.""" + inside = run_git(secrets.workspace, "rev-parse", "--is-inside-work-tree") + if inside.returncode == 0 and inside.stdout.strip() == "true": + require_never_tracked(secrets) + require_gitignored(secrets) + return + if NOT_A_REPOSITORY in (inside.stderr or "").lower(): + require_gitignored_before_init(secrets) + return + detail = (inside.stderr or inside.stdout).strip() or f"exit {inside.returncode}" + raise LaunchError( + "credentials", + f"git could not determine whether the workspace is a repository: {detail}", + ) + + +def defines_only_documented_names(secrets: SecretsFile) -> None: + unknown = sorted(set(secrets.values) - {name.value for name in CredentialName}) + if unknown: + raise LaunchError( + "credentials", + "credentials file defines undocumented names: " + ", ".join(unknown), + ) + + +# The security policy, in enforcement order. Containment (the file must stay +# inside the workspace) is guaranteed earlier, by SecretsFile.locate. +GUARDS: tuple[Callable[[SecretsFile], None], ...] = ( + never_traverses_a_symlink, + is_a_file, + has_owner_only_permissions, + is_gitignored_and_never_tracked, + defines_only_documented_names, +) + + +def require_explicit_opt_in(section: CredentialsSection) -> None: + """Plaintext custody is never implicit: supported source + acknowledgement.""" + if section.source != "workspace-env-file": + raise LaunchError( + "credentials", + f"unsupported credentials.source: {section.source!r} " + "(only 'workspace-env-file' is supported)", + ) + if not section.acknowledge_plaintext_in_sandbox: + raise LaunchError( + "credentials", + "credentials.acknowledgePlaintextInSandbox: true is required — the " + "workspace env file places plaintext keys in both the host " + "workspace and the sandbox VM", + ) + + +def guarded_secrets_file(workspace: Path, configured: str) -> SecretsFile: + """Locate the file and enforce every guard before its values are used.""" + secrets = SecretsFile.locate(workspace, configured) + for guard in GUARDS: + try: + guard(secrets) + except OSError as exc: + # e.g. a host-uid-owned 600 file is unreadable to the agent user — + # keep the failure attributed to its phase. + raise LaunchError( + "credentials", f"credentials file is not readable: {exc}" + ) from exc + return secrets + + +def missing_from_environment(secrets: SecretsFile, env: LauncherEnv) -> dict[str, str]: + """Existing process environment always wins; the file only fills gaps.""" + env_values = env.model_dump() + return { + name: value + for name, value in secrets.values.items() + if not env_values.get(name.lower()) + } + + +def load_file_credentials( + config: WorkspaceConfig, env: LauncherEnv, workspace: Path +) -> dict[str, str]: + """Return name -> value for documented credentials missing from the env.""" + section = config.credentials + if section is None: + return {} + require_explicit_opt_in(section) + secrets = guarded_secrets_file(workspace, section.path) + resolved = missing_from_environment(secrets, env) + logger.info( + "Loaded %d credential value(s) from the workspace env file", len(resolved) + ) + return resolved + + +def resolve_credentials( + config: WorkspaceConfig, env: LauncherEnv, workspace: Path +) -> dict[str, str]: + """Canonical name -> value for every credential the customer process + must see: the process environment first, the opt-in file filling gaps. + + Validation accepts environment names case-insensitively (and library + callers may construct LauncherEnv programmatically), so inheriting the + raw environment cannot guarantee the child receives the documented + uppercase names — this mapping can.""" + file_values = load_file_credentials(config, env, workspace) + env_values = env.model_dump() + return { + name.value: value + for name in CredentialName + if (value := env_values.get(name.lower()) or file_values.get(name)) + } diff --git a/src/band/docker/launcher/errors.py b/src/band/docker/launcher/errors.py new file mode 100644 index 000000000..d996e08d1 --- /dev/null +++ b/src/band/docker/launcher/errors.py @@ -0,0 +1,15 @@ +"""The launcher's single failure type.""" + +from __future__ import annotations + + +class LaunchError(ValueError): + """A launch failure attributed to a named phase. + + The message is safe to log: phases never interpolate secret values, + authorization headers, or complete environments. + """ + + def __init__(self, phase: str, message: str) -> None: + self.phase = phase + super().__init__(f"[{phase}] {message}") diff --git a/src/band/docker/launcher/launch.py b/src/band/docker/launcher/launch.py new file mode 100644 index 000000000..418c725fc --- /dev/null +++ b/src/band/docker/launcher/launch.py @@ -0,0 +1,37 @@ +"""The resolved launch: the one data model every launcher phase works on. + +Produced by `run.resolve_launch` and consumed by `sync` and the exec — +kept in its own module so producer and consumers share it without a cycle. +""" + +from __future__ import annotations + +from pathlib import Path + +from pydantic import BaseModel, ConfigDict + +from band.docker.launcher.config import RepoSection + + +class ResolvedLaunch(BaseModel): + """Everything the launch needs, fully resolved and validated.""" + + model_config = ConfigDict(extra="forbid") + + workspace: Path + project: Path + entrypoint: Path + environment_path: Path + state_path: Path + cache_path: Path + log_path: Path + uv_binary: Path + agent_id: str + rest_url: str + ws_url: str + # Canonical credential name -> value for the child environment: process + # environment first, the opt-in workspace file filling gaps. Never logged. + credentials: dict[str, str] = {} + # Optional repository bootstrap; the clone destination is always the + # fenced project path above, never configured separately. + repo: RepoSection | None = None diff --git a/src/band/docker/launcher/paths.py b/src/band/docker/launcher/paths.py new file mode 100644 index 000000000..8fa62ab2a --- /dev/null +++ b/src/band/docker/launcher/paths.py @@ -0,0 +1,109 @@ +"""Path rules: customer-configurable paths are resolved and fenced. + +Workspace-relative paths (project, entrypoint, credential file) must stay +inside their permitted root after resolving symlinks — traversal and link +escapes fail the launch. Sandbox-owned runtime paths (venv, state, cache, +logs) must be absolute and live *outside* both the mounted workspace (a +direct mount is the host directory) and the immutable SDK home. + +""" + +from __future__ import annotations + +from pathlib import Path +from typing import NamedTuple + +from band.docker.launcher.config import DEFAULT_SDK_HOME, LauncherEnv, WorkspaceConfig +from band.docker.launcher.errors import LaunchError + + +class ResolvedPaths(NamedTuple): + """Validated project, entrypoint, and sandbox-owned runtime paths. + + Field names match ``ResolvedLaunch``'s so values flow through by name. + """ + + project: Path + entrypoint: Path + environment_path: Path + state_path: Path + cache_path: Path + log_path: Path + + +def resolve_inside(base: Path, value: str, *, name: str, phase: str) -> Path: + """Resolve ``value`` against ``base`` and require the result stays inside.""" + candidate = Path(value) + resolved = (candidate if candidate.is_absolute() else base / candidate).resolve() + if not resolved.is_relative_to(base.resolve()): + raise LaunchError(phase, f"{name} escapes its permitted root {base}: {value!r}") + return resolved + + +def require_outside( + path: Path, *, forbidden: list[tuple[Path, str]], name: str +) -> Path: + """Require an absolute path that lives outside every forbidden root.""" + if not path.is_absolute(): + raise LaunchError("paths", f"{name} must be an absolute path: {path}") + resolved = path.resolve() + for root, label in forbidden: + if resolved.is_relative_to(root.resolve()): + raise LaunchError("paths", f"{name} must live outside {label}: {resolved}") + return resolved + + +def require_project_materialized(project: Path, entrypoint: Path) -> None: + """The project and entrypoint must exist on disk — at resolve time for a + mounted project, or right after bootstrap for a cloned one.""" + if not project.is_dir(): + raise LaunchError("paths", f"project path is not a directory: {project}") + if not entrypoint.is_file(): + raise LaunchError( + "paths", f"entrypoint is not a file inside the project: {entrypoint}" + ) + + +def resolve_paths( + config: WorkspaceConfig, env: LauncherEnv, workspace: Path +) -> ResolvedPaths: + """Resolve and validate project, entrypoint, and runtime paths. + + Containment fencing is pure path math and always runs here. Existence is + checked here only for a mounted project; with a ``repo`` section the + project materializes at bootstrap, and ``execute`` re-checks it then. + """ + project = resolve_inside( + workspace, config.project.path, name="project path", phase="paths" + ) + entrypoint = resolve_inside( + project, config.agent.entrypoint, name="entrypoint", phase="paths" + ) + if config.repo is None: + require_project_materialized(project, entrypoint) + elif project == workspace.resolve(): + # The workspace root holds band.yaml, so it is non-empty and not a + # repository — repo_init could never clone into it. + raise LaunchError( + "repo", + "project.path must be a subdirectory of the workspace when repo.url is set", + ) + + # `or DEFAULT_SDK_HOME`: a present-but-empty BAND_SDK_HOME must not + # collapse the SDK-home fence to the current directory (Path("") == "."). + sdk_home = Path(env.band_sdk_home or DEFAULT_SDK_HOME) + forbidden = [(workspace, "the mounted workspace"), (sdk_home, "the SDK home")] + runtime = { + field: require_outside(Path(configured), forbidden=forbidden, name=name) + for field, configured, name in ( + ( + "environment_path", + config.runtime.environment_path, + "runtime.environmentPath", + ), + ("state_path", config.runtime.state_path, "runtime.statePath"), + ("cache_path", config.runtime.cache_path, "runtime.cachePath"), + ("log_path", config.runtime.log_path, "runtime.logPath"), + ) + } + return ResolvedPaths(project=project, entrypoint=entrypoint, **runtime) diff --git a/src/band/docker/launcher/run.py b/src/band/docker/launcher/run.py new file mode 100644 index 000000000..9a13cd854 --- /dev/null +++ b/src/band/docker/launcher/run.py @@ -0,0 +1,195 @@ +"""Launch assembly: resolve everything, then exec the customer entrypoint. + +`resolve_launch` performs every check that can fail fast (identity, config, +paths, credentials) and returns a fully validated `ResolvedLaunch`. +`execute` then does the side-effect work — the locked dependency sync — and +finally replaces this process with the customer entrypoint via `os.execve`, +so signals (e.g. SIGTERM from `sbx stop`) reach customer code directly. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +from band.docker.launcher.config import ( + AGENT_HOME, + DEFAULT_CONFIG_FILENAME, + LauncherEnv, + load_workspace_config, + resolve_agent_id, + resolve_endpoints, +) +from band.docker.launcher.bootstrap import bootstrap_repository +from band.docker.launcher.credentials import resolve_credentials +from band.docker.launcher.errors import LaunchError +from band.docker.launcher.launch import ResolvedLaunch +from band.docker.launcher.paths import require_project_materialized, resolve_paths +from band.docker.launcher.sync import sync_customer_environment + +logger = logging.getLogger(__name__) + +AGENT_UID = 1000 + + +def current_uid() -> int: + """Seam for tests: the uid the launcher believes it runs as.""" + return os.getuid() + + +def require_agent_uid() -> None: + """The launcher must run after the base entrypoint's privilege drop.""" + uid = current_uid() + if uid != AGENT_UID: + raise LaunchError( + "identity", + f"launcher must run as uid {AGENT_UID} (after the base entrypoint's " + f"privilege drop), got uid {uid}", + ) + + +def resolve_workspace(env: LauncherEnv) -> Path: + """The sandbox runtime's mounted workspace, verified to exist.""" + if not env.workspace_dir: + raise LaunchError( + "config", "WORKSPACE_DIR is not set — is this a Docker Sandbox?" + ) + workspace = Path(env.workspace_dir).resolve() + if not workspace.is_dir(): + raise LaunchError("config", f"workspace does not exist: {workspace}") + return workspace + + +def locate_config_path(env: LauncherEnv, workspace: Path) -> Path: + """Honor the one supported path override, else the workspace default.""" + if env.band_kit_config_path: + return Path(env.band_kit_config_path) + return workspace / DEFAULT_CONFIG_FILENAME + + +def require_band_api_key(credentials: dict[str, str]) -> None: + """The one credential the agent cannot start without.""" + if not credentials.get("BAND_API_KEY"): + raise LaunchError( + "credentials", + "BAND_API_KEY missing: set it in the environment or provide it " + "via the configured workspace env file", + ) + + +def resolve_uv_binary(env: LauncherEnv) -> Path: + """The image's pinned uv, required by the image contract.""" + if not env.band_sdk_uv: + raise LaunchError("sync", "BAND_SDK_UV is not set — image contract broken") + return Path(env.band_sdk_uv) + + +def resolve_launch(env: LauncherEnv | None = None) -> ResolvedLaunch: + """Fail-fast phases: identity, config, endpoints, paths, credentials.""" + env = env or LauncherEnv() + + require_agent_uid() + workspace = resolve_workspace(env) + config = load_workspace_config(locate_config_path(env, workspace)) + + rest_url, ws_url = resolve_endpoints(config, env) + agent_id = resolve_agent_id(config, env) + paths = resolve_paths(config, env, workspace) + credentials = resolve_credentials(config, env, workspace) + require_band_api_key(credentials) + + return ResolvedLaunch( + workspace=workspace, + **paths._asdict(), + uv_binary=resolve_uv_binary(env), + agent_id=agent_id, + rest_url=rest_url, + ws_url=ws_url, + credentials=credentials, + repo=config.repo, + ) + + +def build_child_environment(launch: ResolvedLaunch) -> dict[str, str]: + """Construct the exact child environment. Never logged.""" + child = dict(os.environ) + # Canonical names, whatever casing or source validation accepted. + child.update(launch.credentials) + child["BAND_AGENT_ID"] = launch.agent_id + child["BAND_REST_URL"] = launch.rest_url + child["BAND_WS_URL"] = launch.ws_url + # The startup chain inherits root's HOME across the setpriv drop. + child["HOME"] = AGENT_HOME + return child + + +def execute(launch: ResolvedLaunch) -> None: + """Side-effect phases: repository bootstrap, the locked sync, the exec.""" + bootstrap_repository(launch) + # With a repo section the project only exists after bootstrap, so its + # existence check runs here instead of at resolve time. + require_project_materialized(launch.project, launch.entrypoint) + sync_customer_environment(launch) + + interpreter = launch.environment_path / "bin" / "python" + if not interpreter.is_file(): + raise LaunchError( + "exec", f"customer interpreter missing after sync: {interpreter}" + ) + + child_env = build_child_environment(launch) + os.chdir(launch.project) + logger.info( + "Launching customer entrypoint %s with %s", launch.entrypoint, interpreter + ) + os.execve(str(interpreter), [str(interpreter), str(launch.entrypoint)], child_env) + + +def launcher_formatter() -> logging.Formatter: + """The one shape of every launcher diagnostic line, on every handler: + str.format-style fields with ISO 8601 timestamps.""" + return logging.Formatter( + "{asctime} {name} {levelname} {message}", + datefmt="%Y-%m-%dT%H:%M:%S%z", + style="{", + ) + + +def configure_logging() -> None: + """Stream diagnostics to stderr (the startup dispatcher's log).""" + handler = logging.StreamHandler() + handler.setFormatter(launcher_formatter()) + logging.basicConfig(level=logging.INFO, handlers=[handler]) + + +def add_log_file(log_path: Path) -> None: + """Mirror diagnostics into the configured runtime log directory.""" + log_path.mkdir(parents=True, exist_ok=True) + handler = logging.FileHandler(log_path / "launcher.log") + handler.setFormatter(launcher_formatter()) + logging.getLogger().addHandler(handler) + + +def main() -> None: + configure_logging() + # Correct the process-wide HOME once, at the boundary: the startup chain + # inherits root's HOME across the setpriv drop, and everything below — + # git subprocesses (credentials check, repo_init clone), uv, the customer + # process — must see the agent user's home. The explicit HOME entries in + # the sync/child environments stay as the contract for library callers + # that bypass main(). + os.environ["HOME"] = AGENT_HOME + try: + launch = resolve_launch() + add_log_file(launch.log_path) + execute(launch) + except LaunchError as exc: + logger.error("Launch failed: %s", exc) + raise SystemExit(1) from exc + except OSError as exc: + # Filesystem/exec errors outside a named phase (e.g. an unwritable + # log directory, ENOEXEC from the customer interpreter) — still a + # clean diagnostic, never a raw traceback. + logger.error("Launch failed: %s", exc) + raise SystemExit(1) from exc diff --git a/src/band/docker/launcher/sync.py b/src/band/docker/launcher/sync.py new file mode 100644 index 000000000..c9988cb0d --- /dev/null +++ b/src/band/docker/launcher/sync.py @@ -0,0 +1,95 @@ +"""Locked dependency synchronization into the sandbox-owned environment. + +Runs the image's pinned `uv` with `sync --locked` against the customer +project — resolution never happens at startup, so a missing or stale +`uv.lock` fails with clear guidance instead of silently drifting. The sync +is serialized with a file lock in sandbox state so a concurrent restart can +never corrupt a half-built environment. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +import sys +from pathlib import Path + +from filelock import FileLock, Timeout + +from band.docker.launcher.config import AGENT_HOME +from band.docker.launcher.errors import LaunchError +from band.docker.launcher.launch import ResolvedLaunch + +logger = logging.getLogger(__name__) + +LOCK_TIMEOUT_S = 600.0 + + +def require_locked_project(launch: ResolvedLaunch) -> None: + """The project must ship a committed lock; the image must ship its uv.""" + if not (launch.project / "pyproject.toml").is_file(): + raise LaunchError("sync", f"pyproject.toml not found in {launch.project}") + if not (launch.project / "uv.lock").is_file(): + raise LaunchError( + "sync", + f"uv.lock not found in {launch.project} — commit a lock " + "(`uv lock`); unlocked resolution is not supported", + ) + if not launch.uv_binary.is_file(): + raise LaunchError("sync", f"pinned runtime uv not found at {launch.uv_binary}") + + +def sync_environment(launch: ResolvedLaunch) -> dict[str, str]: + """The environment the pinned uv runs under: fully pinned, no downloads.""" + sync_env = dict(os.environ) + sync_env["UV_PROJECT_ENVIRONMENT"] = str(launch.environment_path) + sync_env["UV_CACHE_DIR"] = str(launch.cache_path) + sync_env["HOME"] = AGENT_HOME + # Build the customer venv on the image's base interpreter (the SDK + # venv's own base, not the venv python) and fail clearly if the + # project's requires-python can't accept it — never let uv try to + # download a managed interpreter inside the egress-fenced sandbox. + sync_env["UV_PYTHON"] = str(Path(sys.base_prefix) / "bin" / "python3") + sync_env["UV_PYTHON_DOWNLOADS"] = "never" + return sync_env + + +def run_locked_sync(launch: ResolvedLaunch) -> None: + """Invoke the pinned uv and shape its failure into launch guidance.""" + logger.info("Synchronizing locked dependencies into %s", launch.environment_path) + result = subprocess.run( + [str(launch.uv_binary), "sync", "--locked"], + cwd=launch.project, + env=sync_environment(launch), + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + tail = "\n".join((result.stderr or "").strip().splitlines()[-15:]) + raise LaunchError( + "sync", + "locked dependency synchronization failed (is uv.lock up to " + f"date with pyproject.toml?):\n{tail}", + ) + + +def sync_customer_environment(launch: ResolvedLaunch) -> None: + """Run the pinned ``uv sync --locked``, serialized against restarts.""" + require_locked_project(launch) + + launch.state_path.mkdir(parents=True, exist_ok=True) + lock = FileLock(str(launch.state_path / "dependency_sync.lock")) + try: + lock.acquire(timeout=LOCK_TIMEOUT_S) + except Timeout: + raise LaunchError( + "sync", + f"timed out waiting for the dependency-sync lock after " + f"{LOCK_TIMEOUT_S:.0f}s", + ) from None + try: + run_locked_sync(launch) + finally: + lock.release() diff --git a/src/band/docker/repo_init.py b/src/band/docker/repo_init.py index 27070e9b0..e663a60d7 100644 --- a/src/band/docker/repo_init.py +++ b/src/band/docker/repo_init.py @@ -22,6 +22,7 @@ from typing import Any, Generator from urllib.parse import urlparse +from filelock import FileLock, Timeout from pydantic import BaseModel, ValidationError, field_validator logger = logging.getLogger(__name__) @@ -594,8 +595,6 @@ def _locked_file(path: Path, *, timeout_s: float) -> Generator[None, None, None] ``msvcrt`` on Windows) so multi-agent startup stays concurrency-safe on every platform the SDK runs on. """ - from filelock import FileLock, Timeout - path.parent.mkdir(parents=True, exist_ok=True) lock = FileLock(str(path)) try: diff --git a/src/band/integrations/langgraph/graph_tools.py b/src/band/integrations/langgraph/graph_tools.py index bbab96bb9..d861952db 100644 --- a/src/band/integrations/langgraph/graph_tools.py +++ b/src/band/integrations/langgraph/graph_tools.py @@ -6,7 +6,7 @@ import logging import uuid from typing import Annotated, Any, Callable, cast -from pydantic import create_model +from pydantic import Field, create_model from langchain_core.tools import BaseTool, InjectedToolArg, tool from langchain_core.runnables import RunnableConfig from langgraph.pregel import Pregel @@ -132,8 +132,6 @@ def graph_as_tool( # Create Pydantic model for the input schema # This ensures LangChain knows what parameters the tool expects - from pydantic import Field - field_definitions = {} for param_name, param_desc in input_schema.items(): field_definitions[param_name] = (Any, Field(description=param_desc)) diff --git a/tests/docker/launcher/__init__.py b/tests/docker/launcher/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/docker/launcher/conftest.py b/tests/docker/launcher/conftest.py new file mode 100644 index 000000000..5090b83d6 --- /dev/null +++ b/tests/docker/launcher/conftest.py @@ -0,0 +1,35 @@ +"""Fixtures for launcher unit tests. + +Scrubs every supported launcher environment variable so a developer's shell +(or .env.test in full-suite runs) can never leak values into LauncherEnv, +and pins the apparent uid to the agent uid the launcher requires. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from band.docker.launcher import AGENT_UID, LauncherEnv +from band.docker.launcher import run as launcher_run + +from .fakes import Workspace, make_workspace + +SCRUBBED_ENV_VARS = [name.upper() for name in LauncherEnv.model_fields] + + +@pytest.fixture(autouse=True) +def scrub_launcher_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name in SCRUBBED_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture(autouse=True) +def as_agent_uid(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(launcher_run, "current_uid", lambda: AGENT_UID) + + +@pytest.fixture +def workspace(tmp_path: Path) -> Workspace: + return make_workspace(tmp_path) diff --git a/tests/docker/launcher/fakes.py b/tests/docker/launcher/fakes.py new file mode 100644 index 000000000..d909cff89 --- /dev/null +++ b/tests/docker/launcher/fakes.py @@ -0,0 +1,121 @@ +"""Shared builders for launcher unit tests: a realistic customer workspace.""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from band.docker.launcher import LauncherEnv + + +@dataclass +class Workspace: + """A disposable customer workspace plus its sandbox-side runtime root.""" + + root: Path + runtime_root: Path + uv_binary: Path + + @property + def config_path(self) -> Path: + return self.root / "band.yaml" + + +def default_config(workspace: Workspace) -> dict[str, Any]: + """A fully valid band.yaml payload for ``workspace``.""" + rt = workspace.runtime_root + return { + "schemaVersion": "1", + "agent": {"id": "agent-123", "entrypoint": "main.py"}, + "band": { + "restUrl": "https://platform.example.test", + "wsUrl": "wss://platform.example.test/socket", + }, + "project": {"path": "."}, + "runtime": { + "environmentPath": str(rt / "venv"), + "statePath": str(rt / "state"), + "cachePath": str(rt / "cache"), + "logPath": str(rt / "logs"), + }, + } + + +def write_config(workspace: Workspace, config: dict[str, Any]) -> None: + workspace.config_path.write_text(yaml.safe_dump(config), encoding="utf-8") + + +def make_workspace(tmp_path: Path, *, git: bool = True) -> Workspace: + """Create a valid locked customer project under ``tmp_path``. + + The runtime root and the fake pinned uv binary live outside the + workspace, matching the sandbox layout the path rules enforce. + """ + root = tmp_path / "workspace" + root.mkdir() + (root / "main.py").write_text("print('agent')\n", encoding="utf-8") + (root / "pyproject.toml").write_text( + '[project]\nname = "customer"\nversion = "0.1.0"\n', encoding="utf-8" + ) + (root / "uv.lock").write_text("# lock\n", encoding="utf-8") + (root / ".gitignore").write_text(".band/\n", encoding="utf-8") + + runtime_root = tmp_path / "band-kit-runtime" + runtime_root.mkdir() + + uv_binary = tmp_path / "pinned-uv" + uv_binary.write_text("#!/bin/sh\n", encoding="utf-8") + + workspace = Workspace(root=root, runtime_root=runtime_root, uv_binary=uv_binary) + write_config(workspace, default_config(workspace)) + + if git: + subprocess.run( + ["git", "init", "-q", str(root)], check=True, capture_output=True + ) + return workspace + + +def make_env(workspace: Workspace, **overrides: str) -> LauncherEnv: + """A LauncherEnv wired to ``workspace`` with a Band key present.""" + values: dict[str, str] = { + "workspace_dir": str(workspace.root), + "band_sdk_uv": str(workspace.uv_binary), + "band_api_key": "test-band-key", + } + values.update(overrides) + return LauncherEnv(**values) + + +def write_credentials(workspace: Workspace, content: str, *, mode: int = 0o600) -> Path: + """Write the opt-in credential file at the conventional example path.""" + cred_dir = workspace.root / ".band" + cred_dir.mkdir(exist_ok=True) + cred_path = cred_dir / "secrets.env" + cred_path.write_text(content, encoding="utf-8") + cred_path.chmod(mode) + return cred_path + + +def enable_repo( + config: dict[str, Any], + url: str = "https://github.com/example/agent-project.git", + **fields: Any, +) -> dict[str, Any]: + """Add a repo section and point the project at its clone subdirectory.""" + config["repo"] = {"url": url, **fields} + config["project"] = {"path": "app"} + return config + + +def enable_credentials(config: dict[str, Any]) -> dict[str, Any]: + config["credentials"] = { + "source": "workspace-env-file", + "path": ".band/secrets.env", + "acknowledgePlaintextInSandbox": True, + } + return config diff --git a/tests/docker/launcher/test_bootstrap.py b/tests/docker/launcher/test_bootstrap.py new file mode 100644 index 000000000..3aed76a62 --- /dev/null +++ b/tests/docker/launcher/test_bootstrap.py @@ -0,0 +1,186 @@ +"""Repository bootstrap: fenced destination, error wrapping, and ordering.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +from band.docker import repo_init +from band.docker.launcher import bootstrap as launcher_bootstrap +from band.docker.launcher import run as launcher_run +from band.docker.launcher import ( + LaunchError, + bootstrap_repository, + execute, + resolve_launch, +) +from band.docker.launcher.bootstrap import REPO_LOCK_TIMEOUT_S + +from .fakes import Workspace, default_config, enable_repo, make_env, write_config + +REPO_URL = "https://github.com/example/agent-project.git" + + +def enable(workspace: Workspace, **fields: Any) -> None: + write_config(workspace, enable_repo(default_config(workspace), **fields)) + + +def materialize_project(path: Path) -> None: + """What a successful clone leaves behind: the project the launch needs.""" + path.mkdir(parents=True, exist_ok=True) + (path / "main.py").write_text("print('agent')\n", encoding="utf-8") + (path / "pyproject.toml").write_text( + '[project]\nname = "cloned"\nversion = "0.1.0"\n', encoding="utf-8" + ) + (path / "uv.lock").write_text("# lock\n", encoding="utf-8") + + +def test_bootstrap_passes_fenced_destination_and_runtime_dirs( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch +) -> None: + """The clone destination is the fenced project path — never configured — + and state/context live under the sandbox-owned runtime state path.""" + enable(workspace, branch="main", index=True) + captured: dict[str, Any] = {} + + def fake_init(config: dict[str, Any], **kwargs: Any) -> repo_init.RepoInitResult: + captured["config"] = config + captured["kwargs"] = kwargs + materialize_project(Path(config["repo"]["path"])) + return repo_init.RepoInitResult(enabled=True, cloned=True, indexed=True) + + monkeypatch.setattr(launcher_bootstrap, "initialize_repo", fake_init) + launch = resolve_launch(make_env(workspace)) + bootstrap_repository(launch) + + assert captured["config"] == { + "repo": { + "url": REPO_URL, + "path": str(workspace.root.resolve() / "app"), + "branch": "main", + "index": True, + } + } + assert captured["kwargs"] == { + "agent_key": "agent-123", + "state_dir": launch.state_path, + "context_dir": launch.state_path / "context", + "lock_timeout_s": REPO_LOCK_TIMEOUT_S, + } + + +def test_no_repo_section_skips_bootstrap( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch +) -> None: + def never(config: dict[str, Any], **kwargs: Any) -> repo_init.RepoInitResult: + raise AssertionError("initialize_repo must not run without a repo section") + + monkeypatch.setattr(launcher_bootstrap, "initialize_repo", never) + bootstrap_repository(resolve_launch(make_env(workspace))) + + +def test_repo_init_failure_becomes_repo_phase_error( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch +) -> None: + """Every repo_init ValueError (config, auth, git, lock) fails the launch + attributed to its phase, never as a raw traceback.""" + enable(workspace) + + def failing(config: dict[str, Any], **kwargs: Any) -> repo_init.RepoInitResult: + raise ValueError("git clone failed: remote hung up") + + monkeypatch.setattr(launcher_bootstrap, "initialize_repo", failing) + launch = resolve_launch(make_env(workspace)) + with pytest.raises(LaunchError, match=r"\[repo\].*remote hung up"): + bootstrap_repository(launch) + + +def test_launch_error_passes_through_unwrapped( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch +) -> None: + """LaunchError subclasses ValueError — it must keep its own phase.""" + enable(workspace) + + def failing(config: dict[str, Any], **kwargs: Any) -> repo_init.RepoInitResult: + raise LaunchError("paths", "already attributed") + + monkeypatch.setattr(launcher_bootstrap, "initialize_repo", failing) + launch = resolve_launch(make_env(workspace)) + with pytest.raises(LaunchError, match=r"^\[paths\] already attributed$"): + bootstrap_repository(launch) + + +def test_bootstrap_runs_before_sync_then_launch_execs( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch +) -> None: + """Empty-workspace flow: only band.yaml at the root, the project arrives + with the clone, then sync and exec proceed against it.""" + enable(workspace) + order: list[str] = [] + + def fake_init(config: dict[str, Any], **kwargs: Any) -> repo_init.RepoInitResult: + order.append("bootstrap") + materialize_project(Path(config["repo"]["path"])) + return repo_init.RepoInitResult(enabled=True, cloned=True) + + monkeypatch.setattr(launcher_bootstrap, "initialize_repo", fake_init) + monkeypatch.setattr( + launcher_run, "sync_customer_environment", lambda launch: order.append("sync") + ) + monkeypatch.setattr("os.execve", lambda *args: order.append("exec")) + monkeypatch.setattr("os.chdir", lambda path: None) + + launch = resolve_launch(make_env(workspace)) + interpreter = launch.environment_path / "bin" / "python" + interpreter.parent.mkdir(parents=True) + interpreter.write_text("#!/bin/sh\n", encoding="utf-8") + + execute(launch) + assert order == ["bootstrap", "sync", "exec"] + + +def test_unmaterialized_clone_fails_before_sync( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch +) -> None: + """If bootstrap reports success but the project is not on disk, the + launch fails at the paths check instead of deep inside the sync.""" + enable(workspace) + + def hollow(config: dict[str, Any], **kwargs: Any) -> repo_init.RepoInitResult: + return repo_init.RepoInitResult(enabled=True, cloned=True) + + monkeypatch.setattr(launcher_bootstrap, "initialize_repo", hollow) + launch = resolve_launch(make_env(workspace)) + with pytest.raises(LaunchError, match=r"\[paths\].*not a directory"): + execute(launch) + + +def test_existing_repo_reused_without_clone( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch +) -> None: + """Restart flow with the real repo_init: an existing checkout with the + configured remote is validated and reused, never re-cloned. HTTPS + preflight is a no-op and validation only reads local git state, so no + network is touched.""" + enable(workspace) + app = workspace.root / "app" + materialize_project(app) + identity = ["-c", "user.email=test@example.test", "-c", "user.name=Test"] + for command in ( + ["git", "init", "-q", str(app)], + ["git", "-C", str(app), "remote", "add", "origin", REPO_URL], + ["git", "-C", str(app), "add", "."], + ["git", "-C", str(app), *identity, "commit", "-q", "-m", "init"], + ): + subprocess.run(command, check=True, capture_output=True) + + def never_clone(repo: Any, path: Path) -> None: + raise AssertionError("an existing checkout must not be re-cloned") + + monkeypatch.setattr(repo_init, "_clone_repo", never_clone) + launch = resolve_launch(make_env(workspace)) + bootstrap_repository(launch) + assert (launch.state_path / "repo_init_meta.json").is_file() diff --git a/tests/docker/launcher/test_config.py b/tests/docker/launcher/test_config.py new file mode 100644 index 000000000..6967c6a96 --- /dev/null +++ b/tests/docker/launcher/test_config.py @@ -0,0 +1,252 @@ +"""Strict configuration parsing, precedence, and endpoint validation.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from band.docker.launcher import ( + DEFAULT_REST_URL, + DEFAULT_WS_URL, + LaunchError, + load_workspace_config, + resolve_launch, +) +from band.docker.launcher import run as launcher_run + +from .fakes import Workspace, default_config, enable_repo, make_env, write_config + + +def test_valid_config_resolves_fully(workspace: Workspace) -> None: + launch = resolve_launch(make_env(workspace)) + assert launch.agent_id == "agent-123" + assert launch.rest_url == "https://platform.example.test" + assert launch.ws_url == "wss://platform.example.test/socket" + assert launch.project == workspace.root.resolve() + assert launch.entrypoint == (workspace.root / "main.py").resolve() + assert launch.environment_path == workspace.runtime_root / "venv" + + +def test_unknown_top_level_field_rejected(workspace: Workspace) -> None: + config = default_config(workspace) + config["surprise"] = True + write_config(workspace, config) + with pytest.raises(LaunchError, match=r"\[config\].*surprise"): + resolve_launch(make_env(workspace)) + + +def test_unknown_nested_field_rejected(workspace: Workspace) -> None: + config = default_config(workspace) + config["agent"]["model"] = "gpt-5" + write_config(workspace, config) + with pytest.raises(LaunchError, match=r"\[config\].*model"): + resolve_launch(make_env(workspace)) + + +def test_unsupported_schema_version_rejected(workspace: Workspace) -> None: + """A file declaring another schema version was written for different + semantics and must not be interpreted with this launcher's model.""" + config = default_config(workspace) + config["schemaVersion"] = "2" + write_config(workspace, config) + with pytest.raises(LaunchError, match=r"\[config\].*schemaVersion"): + resolve_launch(make_env(workspace)) + + +def test_missing_runtime_section_rejected(workspace: Workspace) -> None: + config = default_config(workspace) + del config["runtime"] + write_config(workspace, config) + with pytest.raises(LaunchError, match=r"\[config\].*runtime"): + resolve_launch(make_env(workspace)) + + +def test_repo_section_parsed(workspace: Workspace) -> None: + write_config(workspace, enable_repo(default_config(workspace), branch="main")) + config = load_workspace_config(workspace.config_path) + assert config.repo is not None + assert config.repo.url == "https://github.com/example/agent-project.git" + assert config.repo.branch == "main" + assert config.repo.index is False + + +def test_repo_path_field_rejected(workspace: Workspace) -> None: + """The clone destination is always the fenced project path — a repo.path + field would let the config direct clone writes elsewhere.""" + write_config(workspace, enable_repo(default_config(workspace), path="/tmp/x")) + with pytest.raises(LaunchError, match=r"\[config\].*path"): + resolve_launch(make_env(workspace)) + + +def test_repo_url_missing_rejected(workspace: Workspace) -> None: + config = default_config(workspace) + config["repo"] = {"branch": "main"} + write_config(workspace, config) + with pytest.raises(LaunchError, match=r"\[config\].*repo\.url"): + resolve_launch(make_env(workspace)) + + +@pytest.mark.parametrize( + "url", + [ + "", # blank: repo_init would normalize to None → local-only mode + " ", + "ftp://host/repo.git", # unsupported scheme + "/local/path", + "https://", # no host, no path + "https://:443/repo.git", # port but no host + "https://example.test", # no repository path + "ssh://", # no host + "git@github.com", # SCP form without :path + "git@:org/repo.git", # SCP form without host + ], +) +def test_repo_url_unsupported_rejected(workspace: Workspace, url: str) -> None: + """Malformed remotes must fail in [config], not at git time.""" + write_config(workspace, enable_repo(default_config(workspace), url=url)) + with pytest.raises(LaunchError, match=r"\[config\].*repo\.url"): + resolve_launch(make_env(workspace)) + + +@pytest.mark.parametrize( + "url", + [ + "https://token:secret-value@example.test/repo.git", + "https://secret-token@example.test/repo.git", + "ssh://git:secret-value@example.test/repo.git", + ], +) +def test_repo_url_with_embedded_credentials_rejected( + workspace: Workspace, url: str +) -> None: + """band.yaml is committed and repo_init logs the URL — a userinfo token + must be rejected in [config], and the error must not echo it.""" + write_config(workspace, enable_repo(default_config(workspace), url=url)) + with pytest.raises(LaunchError, match=r"\[config\].*repo\.url") as exc_info: + resolve_launch(make_env(workspace)) + assert "secret" not in str(exc_info.value) + + +@pytest.mark.parametrize( + "url", + [ + "https://github.com/org/repo.git", + "ssh://git@github.com/org/repo.git", # canonical SSH login user + "git@github.com:org/repo.git", + ], +) +def test_repo_url_supported_forms_accepted(workspace: Workspace, url: str) -> None: + write_config(workspace, enable_repo(default_config(workspace), url=url)) + config = load_workspace_config(workspace.config_path) + assert config.repo is not None + assert config.repo.url == url + + +def test_missing_config_file_fails_with_phase(workspace: Workspace) -> None: + workspace.config_path.unlink() + with pytest.raises(LaunchError, match=r"\[config\].*not found"): + resolve_launch(make_env(workspace)) + + +def test_config_path_override(workspace: Workspace, tmp_path: Path) -> None: + moved = tmp_path / "elsewhere.yaml" + moved.write_text(workspace.config_path.read_text(), encoding="utf-8") + workspace.config_path.unlink() + launch = resolve_launch(make_env(workspace, band_kit_config_path=str(moved))) + assert launch.agent_id == "agent-123" + + +def test_env_overrides_beat_yaml(workspace: Workspace) -> None: + launch = resolve_launch( + make_env( + workspace, + band_agent_id="agent-env", + band_rest_url="https://env.example.test", + band_ws_url="wss://env.example.test/socket", + ) + ) + assert launch.agent_id == "agent-env" + assert launch.rest_url == "https://env.example.test" + assert launch.ws_url == "wss://env.example.test/socket" + + +def test_endpoint_defaults_apply_only_when_unconfigured( + workspace: Workspace, +) -> None: + config = default_config(workspace) + del config["band"] + write_config(workspace, config) + launch = resolve_launch(make_env(workspace)) + assert launch.rest_url == DEFAULT_REST_URL + assert launch.ws_url == DEFAULT_WS_URL + + +def test_non_https_rest_url_rejected(workspace: Workspace) -> None: + with pytest.raises(LaunchError, match=r"\[config\].*https"): + resolve_launch(make_env(workspace, band_rest_url="http://insecure.test")) + + +def test_non_wss_ws_url_rejected(workspace: Workspace) -> None: + with pytest.raises(LaunchError, match=r"\[config\].*wss"): + resolve_launch(make_env(workspace, band_ws_url="ws://insecure.test")) + + +@pytest.mark.parametrize( + "rest_url", + [ + "https://", # no host at all + "https://:443", # port but no host (netloc is truthy) + "https://user@", # userinfo but no host (netloc is truthy) + ], +) +def test_rest_url_without_host_rejected(workspace: Workspace, rest_url: str) -> None: + """A scheme with no parseable hostname must fail the config phase, not + the first connect attempt after the dependency sync.""" + with pytest.raises(LaunchError, match=r"\[config\].*host"): + resolve_launch(make_env(workspace, band_rest_url=rest_url)) + + +@pytest.mark.parametrize("ws_url", ["wss://", "wss://user@"]) +def test_ws_url_without_host_rejected(workspace: Workspace, ws_url: str) -> None: + with pytest.raises(LaunchError, match=r"\[config\].*host"): + resolve_launch(make_env(workspace, band_ws_url=ws_url)) + + +def test_rest_url_with_malformed_port_rejected(workspace: Workspace) -> None: + """urlsplit parses the port lazily — the validator must force it.""" + with pytest.raises(LaunchError, match=r"\[config\].*not a valid URL"): + resolve_launch(make_env(workspace, band_rest_url="https://host:not-a-port")) + + +def test_missing_agent_id_rejected(workspace: Workspace) -> None: + config = default_config(workspace) + config["agent"]["id"] = "" + write_config(workspace, config) + with pytest.raises(LaunchError, match=r"\[config\].*agent id"): + resolve_launch(make_env(workspace)) + + +def test_yaml_that_is_not_a_mapping_rejected(workspace: Workspace) -> None: + workspace.config_path.write_text("- just\n- a\n- list\n", encoding="utf-8") + with pytest.raises(LaunchError, match=r"\[config\].*mapping"): + load_workspace_config(workspace.config_path) + + +def test_missing_workspace_dir_rejected(workspace: Workspace) -> None: + env = make_env(workspace, workspace_dir="") + with pytest.raises(LaunchError, match=r"\[config\].*WORKSPACE_DIR"): + resolve_launch(env) + + +def test_wrong_uid_rejected( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(launcher_run, "current_uid", lambda: 0) + with pytest.raises(LaunchError, match=r"\[identity\].*uid 1000"): + resolve_launch(make_env(workspace)) + + +def test_missing_band_sdk_uv_rejected(workspace: Workspace) -> None: + with pytest.raises(LaunchError, match=r"\[sync\].*BAND_SDK_UV"): + resolve_launch(make_env(workspace, band_sdk_uv="")) diff --git a/tests/docker/launcher/test_credentials.py b/tests/docker/launcher/test_credentials.py new file mode 100644 index 000000000..70a1e2ee3 --- /dev/null +++ b/tests/docker/launcher/test_credentials.py @@ -0,0 +1,205 @@ +"""Opt-in workspace credential file: safeguards, parsing, and precedence.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from band.docker.launcher import LaunchError, resolve_launch + +from .fakes import ( + Workspace, + default_config, + enable_credentials, + make_env, + make_workspace, + write_config, + write_credentials, +) + + +def enable(workspace: Workspace) -> None: + write_config(workspace, enable_credentials(default_config(workspace))) + + +def test_no_credentials_section_needs_env_key(workspace: Workspace) -> None: + launch = resolve_launch(make_env(workspace)) + assert launch.credentials == {"BAND_API_KEY": "test-band-key"} + + +def test_band_api_key_required_from_somewhere(workspace: Workspace) -> None: + with pytest.raises(LaunchError, match=r"\[credentials\].*BAND_API_KEY"): + resolve_launch(make_env(workspace, band_api_key="")) + + +def test_file_fills_missing_band_api_key(workspace: Workspace) -> None: + enable(workspace) + write_credentials(workspace, "BAND_API_KEY=from-file\n") + launch = resolve_launch(make_env(workspace, band_api_key="")) + assert launch.credentials == {"BAND_API_KEY": "from-file"} + + +def test_process_env_wins_over_file(workspace: Workspace) -> None: + enable(workspace) + write_credentials(workspace, "BAND_API_KEY=from-file\nOPENAI_API_KEY=file-openai\n") + launch = resolve_launch(make_env(workspace, band_api_key="from-env")) + # The env key wins; the file only fills the missing name. + assert launch.credentials == { + "BAND_API_KEY": "from-env", + "OPENAI_API_KEY": "file-openai", + } + + +def test_unsupported_source_rejected(workspace: Workspace) -> None: + config = default_config(workspace) + config["credentials"] = { + "source": "vault", + "path": ".band/secrets.env", + "acknowledgePlaintextInSandbox": True, + } + write_config(workspace, config) + with pytest.raises(LaunchError, match=r"\[credentials\].*source"): + resolve_launch(make_env(workspace)) + + +def test_missing_acknowledgement_rejected(workspace: Workspace) -> None: + config = default_config(workspace) + config["credentials"] = { + "source": "workspace-env-file", + "path": ".band/secrets.env", + } + write_config(workspace, config) + write_credentials(workspace, "BAND_API_KEY=x\n") + with pytest.raises( + LaunchError, match=r"\[credentials\].*acknowledgePlaintextInSandbox" + ): + resolve_launch(make_env(workspace)) + + +def test_missing_file_rejected(workspace: Workspace) -> None: + enable(workspace) + with pytest.raises(LaunchError, match=r"\[credentials\].*not found"): + resolve_launch(make_env(workspace)) + + +def test_symlinked_file_rejected_even_inside_workspace( + workspace: Workspace, +) -> None: + """The dedicated symlink guard must fire even when the link's target + stays inside the workspace (containment alone would accept it).""" + enable(workspace) + real = workspace.root / "real-secrets.env" + real.write_text("BAND_API_KEY=x\n", encoding="utf-8") + real.chmod(0o600) + cred_dir = workspace.root / ".band" + cred_dir.mkdir() + (cred_dir / "secrets.env").symlink_to(real) + with pytest.raises( + LaunchError, match=r"\[credentials\].*must not traverse a symlink" + ): + resolve_launch(make_env(workspace)) + + +def test_symlinked_parent_dir_rejected(workspace: Workspace) -> None: + """A symlinked directory on the credentials path is the same unexpected + indirection as a symlinked leaf.""" + enable(workspace) + hidden = workspace.root / "elsewhere" + hidden.mkdir() + secrets = hidden / "secrets.env" + secrets.write_text("BAND_API_KEY=x\n", encoding="utf-8") + secrets.chmod(0o600) + (workspace.root / ".band").symlink_to(hidden, target_is_directory=True) + with pytest.raises( + LaunchError, match=r"\[credentials\].*must not traverse a symlink" + ): + resolve_launch(make_env(workspace)) + + +def test_group_readable_file_rejected(workspace: Workspace) -> None: + enable(workspace) + write_credentials(workspace, "BAND_API_KEY=x\n", mode=0o640) + with pytest.raises(LaunchError, match=r"\[credentials\].*owner-only"): + resolve_launch(make_env(workspace)) + + +def test_traversal_outside_workspace_rejected(workspace: Workspace) -> None: + config = default_config(workspace) + config["credentials"] = { + "source": "workspace-env-file", + "path": "../secrets.env", + "acknowledgePlaintextInSandbox": True, + } + write_config(workspace, config) + with pytest.raises(LaunchError, match=r"\[credentials\].*escapes"): + resolve_launch(make_env(workspace)) + + +def test_git_tracked_file_rejected(workspace: Workspace) -> None: + enable(workspace) + cred_path = write_credentials(workspace, "BAND_API_KEY=x\n") + # Force-add past the .gitignore to simulate a committed secrets file. + subprocess.run( + ["git", "-C", str(workspace.root), "add", "-f", str(cred_path)], + check=True, + capture_output=True, + ) + with pytest.raises(LaunchError, match=r"\[credentials\].*tracked"): + resolve_launch(make_env(workspace)) + + +def test_unignored_file_rejected(workspace: Workspace) -> None: + (workspace.root / ".gitignore").write_text("# nothing\n", encoding="utf-8") + enable(workspace) + write_credentials(workspace, "BAND_API_KEY=x\n") + with pytest.raises(LaunchError, match=r"\[credentials\].*gitignored"): + resolve_launch(make_env(workspace)) + + +def test_corrupt_git_metadata_fails_closed(workspace: Workspace) -> None: + """A git failure that is not the not-a-repository verdict (here: corrupt + metadata) must fail the launch, never silently skip the tracking guards.""" + enable(workspace) + write_credentials(workspace, "BAND_API_KEY=x\n") + (workspace.root / ".git" / "config").write_text("[[[garbage\n", encoding="utf-8") + with pytest.raises(LaunchError, match=r"\[credentials\].*could not determine"): + resolve_launch(make_env(workspace)) + + +def test_non_git_workspace_allowed(tmp_path: Path) -> None: + workspace = make_workspace(tmp_path, git=False) + write_config(workspace, enable_credentials(default_config(workspace))) + write_credentials(workspace, "BAND_API_KEY=from-file\n") + launch = resolve_launch(make_env(workspace, band_api_key="")) + assert launch.credentials == {"BAND_API_KEY": "from-file"} + + +def test_non_git_workspace_without_ignore_rule_rejected(tmp_path: Path) -> None: + """Even before `git init`, the ignore rule must already exist — a later + init would otherwise leave the secrets one `git add .` from a commit.""" + workspace = make_workspace(tmp_path, git=False) + (workspace.root / ".gitignore").unlink() + write_config(workspace, enable_credentials(default_config(workspace))) + write_credentials(workspace, "BAND_API_KEY=x\n") + with pytest.raises(LaunchError, match=r"\[credentials\].*gitignored"): + resolve_launch(make_env(workspace, band_api_key="")) + + +def test_undocumented_names_rejected(workspace: Workspace) -> None: + enable(workspace) + write_credentials(workspace, "BAND_API_KEY=x\nAWS_SECRET_ACCESS_KEY=nope\n") + with pytest.raises(LaunchError, match=r"\[credentials\].*AWS_SECRET_ACCESS_KEY"): + resolve_launch(make_env(workspace)) + + +def test_error_messages_never_contain_values(workspace: Workspace) -> None: + enable(workspace) + write_credentials( + workspace, "BAND_API_KEY=super-secret-value\nBOGUS_NAME=leak-me\n" + ) + with pytest.raises(LaunchError) as exc_info: + resolve_launch(make_env(workspace)) + assert "super-secret-value" not in str(exc_info.value) + assert "leak-me" not in str(exc_info.value) diff --git a/tests/docker/launcher/test_exec.py b/tests/docker/launcher/test_exec.py new file mode 100644 index 000000000..64f110a53 --- /dev/null +++ b/tests/docker/launcher/test_exec.py @@ -0,0 +1,123 @@ +"""Child environment construction and the final exec.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import pytest + +from band.docker.launcher import run as launcher_run +from band.docker.launcher import ( + AGENT_HOME, + LaunchError, + build_child_environment, + execute, + resolve_launch, +) + +from .fakes import Workspace, make_env + + +@pytest.fixture +def no_sync(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(launcher_run, "sync_customer_environment", lambda launch: None) + + +def make_customer_interpreter(launch_environment_path: Path) -> Path: + interpreter = launch_environment_path / "bin" / "python" + interpreter.parent.mkdir(parents=True, exist_ok=True) + interpreter.write_text("#!/bin/sh\n", encoding="utf-8") + return interpreter + + +def test_child_environment_exact( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("UNRELATED_VAR", "preserved") + launch = resolve_launch(make_env(workspace)) + launch.credentials["OPENAI_API_KEY"] = "from-file" + + child = build_child_environment(launch) + + assert child["BAND_AGENT_ID"] == "agent-123" + assert child["BAND_REST_URL"] == "https://platform.example.test" + assert child["BAND_WS_URL"] == "wss://platform.example.test/socket" + assert child["HOME"] == AGENT_HOME + assert child["OPENAI_API_KEY"] == "from-file" + assert child["UNRELATED_VAR"] == "preserved" + + +def test_validated_credential_reaches_child_under_canonical_name( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch +) -> None: + """A credential accepted by validation — case-insensitively from the + environment or via a programmatic LauncherEnv — must reach the child + under its documented uppercase name; inheriting the raw environment + alone cannot guarantee that (example main.py reads BAND_API_KEY).""" + monkeypatch.delenv("BAND_API_KEY", raising=False) + launch = resolve_launch(make_env(workspace, band_api_key="accepted-key")) + child = build_child_environment(launch) + assert child["BAND_API_KEY"] == "accepted-key" + + +def test_execute_execs_customer_entrypoint( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch, no_sync: None +) -> None: + launch = resolve_launch(make_env(workspace)) + interpreter = make_customer_interpreter(launch.environment_path) + + captured: dict[str, Any] = {} + + def fake_execve(path: str, argv: list[str], env: dict[str, str]) -> None: + captured["path"] = path + captured["argv"] = argv + captured["env"] = env + + def fake_chdir(path: str | Path) -> None: + captured["cwd"] = Path(path) + + monkeypatch.setattr(os, "execve", fake_execve) + monkeypatch.setattr(os, "chdir", fake_chdir) + + execute(launch) + + assert captured["path"] == str(interpreter) + assert captured["argv"] == [str(interpreter), str(launch.entrypoint)] + assert captured["cwd"] == launch.project + assert captured["env"]["BAND_AGENT_ID"] == "agent-123" + + +def test_missing_customer_interpreter_rejected( + workspace: Workspace, no_sync: None +) -> None: + launch = resolve_launch(make_env(workspace)) + with pytest.raises(LaunchError, match=r"\[exec\].*interpreter"): + execute(launch) + + +def test_main_exits_nonzero_on_launch_error( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch +) -> None: + def failing_resolve(*args: Any, **kwargs: Any) -> None: + raise LaunchError("config", "boom") + + monkeypatch.setattr(launcher_run, "resolve_launch", failing_resolve) + with pytest.raises(SystemExit) as exc_info: + launcher_run.main() + assert exc_info.value.code == 1 + + +def test_main_exits_cleanly_on_os_error( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch +) -> None: + """Filesystem errors outside a named phase still exit 1, not a traceback.""" + + def failing_resolve(*args: Any, **kwargs: Any) -> None: + raise PermissionError("unwritable log directory") + + monkeypatch.setattr(launcher_run, "resolve_launch", failing_resolve) + with pytest.raises(SystemExit) as exc_info: + launcher_run.main() + assert exc_info.value.code == 1 diff --git a/tests/docker/launcher/test_paths.py b/tests/docker/launcher/test_paths.py new file mode 100644 index 000000000..1a126d050 --- /dev/null +++ b/tests/docker/launcher/test_paths.py @@ -0,0 +1,115 @@ +"""Configurable path resolution, traversal rejection, and boundary rules.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from band.docker.launcher import LaunchError, resolve_launch + +from .fakes import Workspace, default_config, enable_repo, make_env, write_config + + +def test_project_traversal_rejected(workspace: Workspace) -> None: + config = default_config(workspace) + config["project"]["path"] = "../outside" + write_config(workspace, config) + with pytest.raises(LaunchError, match=r"\[paths\].*escapes"): + resolve_launch(make_env(workspace)) + + +def test_project_symlink_escape_rejected(workspace: Workspace, tmp_path: Path) -> None: + outside = tmp_path / "outside-project" + outside.mkdir() + (workspace.root / "linked").symlink_to(outside, target_is_directory=True) + config = default_config(workspace) + config["project"]["path"] = "linked" + write_config(workspace, config) + with pytest.raises(LaunchError, match=r"\[paths\].*escapes"): + resolve_launch(make_env(workspace)) + + +def test_entrypoint_outside_project_rejected(workspace: Workspace) -> None: + config = default_config(workspace) + subdir = workspace.root / "app" + subdir.mkdir() + (subdir / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + config["project"]["path"] = "app" + config["agent"]["entrypoint"] = "../main.py" + write_config(workspace, config) + with pytest.raises(LaunchError, match=r"\[paths\].*escapes"): + resolve_launch(make_env(workspace)) + + +def test_missing_entrypoint_rejected(workspace: Workspace) -> None: + (workspace.root / "main.py").unlink() + with pytest.raises(LaunchError, match=r"\[paths\].*entrypoint"): + resolve_launch(make_env(workspace)) + + +def test_repo_mode_defers_project_existence(workspace: Workspace) -> None: + """With a repo section the project materializes at bootstrap — resolve + must fence the paths but not require them to exist yet.""" + write_config(workspace, enable_repo(default_config(workspace))) + launch = resolve_launch(make_env(workspace)) + assert launch.project == workspace.root.resolve() / "app" + assert not launch.project.exists() + + +def test_repo_mode_still_rejects_project_escape(workspace: Workspace) -> None: + """Deferring existence must not defer the containment fence.""" + config = enable_repo(default_config(workspace)) + config["project"]["path"] = "../outside" + write_config(workspace, config) + with pytest.raises(LaunchError, match=r"\[paths\].*escapes"): + resolve_launch(make_env(workspace)) + + +def test_repo_mode_rejects_workspace_root_project(workspace: Workspace) -> None: + """The root holds band.yaml (non-empty, not a repo) — repo_init could + never clone into it, so fail with a clear error instead.""" + config = enable_repo(default_config(workspace)) + config["project"]["path"] = "." + write_config(workspace, config) + with pytest.raises(LaunchError, match=r"\[repo\].*subdirectory"): + resolve_launch(make_env(workspace)) + + +def test_runtime_path_inside_workspace_rejected(workspace: Workspace) -> None: + config = default_config(workspace) + config["runtime"]["environmentPath"] = str(workspace.root / ".venv") + write_config(workspace, config) + with pytest.raises(LaunchError, match=r"\[paths\].*outside the mounted workspace"): + resolve_launch(make_env(workspace)) + + +def test_runtime_path_inside_sdk_home_rejected( + workspace: Workspace, tmp_path: Path +) -> None: + sdk_home = tmp_path / "sdk-home" + sdk_home.mkdir() + config = default_config(workspace) + config["runtime"]["statePath"] = str(sdk_home / "state") + write_config(workspace, config) + env = make_env(workspace, band_sdk_home=str(sdk_home)) + with pytest.raises(LaunchError, match=r"\[paths\].*outside the SDK home"): + resolve_launch(env) + + +def test_empty_sdk_home_keeps_default_fence(workspace: Workspace) -> None: + """A present-but-empty BAND_SDK_HOME must fence /opt/band, not the cwd.""" + config = default_config(workspace) + config["runtime"]["cachePath"] = "/opt/band/cache" + write_config(workspace, config) + env = make_env(workspace, band_sdk_home="") + with pytest.raises(LaunchError, match=r"\[paths\].*outside the SDK home"): + resolve_launch(env) + + +def test_relative_runtime_path_rejected(workspace: Workspace) -> None: + config = default_config(workspace) + config["runtime"]["cachePath"] = "relative/cache" + write_config(workspace, config) + with pytest.raises(LaunchError, match=r"\[paths\].*absolute"): + resolve_launch(make_env(workspace)) diff --git a/tests/docker/launcher/test_sync.py b/tests/docker/launcher/test_sync.py new file mode 100644 index 000000000..a61876be2 --- /dev/null +++ b/tests/docker/launcher/test_sync.py @@ -0,0 +1,117 @@ +"""Locked dependency synchronization: command, environment, lock, failures.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest +from filelock import Timeout + +from band.docker.launcher import ( + AGENT_HOME, + LaunchError, + resolve_launch, + sync_customer_environment, +) +from band.docker.launcher import sync as launcher_sync + +from .fakes import Workspace, make_env + + +class FakeRun: + """Captures the uv invocation; configurable exit code.""" + + def __init__(self, returncode: int = 0, stderr: str = "") -> None: + self.returncode = returncode + self.stderr = stderr + self.calls: list[dict[str, Any]] = [] + + def __call__(self, cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: + self.calls.append({"cmd": cmd, **kwargs}) + return subprocess.CompletedProcess( + cmd, self.returncode, stdout="", stderr=self.stderr + ) + + +def test_missing_pyproject_rejected(workspace: Workspace) -> None: + launch = resolve_launch(make_env(workspace)) + (workspace.root / "pyproject.toml").unlink() + with pytest.raises(LaunchError, match=r"\[sync\].*pyproject.toml"): + sync_customer_environment(launch) + + +def test_missing_lock_rejected_with_guidance(workspace: Workspace) -> None: + launch = resolve_launch(make_env(workspace)) + (workspace.root / "uv.lock").unlink() + with pytest.raises(LaunchError, match=r"\[sync\].*uv.lock.*not supported"): + sync_customer_environment(launch) + + +def test_missing_uv_binary_rejected(workspace: Workspace) -> None: + launch = resolve_launch(make_env(workspace)) + workspace.uv_binary.unlink() + with pytest.raises(LaunchError, match=r"\[sync\].*pinned runtime uv"): + sync_customer_environment(launch) + + +def test_sync_invocation_exact( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch +) -> None: + launch = resolve_launch(make_env(workspace)) + fake = FakeRun() + monkeypatch.setattr(launcher_sync.subprocess, "run", fake) + + sync_customer_environment(launch) + + assert len(fake.calls) == 1 + call = fake.calls[0] + assert call["cmd"] == [str(workspace.uv_binary), "sync", "--locked"] + assert call["cwd"] == launch.project + env = call["env"] + assert env["UV_PROJECT_ENVIRONMENT"] == str(launch.environment_path) + assert env["UV_CACHE_DIR"] == str(launch.cache_path) + assert env["HOME"] == AGENT_HOME + # The customer venv must build on the base interpreter (never the SDK + # venv python), and uv must never try to download one inside the + # egress-fenced sandbox. + assert env["UV_PYTHON"] == str(Path(sys.base_prefix) / "bin" / "python3") + assert env["UV_PYTHON_DOWNLOADS"] == "never" + + +def test_sync_failure_surfaces_stderr_tail( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch +) -> None: + launch = resolve_launch(make_env(workspace)) + fake = FakeRun(returncode=2, stderr="error: lockfile is out of date") + monkeypatch.setattr(launcher_sync.subprocess, "run", fake) + with pytest.raises(LaunchError, match=r"(?s)\[sync\].*lockfile is out of date"): + sync_customer_environment(launch) + + +def test_sync_lock_contention_times_out_cleanly( + workspace: Workspace, monkeypatch: pytest.MonkeyPatch +) -> None: + """A held dependency-sync lock surfaces as a clear phase error, and the + sync command is never attempted.""" + + class HeldLock: + def __init__(self, path: str) -> None: + self.path = path + + def acquire(self, timeout: float | None = None) -> None: + raise Timeout(self.path) + + def release(self) -> None: # pragma: no cover - never reached + raise AssertionError("release without acquire") + + launch = resolve_launch(make_env(workspace)) + fake = FakeRun() + monkeypatch.setattr(launcher_sync.subprocess, "run", fake) + monkeypatch.setattr(launcher_sync, "FileLock", HeldLock) + + with pytest.raises(LaunchError, match=r"\[sync\].*timed out.*lock"): + sync_customer_environment(launch) + assert fake.calls == [] diff --git a/tests/docker/test_kit_spec.py b/tests/docker/test_kit_spec.py new file mode 100644 index 000000000..25a93373e --- /dev/null +++ b/tests/docker/test_kit_spec.py @@ -0,0 +1,153 @@ +"""The kit spec and echo-agent starter stay coherent with the launcher contract. + +Pure file/contract checks — no Docker daemon and no sbx CLI needed, so these +run in the ordinary unit suite. `sbx kit validate` itself is a manual step +(recorded in the kit README) because the sandbox CLI only exists on +Docker-Sandbox-capable machines. +""" + +from __future__ import annotations + +import importlib +import re +from pathlib import Path +from typing import Any + +import yaml +from dotenv import dotenv_values + +from band.docker.launcher import CredentialName, WorkspaceConfig + +KIT_DIR = Path(__file__).parents[2] / "docker" / "band_python_kit" +ECHO_AGENT_DIR = KIT_DIR / "echo-agent" + +# Hosts the launch flow was measured to need (see the kit spec's comments): +# Band, locked dependency sync, and each supported LLM backend. +REQUIRED_ALLOWLIST_HOSTS = { + "app.band.ai", + "pypi.org", + "files.pythonhosted.org", + "api.openai.com", + "api.anthropic.com", + "github.com", + "api.github.com", + "release-assets.githubusercontent.com", + "*.githubcopilot.com", +} + +# IDE-completion and telemetry hosts that were measured NOT to be needed — +# reintroducing one silently widens the egress surface. +EXCLUDED_HOSTS = { + "copilot-proxy.githubusercontent.com", + "origin-tracker.githubusercontent.com", + "collector.github.com", + "copilot-telemetry.githubusercontent.com", + "default.exp-tas.com", +} + + +def load_spec() -> dict[str, Any]: + return yaml.safe_load((KIT_DIR / "spec.yaml").read_text(encoding="utf-8")) + + +def test_spec_is_a_sandbox_kit_with_stable_identity() -> None: + spec = load_spec() + assert spec["kind"] == "sandbox" + # The kit name doubles as the `sbx create` agent positional — renaming it + # breaks every documented launch command. + assert spec["name"] == "band-python-kit" + assert spec["sandbox"]["aiFilename"] == "AGENTS.md" + + +def test_agent_launches_via_startup_command_not_entrypoint() -> None: + spec = load_spec() + # Launch is headless via commands.startup; an entrypoint block would put + # the agent on the attach path instead (root, PTY, needs a session). + assert "entrypoint" not in spec["sandbox"] + + startup = spec["commands"]["startup"] + assert len(startup) == 1 + entry = startup[0] + assert entry["background"] is True + assert entry["user"] == "0" + command = entry["command"] + # Root phase (CA refresh + privilege drop) must wrap the launcher. + assert command[0] == "/usr/local/bin/band-entrypoint.sh" + assert command[-2:] == ["-m", "band.docker.launcher"] + + +def test_launcher_module_referenced_by_kit_is_importable() -> None: + spec = load_spec() + module_name = spec["commands"]["startup"][0]["command"][-1] + importlib.import_module(module_name) + + +# Copilot plan-variant wildcards: not observed in the smoke (which ran on an +# individual-plan account) but required for business/enterprise accounts. +PLAN_VARIANT_HOSTS = { + "*.individual.githubcopilot.com", + "*.business.githubcopilot.com", + "*.enterprise.githubcopilot.com", +} + + +def test_allowlist_matches_measured_minimal_set() -> None: + allow = set(load_spec()["caps"]["network"]["allow"]) + # Exact equality: any widening of the egress surface fails this test, + # not just reintroducing the specifically excluded hosts. + assert allow == REQUIRED_ALLOWLIST_HOSTS | PLAN_VARIANT_HOSTS + assert not (EXCLUDED_HOSTS & allow) + + +def test_spec_defines_no_proxy_or_credential_entries() -> None: + spec = load_spec() + # Proxy vars are runtime-owned (overriding them bypasses policy), and + # credential injection belongs to the later proxy-custody milestone. + env_vars = spec.get("environment", {}).get("variables", {}) + assert not {k for k in env_vars if k.upper().endswith("_PROXY")} + assert "credentials" not in spec + + +def test_echo_agent_workspace_satisfies_the_launcher_contract() -> None: + raw = yaml.safe_load((ECHO_AGENT_DIR / "band.yaml").read_text(encoding="utf-8")) + config = WorkspaceConfig.model_validate(raw) + + assert (ECHO_AGENT_DIR / config.agent.entrypoint).is_file() + assert (ECHO_AGENT_DIR / "pyproject.toml").is_file() + assert (ECHO_AGENT_DIR / "uv.lock").is_file(), ( + "the echo-agent starter must ship a committed lock — unlocked resolution is not supported" + ) + + credentials = config.credentials + assert credentials is not None + assert credentials.acknowledge_plaintext_in_sandbox is True + # The configured credential path must be covered by the echo-agent starter's + # .gitignore so a copied workspace never commits secrets. + gitignore = (ECHO_AGENT_DIR / ".gitignore").read_text(encoding="utf-8") + assert credentials.path.split("/")[0] + "/" in gitignore + + # Runtime paths must be sandbox-owned: outside the workspace mount and + # outside the immutable SDK home. + for value in ( + config.runtime.environment_path, + config.runtime.state_path, + config.runtime.cache_path, + config.runtime.log_path, + ): + assert value.startswith("/home/agent/"), value + + +def test_echo_agent_secrets_template_names_match_documented_names() -> None: + """The shipped template must mention every documented credential name and + nothing else — the launcher rejects undocumented names at launch.""" + template = ECHO_AGENT_DIR / "secrets.env.example" + active = set(dotenv_values(template)) + commented = set( + re.findall( + r"^#\s*([A-Z][A-Z0-9_]*)=", + template.read_text(encoding="utf-8"), + re.MULTILINE, + ) + ) + assert "BAND_API_KEY" in active + assert active | commented == {name.value for name in CredentialName}