diff --git a/Dockerfile b/Dockerfile index f5a025a..980fb2c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # The validator scans on-chain reveals, dispatches duels to the GPU eval server, # crowns winners and sets weights. It never loads a model, so it needs no torch. # The GPU eval server uses Dockerfile.eval instead. -FROM python:3.12-slim +FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de # ffprobe/ffmpeg for the video utilities; curl for healthchecks; # build-essential for any deps that need compiling. @@ -11,16 +11,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ffmpeg curl build-essential \ && rm -rf /var/lib/apt/lists/* +RUN python -m venv /opt/venv +ENV VIRTUAL_ENV=/opt/venv \ + PATH="/opt/venv/bin:${PATH}" \ + LEOMA_UV_LOCK_PATH=/app/uv.lock + WORKDIR /app -RUN pip install --no-cache-dir uv +RUN pip install --no-cache-dir uv==0.11.6 -COPY pyproject.toml README.md ./ +COPY pyproject.toml uv.lock README.md ./ COPY leoma ./leoma # Install the package (non-editable for a production image). chain.toml ships # INSIDE the package via [tool.setuptools.package-data] — it is consensus-critical # and is read at import time, so it must exist in site-packages, not just in git. -RUN uv pip install --system --no-cache . +RUN uv sync --active --frozen --no-dev --no-editable --no-cache # Fail at BUILD time if the package cannot import. This is exactly the bug class # that shipped before (a missing chain.toml / missing numpy only surfaced when the diff --git a/Dockerfile.eval b/Dockerfile.eval index 8ed0c02..f71577b 100644 --- a/Dockerfile.eval +++ b/Dockerfile.eval @@ -6,7 +6,7 @@ # # It needs the [eval] extra (torch / diffusers / lpips / opencv / open_clip). # The validator image (Dockerfile) deliberately does NOT. -FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04 +FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04@sha256:2fcc4280646484290cc50dce5e65f388dd04352b07cbe89a635703bd1f9aedb6 ENV DEBIAN_FRONTEND=noninteractive \ PYTHONUNBUFFERED=1 \ @@ -22,13 +22,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* RUN python3.12 -m venv /opt/venv -ENV PATH="/opt/venv/bin:${PATH}" +ENV VIRTUAL_ENV=/opt/venv \ + PATH="/opt/venv/bin:${PATH}" WORKDIR /app -RUN pip install --no-cache-dir uv +RUN pip install --no-cache-dir uv==0.11.6 COPY pyproject.toml uv.lock README.md ./ COPY leoma ./leoma +ENV LEOMA_UV_LOCK_PATH=/app/uv.lock # The [eval] extra is the whole point of this image. Frozen sync makes rebuilds use # the exact tested CUDA/Diffusers/Transformers graph from uv.lock; an open-ended GPU diff --git a/README.md b/README.md index ac05788..a24b5c7 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,9 @@ The validator scans reveals, dispatches duels to an eval server, crowns winners, - **Bittensor wallet** (coldkey + hotkey) registered as a validator on the subnet. - **A reachable eval server** (`EVAL_SERVER_URL`), typically an SSH tunnel to a GPU box. -- **An own bucket** (`R2_OWN_BUCKET` + `R2_OWN_WRITE_*`) for durable king state. +- **A private Hippius state bucket** (`HIPPIUS_OWN_BUCKET` + + `HIPPIUS_OWN_WRITE_*`) for durable king state. Legacy `R2_OWN_*` aliases remain + supported. - **A separate public-read dashboard bucket** (`LEOMA_DASHBOARD_BUCKET`) defaulting to the same owner endpoint/write credentials. Providers with bucket-scoped keys can use the optional `LEOMA_DASHBOARD_*` overrides. Expose only @@ -81,12 +83,16 @@ The validator scans reveals, dispatches duels to an eval server, crowns winners, ```bash # Requires Python 3.12+ pip install -e . # or: uv pip install -e . -cp env.validator.example .env # fill in wallet, EVAL_SERVER_URL, R2_OWN_* +cp env.validator.example .env # fill in wallet, EVAL_SERVER_URL, HIPPIUS_OWN_* leoma serve # scan reveals -> duel -> crown -> set weights ``` -Or with Docker: `cp env.validator.example .env && docker compose up -d validator`. Mount your -Bittensor wallets so the container can sign weight-setting transactions. +For a local image build, use +`cp env.validator.example .env && docker compose up -d validator`. Production is +pull-only and digest-addressed: set `LEOMA_VALIDATOR_IMAGE_DIGEST` and +`BITTENSOR_WALLETS_DIR`, then run +`docker compose -f docker-compose.validator.production.yml up -d`. A mutable tag +cannot be supplied to that production file. --- @@ -98,13 +104,26 @@ downloaded anonymously; Hippius Hub credentials are optional for private genesis registry rate limits. ```bash -pip install -e '.[eval]' +uv sync --frozen --extra eval --no-dev cp env.eval.example .env # fill in HIPPIUS_VIDEOS_READ_* -leoma servers eval-server # FastAPI on EVAL_SERVER_PORT (default 9000) +uv run leoma servers eval-server # FastAPI on EVAL_SERVER_PORT (default 9000) ``` The validator reaches it over `EVAL_SERVER_URL` (default `http://localhost:9000`, usually an SSH tunnel). One duel runs at a time. See `ecosystem.eval.config.js` for a PM2 launcher. +The evaluator refuses jobs unless its Python, CUDA, H100 capability, uv lock, and +installed generation/scoring packages match the runtime pinned in `chain.toml`. +The production 8xH100 layout runs four digest-pinned containers from +`docker-compose.eval.8xh100.production.yml`, one isolated process per GPU pair. +See `docs/PRODUCTION_8XH100_RUNBOOK.md` for image publication and rollout. + +For a live-chain rehearsal on a non-production validator host, run +`leoma rehearse --ticks N --state-bucket --state-prefix +rehearsals/`. It exercises +the real reveal, evaluator, verdict, persistence, and dashboard paths while the +central weight boundary suppresses every `set_weights` extrinsic. The rehearsal +bucket must differ from the configured validator state bucket, and the process exits +after `N` ticks. Before a new eval box is allowed to duel, prove it decodes the pinned corpus byte-identically: diff --git a/docker-compose.eval.8xh100.production.yml b/docker-compose.eval.8xh100.production.yml new file mode 100644 index 0000000..b0de680 --- /dev/null +++ b/docker-compose.eval.8xh100.production.yml @@ -0,0 +1,74 @@ +# Leoma production evaluator fleet (run on the 8xH100 host). +# +# Four isolated processes each see exactly two physical GPUs. Inside a container the +# assigned pair is remapped to cuda:0/cuda:1. Host ports bind loopback only and are +# reached from the validator through the authenticated SSH tunnel in the runbook. +x-eval-common: &eval-common + image: rendixnetwork/leoma@${LEOMA_EVAL_IMAGE_DIGEST:?set LEOMA_EVAL_IMAGE_DIGEST to sha256:<64hex>} + pull_policy: always + restart: unless-stopped + command: leoma servers eval-server + env_file: + - .env + environment: &eval-environment + PYTHONUNBUFFERED: "1" + EVAL_SERVER_HOST: "0.0.0.0" + EVAL_SERVER_PORT: "9000" + LEOMA_EVAL_TOKEN: ${LEOMA_EVAL_TOKEN:?set LEOMA_EVAL_TOKEN in .env} + HIPPIUS_HUB_TOKEN: ${HIPPIUS_HUB_TOKEN:-} + LEOMA_MODEL_CACHE_DIR: ${LEOMA_MODEL_CACHE_DIR:-/var/lib/leoma/models} + LEOMA_MAX_CACHED_SNAPSHOTS: ${LEOMA_MAX_CACHED_SNAPSHOTS:-8} + LEOMA_MIN_FREE_BYTES: ${LEOMA_MIN_FREE_BYTES:-322122547200} + OBJECT_STORAGE_BACKEND: ${OBJECT_STORAGE_BACKEND:-hippius} + HIPPIUS_ENDPOINT: ${HIPPIUS_ENDPOINT:-s3.hippius.com} + HIPPIUS_REGION: ${HIPPIUS_REGION:-decentralized} + HIPPIUS_SOURCE_BUCKET: ${HIPPIUS_SOURCE_BUCKET:-leoma-source} + HIPPIUS_VIDEOS_READ_ACCESS_KEY: ${HIPPIUS_VIDEOS_READ_ACCESS_KEY:-} + HIPPIUS_VIDEOS_READ_SECRET_KEY: ${HIPPIUS_VIDEOS_READ_SECRET_KEY:-} + LEOMA_CONCURRENT_GENERATION: "1" + LEOMA_KING_DEVICE: cuda:0 + LEOMA_CHALLENGER_DEVICE: cuda:1 + volumes: + - ${LEOMA_MODEL_CACHE_HOST_DIR:-/var/lib/leoma/models}:${LEOMA_MODEL_CACHE_DIR:-/var/lib/leoma/models} + - ${LEOMA_CALIBRATION_HOST_DIR:-./calibration}:/var/lib/leoma/calibration + +services: + leoma-eval-0: + <<: *eval-common + container_name: leoma-eval-0 + ports: + - "127.0.0.1:9000:9000" + gpus: + - driver: nvidia + device_ids: ["0", "1"] + capabilities: [gpu] + + leoma-eval-1: + <<: *eval-common + container_name: leoma-eval-1 + ports: + - "127.0.0.1:9001:9000" + gpus: + - driver: nvidia + device_ids: ["2", "3"] + capabilities: [gpu] + + leoma-eval-2: + <<: *eval-common + container_name: leoma-eval-2 + ports: + - "127.0.0.1:9002:9000" + gpus: + - driver: nvidia + device_ids: ["4", "5"] + capabilities: [gpu] + + leoma-eval-3: + <<: *eval-common + container_name: leoma-eval-3 + ports: + - "127.0.0.1:9003:9000" + gpus: + - driver: nvidia + device_ids: ["6", "7"] + capabilities: [gpu] diff --git a/docker-compose.validator.production.yml b/docker-compose.validator.production.yml new file mode 100644 index 0000000..771f7d4 --- /dev/null +++ b/docker-compose.validator.production.yml @@ -0,0 +1,50 @@ +# Leoma production validator (run on the validator host). +# +# The image is pull-only and addressed by registry digest. Set the variable to the +# raw `sha256:<64 hex>` manifest digest; a mutable image tag cannot be supplied. +services: + validator: + image: rendixnetwork/leoma@${LEOMA_VALIDATOR_IMAGE_DIGEST:?set LEOMA_VALIDATOR_IMAGE_DIGEST to sha256:<64hex>} + pull_policy: always + container_name: leoma-validator + restart: unless-stopped + command: leoma serve + env_file: + - .env + environment: + PYTHONUNBUFFERED: "1" + NETWORK: ${NETWORK:-finney} + NETUID: ${NETUID:-36} + LEOMA_TESTNET_SUBNET_NAME: ${LEOMA_TESTNET_SUBNET_NAME:-} + WALLET_NAME: ${WALLET_NAME:-default} + HOTKEY_NAME: ${HOTKEY_NAME:-default} + EVAL_SERVER_URL: ${EVAL_SERVER_URL:-http://127.0.0.1:9000} + EVAL_SERVER_URLS: ${EVAL_SERVER_URLS:-} + LEOMA_EVAL_TOKEN: ${LEOMA_EVAL_TOKEN:?set LEOMA_EVAL_TOKEN in .env} + OBJECT_STORAGE_BACKEND: ${OBJECT_STORAGE_BACKEND:-hippius} + HIPPIUS_HUB_TOKEN: ${HIPPIUS_HUB_TOKEN:-} + HIPPIUS_ENDPOINT: ${HIPPIUS_ENDPOINT:-s3.hippius.com} + HIPPIUS_REGION: ${HIPPIUS_REGION:-decentralized} + HIPPIUS_SOURCE_BUCKET: ${HIPPIUS_SOURCE_BUCKET:-leoma-source} + HIPPIUS_VIDEOS_READ_ACCESS_KEY: ${HIPPIUS_VIDEOS_READ_ACCESS_KEY:-} + HIPPIUS_VIDEOS_READ_SECRET_KEY: ${HIPPIUS_VIDEOS_READ_SECRET_KEY:-} + HIPPIUS_OWN_BUCKET: ${HIPPIUS_OWN_BUCKET:-} + HIPPIUS_OWN_WRITE_ACCESS_KEY: ${HIPPIUS_OWN_WRITE_ACCESS_KEY:-} + HIPPIUS_OWN_WRITE_SECRET_KEY: ${HIPPIUS_OWN_WRITE_SECRET_KEY:-} + HIPPIUS_OWN_ENDPOINT: ${HIPPIUS_OWN_ENDPOINT:-} + HIPPIUS_OWN_REGION: ${HIPPIUS_OWN_REGION:-decentralized} + # Legacy Cloudflare R2 aliases. + R2_OWN_BUCKET: ${R2_OWN_BUCKET:-} + R2_OWN_WRITE_ACCESS_KEY: ${R2_OWN_WRITE_ACCESS_KEY:-} + R2_OWN_WRITE_SECRET_KEY: ${R2_OWN_WRITE_SECRET_KEY:-} + R2_OWN_ENDPOINT: ${R2_OWN_ENDPOINT:-} + R2_OWN_REGION: ${R2_OWN_REGION:-auto} + LEOMA_DASHBOARD_BUCKET: ${LEOMA_DASHBOARD_BUCKET:-} + LEOMA_DASHBOARD_ENDPOINT: ${LEOMA_DASHBOARD_ENDPOINT:-} + LEOMA_DASHBOARD_REGION: ${LEOMA_DASHBOARD_REGION:-} + LEOMA_DASHBOARD_WRITE_ACCESS_KEY: ${LEOMA_DASHBOARD_WRITE_ACCESS_KEY:-} + LEOMA_DASHBOARD_WRITE_SECRET_KEY: ${LEOMA_DASHBOARD_WRITE_SECRET_KEY:-} + volumes: + # Preflight reads the real hotkey and coldkeypub. Never bake wallet files + # into an image or make the host wallet writable by the container. + - ${BITTENSOR_WALLETS_DIR:?set BITTENSOR_WALLETS_DIR to the host wallets directory}:/root/.bittensor/wallets:ro diff --git a/docker-compose.yml b/docker-compose.yml index 2298424..ff0f3b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,16 @@ -# Leoma Subnet — king of the hill. +# Leoma Subnet — local/dev builds only. # # Two roles: the validator (scans reveals, dispatches duels, sets weights) and # the eval server (GPU box that downloads + runs miner models). They are usually -# on separate hosts; this compose is a convenience for co-locating them. +# on separate hosts; this compose is a convenience for co-locating and rebuilding +# them. Production MUST use docker-compose.validator.production.yml and +# docker-compose.eval.8xh100.production.yml, which accept only image digests and +# contain no build instructions. services: # Validator: scan reveals -> duel via eval server -> crown -> set weights. validator: build: . - image: rendixnetwork/leoma:latest + image: rendixnetwork/leoma:dev-validator container_name: leoma-validator restart: unless-stopped command: leoma serve @@ -16,7 +19,7 @@ services: environment: PYTHONUNBUFFERED: "1" NETWORK: ${NETWORK:-finney} - NETUID: ${NETUID:-99} + NETUID: ${NETUID:-36} WALLET_NAME: ${WALLET_NAME:-default} HOTKEY_NAME: ${HOTKEY_NAME:-default} EVAL_SERVER_URL: ${EVAL_SERVER_URL:-http://eval-server:9000} @@ -48,7 +51,7 @@ services: build: context: . dockerfile: Dockerfile.eval - image: rendixnetwork/leoma:latest-eval + image: rendixnetwork/leoma:dev-eval container_name: leoma-eval-server restart: unless-stopped command: leoma servers eval-server diff --git a/docs/DEPENDENCY_SECURITY.md b/docs/DEPENDENCY_SECURITY.md new file mode 100644 index 0000000..0ed2ec4 --- /dev/null +++ b/docs/DEPENDENCY_SECURITY.md @@ -0,0 +1,58 @@ +# Dependency security and numerical-runtime policy + +Last reviewed: 2026-08-02. + +Leoma has two different upgrade classes. Validator, network, HTTP, crypto, and CLI +packages may be patched after the normal test/build gate. Packages that decode +frames, load weights, generate video, or calculate scores are consensus-sensitive: +their upgrade also requires a coordinated runtime-digest release and H100 +calibration. An unattended dependency bot must never merge the latter class. + +## Current audit result + +The locked validator graph has no known advisory after upgrading FastAPI/Starlette, +Pillow, cryptography, urllib3, idna, msgpack, pyasn1, Pygments, Click, setuptools, +and yt-dlp. Pillow 12.3.0 is a frame-processing change, so this release still needs +the calibration procedure below before mainnet rollout. + +The evaluator intentionally retains Torch 2.6.0 and Diffusers 0.35.2 until the +replacement stack passes a real Wan2.2 compatibility/calibration run. The current +advisory database reports findings in both. Their most serious miner-reachable class +is malicious repository code or pickle deserialization. Leoma applies independent +controls before either library sees a miner snapshot: + +- the registry request allows only JSON/config/tokenizer data and `.safetensors`; +- the materialized directory is rechecked before every load, including completed + cache entries; executable files, pickle weights, symlinks, and special files fail; +- Diffusers is forced offline with `local_files_only=True`, + `trust_remote_code=False`, and `use_safetensors=True`; +- the pipeline, component libraries/classes, shape-critical config, and total model + size must match the immutable Wan2.2 base architecture. + +These controls substantially reduce reachability but do not make an old ML runtime +equivalent to a patched one. The evaluator remains a dedicated, authenticated box; +its ports bind loopback, model repositories are treated as hostile, and no wallet or +state-bucket write credentials belong on that host. + +The dashboard lock has all available non-breaking fixes. npm currently reports one +React Router RSC action-CSRF advisory with no non-vulnerable published version; its +suggested downgrade reintroduces older advisories. Leoma is a client-rendered Vite +SPA using `BrowserRouter`/`Routes`, with no RSC server, route actions, loaders, or +document request handler, so the affected server path is absent. Recheck and remove +this exception as soon as a fixed release is published. + +## Consensus-sensitive upgrade gate + +For Pillow, Torch, Diffusers, Transformers, Accelerate, safetensors, NumPy, SciPy, +OpenCV, torchvision, LPIPS, or OpenCLIP changes: + +1. Update exact versions in `uv.lock` and `leoma/eval/runtime_lock.py`. +2. Set `[runtime].eval_lock_digest` in `chain.toml` to the new `uv.lock` SHA-256. +3. Build and publish the eval image, then record its immutable registry digest. +4. On every physical H100, run two same-model control records and analyze all 16. +5. Run a complete seed-versus-seed duel and a known real challenger regression. +6. Only after every check passes, deploy that exact image digest to all four eval + pairs and the matching consensus release to every validator. + +Do not mix old/new runtime digests. Evaluator health and validator dispatch are +fail-closed specifically to prevent that partial rollout from producing verdicts. diff --git a/docs/PRODUCTION_8XH100_RUNBOOK.md b/docs/PRODUCTION_8XH100_RUNBOOK.md index c3f0712..08016bd 100644 --- a/docs/PRODUCTION_8XH100_RUNBOOK.md +++ b/docs/PRODUCTION_8XH100_RUNBOOK.md @@ -17,29 +17,45 @@ headroom. A full four-pair fleet can hold one king and four challengers on disk eight loaded pipelines in host memory. Use at least 2 TB of local NVMe and 768 GB of system RAM; keep `LEOMA_MIN_FREE_BYTES` at 300 GiB or higher. -## 1. Host and dependency checks +## 0. Build and publish immutable release images -Install Python 3.12, ffmpeg, Node/PM2, and a driver that supports the locked CUDA -12.4 PyTorch stack. Confirm all devices are healthy: +Both Dockerfiles pin their base image manifest, uv version, and complete Python +resolution. Choose a release identifier, build the two roles, and push them: ```bash -nvidia-smi --query-gpu=index,name,uuid,memory.total,driver_version --format=csv -ffmpeg -version -df -h /var/lib/leoma +RELEASE= +docker buildx build --platform linux/amd64 -f Dockerfile \ + -t "rendixnetwork/leoma:validator-${RELEASE}" --push . +docker buildx build --platform linux/amd64 -f Dockerfile.eval \ + -t "rendixnetwork/leoma:eval-${RELEASE}" --push . +docker buildx imagetools inspect "rendixnetwork/leoma:validator-${RELEASE}" +docker buildx imagetools inspect "rendixnetwork/leoma:eval-${RELEASE}" ``` -Exactly eight H100s must be visible and no other process may consume their VRAM. -Install the frozen project environment rather than resolving dependencies again: +Copy each reported manifest `sha256:` digest into the corresponding host `.env`. +Production Compose constructs `repository@sha256:...` itself and contains no build +instruction, so a mutable tag cannot accidentally be deployed. + +## 1. Host and dependency checks + +Install Docker Engine, the Compose plugin, NVIDIA Container Toolkit, and a driver +that supports the locked CUDA 12.4 PyTorch stack. Python, ffmpeg, and all Python +packages come from the digest-pinned image; do not install a parallel host runtime. +Confirm all devices are healthy: ```bash -python3.12 -m venv venv -. venv/bin/activate -uv sync --active --frozen --extra eval --no-dev -python -c "import torch,diffusers,transformers,hippius_hub; print(torch.__version__, diffusers.__version__, transformers.__version__, hippius_hub.__version__)" +nvidia-smi --query-gpu=index,name,uuid,memory.total,driver_version --format=csv +docker compose version +nvidia-container-cli info +df -h /var/lib/leoma ``` +Exactly eight H100s must be visible and no other process may consume their VRAM. Expected core versions are PyTorch 2.6.0, Diffusers 0.35.2, Transformers 5.14.1, -and Hippius Hub 0.6.1. +and Hippius Hub 0.6.1. The evaluator also hashes the complete `uv.lock`, verifies +the installed generation/scoring packages, and requires CUDA 12.4 on compute +capability 9.0. `/health` must report `eval_runtime_compatible: true`; otherwise +the server returns HTTP 503 instead of starting a duel. ## 2. Eval-host secrets @@ -56,17 +72,22 @@ HIPPIUS_VIDEOS_READ_ACCESS_KEY= HIPPIUS_VIDEOS_READ_SECRET_KEY= LEOMA_EVAL_TOKEN= LEOMA_MODEL_CACHE_DIR=/var/lib/leoma/models +LEOMA_MODEL_CACHE_HOST_DIR=/var/lib/leoma/models +LEOMA_CALIBRATION_HOST_DIR=./calibration LEOMA_MAX_CACHED_SNAPSHOTS=8 LEOMA_MIN_FREE_BYTES=322122547200 +LEOMA_EVAL_IMAGE_DIGEST=sha256: ``` -Load it into the shell used to start PM2: +Load it into the shell used to run Compose and prepare the two persistent host +directories: ```bash set -a . ./.env set +a install -d -m 0750 /var/lib/leoma/models +install -d -m 0750 ./calibration ``` ## 3. Verify the pinned corpus on this box @@ -74,8 +95,11 @@ install -d -m 0750 /var/lib/leoma/models First verify a quick sample, then the complete corpus before production: ```bash -venv/bin/leoma corpus verify --sample 4 -venv/bin/leoma corpus verify --sample 0 +docker compose -f docker-compose.eval.8xh100.production.yml pull +docker compose -f docker-compose.eval.8xh100.production.yml run --rm leoma-eval-0 \ + leoma corpus verify --sample 4 +docker compose -f docker-compose.eval.8xh100.production.yml run --rm leoma-eval-0 \ + leoma corpus verify --sample 0 ``` Both commands must report byte-identical decoding. A failure means this host cannot @@ -87,13 +111,9 @@ All four eval processes share one cache. Download the 126 GB genesis model once, before starting any of them, so no processes race over a partial snapshot: ```bash -venv/bin/python - <<'PY' -from leoma.infra.chain_config import SEED_DIGEST, SEED_REPO -from leoma.infra.model_store import ModelRef, materialize_model - -path = materialize_model(ModelRef(SEED_REPO, SEED_DIGEST)) -print(path) -PY +docker compose -f docker-compose.eval.8xh100.production.yml run --rm \ + --entrypoint python leoma-eval-0 -c \ + 'from leoma.infra.chain_config import SEED_DIGEST, SEED_REPO; from leoma.infra.model_store import ModelRef, materialize_model; print(materialize_model(ModelRef(SEED_REPO, SEED_DIGEST)))' ``` Do not proceed until the snapshot directory contains `.leoma_complete.json` and the @@ -105,45 +125,54 @@ Identical model names do not prove identical numerical behavior. Run two records each physical GPU to measure both repeatability and card-to-card noise: ```bash -mkdir -p calibration +compose_file=docker-compose.eval.8xh100.production.yml for run in 1 2; do - for gpu in 0 1 2 3 4 5 6 7; do - CUDA_VISIBLE_DEVICES="$gpu" \ - venv/bin/leoma calibrate generate \ - --gpu "h100-${gpu}-run${run}" \ - --n-clips 32 \ - --out "calibration/h100-${gpu}-run${run}.json" + for service in 0 1 2 3; do + for local_gpu in 0 1; do + gpu=$((service * 2 + local_gpu)) + docker compose -f "$compose_file" run --rm \ + -e CUDA_VISIBLE_DEVICES="$local_gpu" "leoma-eval-${service}" \ + leoma calibrate generate \ + --gpu "h100-${gpu}-run${run}" \ + --n-clips 32 \ + --out "/var/lib/leoma/calibration/h100-${gpu}-run${run}.json" + done done done -venv/bin/leoma calibrate analyze calibration/h100-*.json +docker compose -f "$compose_file" run --rm --entrypoint sh leoma-eval-0 \ + -c 'leoma calibrate analyze /var/lib/leoma/calibration/h100-*.json' ``` `analyze` must return PASS. If it recommends a larger `delta_threshold`, update `chain.toml`, redeploy the exact same config to validator and eval hosts, and rerun preflight. Do not launch through a calibration failure. -## 6. Start one GPU pair first +## 6. Start one digest-pinned GPU pair first -The four-process PM2 file maps physical pairs 0/1, 2/3, 4/5 and 6/7 to local ports -9000 through 9003. Inside each isolated process the pair appears as cuda:0/cuda:1. +The production Compose file maps physical pairs 0/1, 2/3, 4/5 and 6/7 to local +ports 9000 through 9003. Inside each isolated container the pair appears as +cuda:0/cuda:1. ```bash -export LEOMA_BIN="$PWD/venv/bin/leoma" -pm2 start ecosystem.eval.8xh100.config.js --only leoma-eval-0 --update-env +docker compose -f docker-compose.eval.8xh100.production.yml config --quiet +docker compose -f docker-compose.eval.8xh100.production.yml pull +docker compose -f docker-compose.eval.8xh100.production.yml up -d leoma-eval-0 curl -fsS -H "Authorization: Bearer $LEOMA_EVAL_TOKEN" http://127.0.0.1:9000/health ``` +Confirm the response has `status: "ok"`, `eval_runtime_compatible: true`, and equal +`eval_runtime_digest` / `expected_eval_runtime_digest` values before continuing. + Run one real seed/self-evaluation or compatible challenger through this pair and watch VRAM, RAM, disk and progress before enabling the remaining six GPUs. The genesis model alone is not a full on-chain challenger test; a challenger must have different weights and pass the naming rule below. -## 7. Start the four-process fleet +## 7. Start the four-container fleet ```bash -pm2 start ecosystem.eval.8xh100.config.js --update-env -pm2 save -pm2 status +docker compose -f docker-compose.eval.8xh100.production.yml up -d +docker compose -f docker-compose.eval.8xh100.production.yml ps ``` All servers bind loopback and require the Bearer token. If the validator is on a @@ -168,11 +197,13 @@ public miner configs are fetched anonymously: ```dotenv NETWORK=finney -NETUID=99 +NETUID=36 WALLET_NAME= HOTKEY_NAME= EVAL_SERVER_URLS=http://127.0.0.1:9000,http://127.0.0.1:9001,http://127.0.0.1:9002,http://127.0.0.1:9003 LEOMA_EVAL_TOKEN= +LEOMA_VALIDATOR_IMAGE_DIGEST=sha256: +BITTENSOR_WALLETS_DIR=/home//.bittensor/wallets HIPPIUS_HUB_TOKEN= OBJECT_STORAGE_BACKEND=hippius HIPPIUS_ENDPOINT=s3.hippius.com @@ -180,29 +211,34 @@ HIPPIUS_REGION=decentralized HIPPIUS_SOURCE_BUCKET=leoma-source HIPPIUS_VIDEOS_READ_ACCESS_KEY= HIPPIUS_VIDEOS_READ_SECRET_KEY= -R2_OWN_BUCKET= +HIPPIUS_OWN_BUCKET= LEOMA_DASHBOARD_BUCKET= # Optional when the dashboard bucket has its own S3 credentials: # LEOMA_DASHBOARD_ENDPOINT= # LEOMA_DASHBOARD_REGION= # LEOMA_DASHBOARD_WRITE_ACCESS_KEY= # LEOMA_DASHBOARD_WRITE_SECRET_KEY= -R2_OWN_ENDPOINT= -R2_OWN_REGION= -R2_OWN_WRITE_ACCESS_KEY= -R2_OWN_WRITE_SECRET_KEY= +HIPPIUS_OWN_ENDPOINT= +HIPPIUS_OWN_REGION= +HIPPIUS_OWN_WRITE_ACCESS_KEY= +HIPPIUS_OWN_WRITE_SECRET_KEY= ``` -The `R2_OWN_*` names are legacy names for the validator's independent S3-compatible +The `R2_OWN_*` names remain compatibility aliases for the validator's independent S3-compatible state bucket; its endpoint may be Hippius. Then gate startup: ```bash -venv/bin/leoma preflight -venv/bin/leoma serve +docker compose -f docker-compose.validator.production.yml config --quiet +docker compose -f docker-compose.validator.production.yml pull +docker compose -f docker-compose.validator.production.yml run --rm validator leoma preflight +docker compose -f docker-compose.validator.production.yml up -d ``` -Preflight must pass every configured eval URL with identical consensus and scoring -code digests. +Preflight is now fail-closed. It must fetch the exact pinned corpus, load the real +hotkey file, confirm that the configured chain identifies Leoma, and prove the +hotkey is registered with a validator permit. Every configured evaluator must be +reachable and match the consensus, scoring-code, and numerical-runtime digests. +Missing fields from an older evaluator are failures, not warnings. ## 9. Submit a real challenger @@ -224,6 +260,7 @@ Before mainnet, all of these must be true: - all 16 calibration records analyze to PASS; - one complete real duel finishes on a single pair without OOM or watchdog timeout; - all four authenticated health endpoints pass validator preflight; +- the wallet is registered and has a validator permit on `finney` NetUID 36; - validator restart and one eval-process restart recover correctly; - `leoma smoke ` observes every required scenario; - disk monitoring alerts before free space reaches 150 GiB; diff --git a/docs/TESTNET_RUNBOOK.md b/docs/TESTNET_RUNBOOK.md index 321c368..e03a65a 100644 --- a/docs/TESTNET_RUNBOOK.md +++ b/docs/TESTNET_RUNBOOK.md @@ -53,16 +53,19 @@ leoma calibrate analyze box-*.json ## 2. Preflight — the launch gate -On the validator box, with `EVAL_SERVER_URL`, `R2_OWN_BUCKET`, +On the validator box, with `EVAL_SERVER_URL`, `HIPPIUS_OWN_BUCKET` (or the legacy +`R2_OWN_BUCKET` alias), `LEOMA_DASHBOARD_BUCKET`, `WALLET_NAME`, and `HOTKEY_NAME` set: ```bash leoma preflight ``` -It exits non-zero (and says exactly why) if the seed or corpus is unpinned, the -consensus surface is invalid, or the eval box is on a different `chain.toml` / scoring -code than the validator. **Gate your launch script on it:** +It exits non-zero (and says exactly why) if the seed or corpus is unpinned or +unreachable, the wallet does not exist, the hotkey is not registered/permitted on +the configured subnet, or any eval box is unreachable or uses different +`chain.toml`, scoring code, uv lock, numerical packages, CUDA, or GPU capability. +**Gate your launch script on it:** ```bash leoma preflight && leoma serve diff --git a/ecosystem.config.js b/ecosystem.config.js index ac85281..79b8127 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -18,7 +18,7 @@ module.exports = { // until process exit. Production health must be observable in real time. PYTHONUNBUFFERED: process.env.PYTHONUNBUFFERED || "1", NETWORK: process.env.NETWORK || "finney", - NETUID: process.env.NETUID || "99", + NETUID: process.env.NETUID || "36", WALLET_NAME: process.env.WALLET_NAME || "default", HOTKEY_NAME: process.env.HOTKEY_NAME || "default", diff --git a/ecosystem.eval.8xh100.config.js b/ecosystem.eval.8xh100.config.js index b15827c..6a9810f 100644 --- a/ecosystem.eval.8xh100.config.js +++ b/ecosystem.eval.8xh100.config.js @@ -39,6 +39,7 @@ const shared = { LEOMA_MODEL_CACHE_DIR: process.env.LEOMA_MODEL_CACHE_DIR || "/var/lib/leoma/models", LEOMA_MAX_CACHED_SNAPSHOTS: process.env.LEOMA_MAX_CACHED_SNAPSHOTS || "8", LEOMA_MIN_FREE_BYTES: process.env.LEOMA_MIN_FREE_BYTES || "322122547200", + LEOMA_UV_LOCK_PATH: process.env.LEOMA_UV_LOCK_PATH || `${__dirname}/uv.lock`, LEOMA_CONCURRENT_GENERATION: "1", LEOMA_KING_DEVICE: "cuda:0", LEOMA_CHALLENGER_DEVICE: "cuda:1", diff --git a/ecosystem.eval.config.js b/ecosystem.eval.config.js index edc68a5..9b589b6 100644 --- a/ecosystem.eval.config.js +++ b/ecosystem.eval.config.js @@ -24,6 +24,7 @@ module.exports = { HIPPIUS_HUB_USERNAME: process.env.HIPPIUS_HUB_USERNAME || "", HIPPIUS_HUB_PASSWORD: process.env.HIPPIUS_HUB_PASSWORD || "", LEOMA_MODEL_CACHE_DIR: process.env.LEOMA_MODEL_CACHE_DIR || "/var/lib/leoma/models", + LEOMA_UV_LOCK_PATH: process.env.LEOMA_UV_LOCK_PATH || `${__dirname}/uv.lock`, // Hippius S3 source-video corpus (ground-truth continuations). OBJECT_STORAGE_BACKEND: "hippius", diff --git a/env.eval.example b/env.eval.example index e637a72..7669957 100644 --- a/env.eval.example +++ b/env.eval.example @@ -6,6 +6,8 @@ EVAL_SERVER_HOST=127.0.0.1 EVAL_SERVER_PORT=9000 +# Production Docker only: raw registry manifest digest, never a tag. +# LEOMA_EVAL_IMAGE_DIGEST=sha256:<64hex> # Required if binding anywhere except loopback; recommended everywhere. The validator # must use the same value. Sent as an Authorization: Bearer header. LEOMA_EVAL_TOKEN= @@ -16,6 +18,8 @@ HIPPIUS_HUB_TOKEN= # HIPPIUS_HUB_USERNAME= # HIPPIUS_HUB_PASSWORD= LEOMA_MODEL_CACHE_DIR=/var/lib/leoma/models +# Host path bind-mounted at LEOMA_MODEL_CACHE_DIR by the production Compose file. +LEOMA_MODEL_CACHE_HOST_DIR=/var/lib/leoma/models LEOMA_MAX_CACHED_SNAPSHOTS=4 LEOMA_MIN_FREE_BYTES=161061273600 diff --git a/env.validator.example b/env.validator.example index 168f99d..258a1ce 100644 --- a/env.validator.example +++ b/env.validator.example @@ -8,7 +8,15 @@ WALLET_NAME=default HOTKEY_NAME=default NETWORK=finney -NETUID=99 +NETUID=36 +# Non-mainnet dress rehearsals may target a borrowed subnet whose on-chain name has +# not been changed to Leoma. This override is rejected on finney/mainnet. +# LEOMA_TESTNET_SUBNET_NAME= + +# Production Docker only: raw registry manifest digest, never a tag. The host +# wallet directory is mounted read-only by docker-compose.validator.production.yml. +# LEOMA_VALIDATOR_IMAGE_DIGEST=sha256:<64hex> +# BITTENSOR_WALLETS_DIR=/home//.bittensor/wallets # Eval server the validator dispatches duels to (SSH-tunnel to the GPU box). EVAL_SERVER_URL=http://localhost:9000 @@ -43,15 +51,26 @@ LEOMA_KING_CHAIN_SIZE=5 LEOMA_WEIGHT_INTERVAL=300 LEOMA_BURN_UID=0 -# This validator's own bucket for durable king state (king/current.json, etc.). -R2_OWN_BUCKET= -R2_OWN_WRITE_ACCESS_KEY= -R2_OWN_WRITE_SECRET_KEY= -# Set only if your bucket is on a different R2 account than the default: +# This validator's own private Hippius bucket for durable king state +# (state/state.json, king/current.json, etc.). Bucket-scoped write credentials are +# supported. The legacy R2_OWN_* names remain valid for existing deployments. +HIPPIUS_OWN_BUCKET= +HIPPIUS_OWN_WRITE_ACCESS_KEY= +HIPPIUS_OWN_WRITE_SECRET_KEY= +# Defaults to HIPPIUS_ENDPOINT/HIPPIUS_REGION. +# HIPPIUS_OWN_ENDPOINT=s3.hippius.com +# HIPPIUS_OWN_REGION=decentralized +# Required only by `leoma rehearse`; it must differ from the validator state bucket. +# LEOMA_REHEARSAL_BUCKET=your-validator-rehearsal-bucket +# LEOMA_REHEARSAL_PREFIX=rehearsals/ +# Legacy Cloudflare R2 configuration: +# R2_OWN_BUCKET= +# R2_OWN_WRITE_ACCESS_KEY= +# R2_OWN_WRITE_SECRET_KEY= # R2_OWN_ENDPOINT= # R2_OWN_REGION=auto -# Dedicated public-read bucket for dashboard.json. It defaults to the R2_OWN +# Dedicated public-read bucket for dashboard.json. It defaults to the validator-state # endpoint and write credentials but must not be the canonical state bucket. LEOMA_DASHBOARD_BUCKET=leoma-dashboard # Set these only when the provider issues bucket-scoped credentials for the diff --git a/leoma/__init__.py b/leoma/__init__.py index 4f2533e..bfcb1f3 100644 --- a/leoma/__init__.py +++ b/leoma/__init__.py @@ -1,5 +1,14 @@ """Leoma package metadata.""" -__version__ = "0.3.2" +from importlib.metadata import PackageNotFoundError, version + + +try: + # pyproject.toml / installed distribution metadata is the single source of + # truth. A duplicated literal here previously made the 0.3.9 image advertise + # itself as 0.3.2 even though its wheel metadata was correct. + __version__ = version("leoma") +except PackageNotFoundError: # source tree imported before installation + __version__ = "0+unknown" __all__ = ["__version__"] diff --git a/leoma/app/preflight.py b/leoma/app/preflight.py index 7ee3585..56bc011 100644 --- a/leoma/app/preflight.py +++ b/leoma/app/preflight.py @@ -8,8 +8,8 @@ ``run_preflight`` is that single place. It runs every readiness check, classifies each as pass / warn / fail, and returns an overall verdict. ``fail`` means "the validator -will not crown anyone until you fix this"; ``warn`` means "this will work but you -probably didn't mean it" (e.g. no eval server configured to check against). +cannot safely launch"; ``warn`` is reserved for non-consensus observability such as +an optional public dashboard bucket. The checks are **pure functions of already-fetched inputs** — the CLI does the I/O (HTTP to the eval box, a HEAD on the corpus) and hands the results here — so the @@ -96,9 +96,12 @@ def check_consensus_digest(consensus_digest: str) -> CheckResult: def check_corpus_reachable(fetched_digest: Optional[str], pinned_digest: str, error: Optional[str]) -> CheckResult: """Given the digest of the manifest actually fetched from the bucket, does it match?""" if error: - return CheckResult("corpus_fetch", WARN, f"could not fetch the corpus manifest to verify it: {error}") + return CheckResult("corpus_fetch", FAIL, f"could not fetch the pinned corpus manifest: {error}") if not fetched_digest: - return CheckResult("corpus_fetch", WARN, "corpus manifest not checked (no bucket credentials)") + return CheckResult( + "corpus_fetch", FAIL, + "corpus manifest was not fetched (missing or unusable source-bucket credentials)", + ) if fetched_digest == pinned_digest: return CheckResult("corpus_fetch", PASS, "published manifest matches the pinned digest") return CheckResult( @@ -112,6 +115,7 @@ def check_eval_server( health: Optional[dict], our_consensus_digest: str, our_eval_code_digest: str, + our_eval_runtime_digest: str, error: Optional[str] = None, *, name: str = "eval_server", @@ -122,9 +126,11 @@ def check_eval_server( when checking a whole ``EVAL_SERVER_URLS`` fleet instead of a single box. """ if error: - return CheckResult(name, WARN, f"eval server not reachable ({error}); skipped") + return CheckResult(name, FAIL, f"eval server not reachable ({error})") if health is None: - return CheckResult(name, WARN, "no eval server configured to check against (set EVAL_SERVER_URL(S))") + return CheckResult( + name, FAIL, "no eval server configured (set EVAL_SERVER_URL or EVAL_SERVER_URLS)" + ) theirs_consensus = health.get("consensus_digest") theirs_code = health.get("eval_code_digest") @@ -140,7 +146,7 @@ def check_eval_server( # have literally no evidence its scoring code matches, so this must not read as # a silent PASS. return CheckResult( - name, WARN, + name, FAIL, "eval box's /health did not report eval_code_digest (stale build?) — its " "scoring code could not be verified against this validator's.", ) @@ -150,7 +156,20 @@ def check_eval_server( f"eval box runs DIFFERENT scoring code (box {str(theirs_code)[:19]}…, " f"validator {our_eval_code_digest[:19]}…) — its distances would not be reproducible.", ) - return CheckResult(name, PASS, "eval box matches this validator's consensus surface + code") + theirs_runtime = health.get("eval_runtime_digest") + if health.get("eval_runtime_compatible") is not True: + issues = health.get("eval_runtime_issues") or [] + detail = f": {'; '.join(str(item) for item in issues[:5])}" if issues else "" + return CheckResult(name, FAIL, f"eval box reports an incompatible numerical runtime{detail}") + if theirs_runtime != our_eval_runtime_digest: + return CheckResult( + name, FAIL, + f"eval box runs a DIFFERENT numerical runtime (box " + f"{str(theirs_runtime)[:19]}…, validator {our_eval_runtime_digest[:19]}…).", + ) + return CheckResult( + name, PASS, "eval box matches this validator's consensus, code, and runtime" + ) class EvalServerProbe(NamedTuple): @@ -164,6 +183,7 @@ def check_eval_servers( probes: tuple[EvalServerProbe, ...], our_consensus_digest: str, our_eval_code_digest: str, + our_eval_runtime_digest: str, ) -> tuple[CheckResult, ...]: """One :func:`check_eval_server` result per configured ``EVAL_SERVER_URLS`` entry. @@ -172,28 +192,73 @@ def check_eval_servers( configured server (or none at all) would leave the rest of the fleet unverified. """ if not probes: - return (check_eval_server(None, our_consensus_digest, our_eval_code_digest),) + return ( + check_eval_server( + None, our_consensus_digest, our_eval_code_digest, our_eval_runtime_digest + ), + ) if len(probes) == 1: p = probes[0] - return (check_eval_server(p.health, our_consensus_digest, our_eval_code_digest, p.error),) + return ( + check_eval_server( + p.health, + our_consensus_digest, + our_eval_code_digest, + our_eval_runtime_digest, + p.error, + ), + ) return tuple( check_eval_server( - p.health, our_consensus_digest, our_eval_code_digest, p.error, + p.health, + our_consensus_digest, + our_eval_code_digest, + our_eval_runtime_digest, + p.error, name=f"eval_server[{p.url}]", ) for p in probes ) -def check_state_bucket(own_bucket: Optional[str]) -> CheckResult: +def check_state_bucket( + own_bucket: Optional[str], error: Optional[str] = None +) -> CheckResult: if own_bucket and own_bucket.strip(): + if error: + return CheckResult( + "state_bucket", + FAIL, + f"validator state bucket {own_bucket} is configured but not reachable " + f"with its write credentials ({error})", + ) return CheckResult("state_bucket", PASS, f"king state persists to {own_bucket}") return CheckResult( "state_bucket", FAIL, - "R2_OWN_BUCKET is not set — the validator cannot persist king state and will refuse to run.", + "the validator state bucket is not set (HIPPIUS_OWN_BUCKET for Hippius, " + "R2_OWN_BUCKET for R2) — the validator cannot persist king state and will refuse to run.", ) +def expected_subnet_name( + network: str, + canonical_name: str, + testnet_override: Optional[str] = None, +) -> str: + """Resolve the subnet identity preflight should require. + + Test chains are often borrowed before a subnet receives its production identity. + An explicit override permits that dress rehearsal without weakening the mainnet + wrong-NetUID guard. Mainnet always remains pinned to ``chain.toml``. + """ + override = (testnet_override or "").strip() + if not override: + return canonical_name + if network.strip().casefold() in {"finney", "mainnet"}: + raise ValueError("LEOMA_TESTNET_SUBNET_NAME cannot override a mainnet subnet identity") + return override + + def check_dashboard_bucket( dashboard_bucket: Optional[str], own_bucket: Optional[str], @@ -203,7 +268,7 @@ def check_dashboard_bucket( Dashboard publication is observability, not consensus, so a missing dedicated bucket is a warning rather than a launch-blocking failure. The validator retains - the legacy fallback to ``R2_OWN_BUCKET`` but preflight makes that fallback visible. + a same-state-bucket fallback but preflight makes that fallback visible. """ dashboard = (dashboard_bucket or "").strip() state = (own_bucket or "").strip() @@ -236,10 +301,76 @@ def check_dashboard_bucket( ) -def check_wallet(wallet_name: Optional[str], hotkey_name: Optional[str]) -> CheckResult: - if wallet_name and hotkey_name: - return CheckResult("wallet", PASS, f"{wallet_name}/{hotkey_name}") - return CheckResult("wallet", WARN, "wallet/hotkey not both set (using defaults)") +class WalletProbe(NamedTuple): + wallet_name: str + hotkey_name: str + hotkey_exists: bool + hotkey_ss58: Optional[str] + error: Optional[str] = None + + +class ChainProbe(NamedTuple): + network: str + netuid: int + subnet_name: Optional[str] + block: Optional[int] + hotkey_ss58: Optional[str] + uid: Optional[int] + validator_permit: bool + error: Optional[str] = None + + +def check_wallet(probe: WalletProbe) -> CheckResult: + if probe.error: + return CheckResult( + "wallet", FAIL, + f"cannot load {probe.wallet_name}/{probe.hotkey_name}: {probe.error}", + ) + if not probe.hotkey_exists or not probe.hotkey_ss58: + return CheckResult( + "wallet", FAIL, + f"hotkey file does not exist or has no address for " + f"{probe.wallet_name}/{probe.hotkey_name}", + ) + return CheckResult( + "wallet", PASS, + f"{probe.wallet_name}/{probe.hotkey_name} ({probe.hotkey_ss58})", + ) + + +def check_chain(probe: ChainProbe, expected_subnet_name: str) -> CheckResult: + if probe.error: + return CheckResult( + "chain", FAIL, + f"cannot verify {probe.network} NetUID {probe.netuid}: {probe.error}", + ) + if not probe.subnet_name: + return CheckResult( + "chain", FAIL, + f"NetUID {probe.netuid} does not exist on {probe.network}", + ) + if probe.subnet_name.casefold() != expected_subnet_name.casefold(): + return CheckResult( + "chain", FAIL, + f"configured {probe.network} NetUID {probe.netuid} is {probe.subnet_name!r}, " + f"not {expected_subnet_name!r}", + ) + if probe.uid is None or not probe.hotkey_ss58: + return CheckResult( + "chain", FAIL, + f"validator hotkey is not registered on {probe.network} NetUID {probe.netuid}", + ) + if not probe.validator_permit: + return CheckResult( + "chain", FAIL, + f"hotkey {probe.hotkey_ss58} is UID {probe.uid} but has no validator permit " + f"on NetUID {probe.netuid}", + ) + return CheckResult( + "chain", PASS, + f"{probe.subnet_name} on {probe.network} NetUID {probe.netuid}, UID {probe.uid}, " + f"validator permit confirmed at block {probe.block}", + ) def run_preflight( @@ -249,9 +380,12 @@ def run_preflight( manifest_digest: str, consensus_digest: str, eval_code_digest: str, + eval_runtime_digest: str, own_bucket: Optional[str], - wallet_name: Optional[str], - hotkey_name: Optional[str], + wallet_probe: WalletProbe, + chain_probe: ChainProbe, + expected_subnet_name: str, + state_error: Optional[str] = None, dashboard_bucket: Optional[str] = None, dashboard_error: Optional[str] = None, corpus_fetched_digest: Optional[str] = None, @@ -270,18 +404,22 @@ def run_preflight( check_corpus_pin(corpus_pinned, manifest_digest), check_consensus_digest(consensus_digest), check_corpus_reachable(corpus_fetched_digest, manifest_digest, corpus_error), - *check_eval_servers(eval_servers, consensus_digest, eval_code_digest), - check_state_bucket(own_bucket), + *check_eval_servers( + eval_servers, consensus_digest, eval_code_digest, eval_runtime_digest + ), + check_state_bucket(own_bucket, state_error), check_dashboard_bucket(dashboard_bucket, own_bucket, dashboard_error), - check_wallet(wallet_name, hotkey_name), + check_wallet(wallet_probe), + check_chain(chain_probe, expected_subnet_name), ] return PreflightReport(tuple(checks)) __all__ = [ "PASS", "WARN", "FAIL", - "CheckResult", "PreflightReport", "EvalServerProbe", "run_preflight", + "CheckResult", "PreflightReport", "EvalServerProbe", "WalletProbe", "ChainProbe", + "run_preflight", "check_seed", "check_corpus_pin", "check_consensus_digest", "check_corpus_reachable", "check_eval_server", "check_eval_servers", - "check_state_bucket", "check_dashboard_bucket", "check_wallet", + "check_state_bucket", "check_dashboard_bucket", "check_wallet", "check_chain", ] diff --git a/leoma/app/validator/failures.py b/leoma/app/validator/failures.py index 9929e3b..eb54e48 100644 --- a/leoma/app/validator/failures.py +++ b/leoma/app/validator/failures.py @@ -76,6 +76,7 @@ def is_local(self) -> bool: ("consensus_mismatch", "consensus_mismatch"), ("consensus_echo_mismatch", "consensus_echo_mismatch"), ("code_mismatch", "code_mismatch"), + ("runtime_mismatch", "runtime_mismatch"), ("does not match the manifest", "corpus_integrity"), ("decoded ground truth does not match", "corpus_integrity"), ("corpus manifest digest mismatch", "corpus_integrity"), diff --git a/leoma/app/validator/main.py b/leoma/app/validator/main.py index 06f31b2..60b5d1c 100644 --- a/leoma/app/validator/main.py +++ b/leoma/app/validator/main.py @@ -16,6 +16,7 @@ import os import asyncio +from contextvars import ContextVar from datetime import datetime, timezone from typing import Optional @@ -44,6 +45,7 @@ ) from leoma.eval.codehash import eval_code_digest from leoma.eval.errors import ConsensusConfigError +from leoma.eval.runtime_lock import expected_eval_runtime_digest from leoma.eval.spec import verify_echo from leoma.app.validator.reveal_scan import ( ChallengerEntry, @@ -105,6 +107,14 @@ def _parse_eval_server_urls(raw: str, fallback: str) -> list[str]: CHALLENGE_POLL_INTERVAL = int(os.environ.get("LEOMA_CHALLENGE_POLL_INTERVAL", "60")) +# Production is the default. The dedicated ``leoma rehearse`` command scopes this +# ContextVar to False for its entire async task, including crown paths that call +# maybe_set_weights(force=True). Keeping the interlock here, at the final extrinsic +# boundary, means a newly-added call site cannot accidentally bypass rehearsal mode. +_WEIGHT_WRITES_ALLOWED: ContextVar[bool] = ContextVar( + "leoma_weight_writes_allowed", default=True +) + # The pre-dispatch architecture check. On by default: it is the difference between a # bad model costing ~5 seconds and costing hours of GPU with the lock held. Off only # as an operator escape hatch if the Hub's config endpoint is misbehaving. @@ -253,12 +263,22 @@ async def preflight_eval_server(client, eval_server_url: str = EVAL_SERVER_URL) reason="consensus_mismatch", ) their_code = health.get("eval_code_digest") - if their_code and their_code != eval_code_digest(): + if their_code != eval_code_digest(): raise EvalJobFailed( f"eval server {eval_server_url} runs different scoring code (box {their_code}, " f"validator {eval_code_digest()}). Its distances would not be reproducible.", reason="code_mismatch", ) + expected_runtime = expected_eval_runtime_digest(SPEC.runtime) + their_runtime = health.get("eval_runtime_digest") + if health.get("eval_runtime_compatible") is not True or their_runtime != expected_runtime: + issues = health.get("eval_runtime_issues") or [] + detail = f" Issues: {'; '.join(str(item) for item in issues[:5])}" if issues else "" + raise EvalJobFailed( + f"eval server {eval_server_url} runs a different numerical runtime (box " + f"{their_runtime}, validator expects {expected_runtime}).{detail}", + reason="runtime_mismatch", + ) async def start_duel( @@ -388,6 +408,19 @@ def _verified_verdict(verdict: Optional[dict]) -> dict: verify_echo(SPEC, verdict.get("echo")) except ConsensusConfigError as e: raise EvalJobFailed(str(e), reason="consensus_echo_mismatch") from e + audit = verdict.get("audit") or {} + if audit.get("eval_code_digest") != eval_code_digest(): + raise EvalJobFailed( + "eval verdict did not prove the expected scoring-code digest", + reason="code_mismatch", + ) + runtime = audit.get("eval_runtime") or {} + expected_runtime = expected_eval_runtime_digest(SPEC.runtime) + if runtime.get("compatible") is not True or runtime.get("digest") != expected_runtime: + raise EvalJobFailed( + "eval verdict did not prove the expected numerical-runtime digest", + reason="runtime_mismatch", + ) return verdict @@ -465,6 +498,17 @@ async def maybe_set_weights( """ current_block = await subtensor.get_current_block() + if not _WEIGHT_WRITES_ALLOWED.get(): + uids, weights, label = K.weight_targets( + state.king, state.king_chain, uid_map, burn_uid=K.BURN_UID + ) + log( + f"[{current_block}] REHEARSAL: would set_weights -> {label}: " + f"uids={uids} weights={[round(w, 4) for w in weights]}; extrinsic suppressed", + "warn", + ) + return False + if not force: if current_block < state.next_weight_block: return False # backing off after a genuine failure @@ -1181,6 +1225,11 @@ async def tick( # hours behind an inline duel: no weights (the chain concludes the validator is # dead) and a dashboard frozen on whatever was true when the duel started. await maybe_set_weights(subtensor, wallet, state, uid_map, store) + # Weight submission is not the persistence boundary. It can be suppressed in a + # rehearsal, rate-limited by the chain, or fail transiently; genesis seeding and + # any other dirty state must still survive a restart. ``flush`` is a cheap no-op + # when a crown or successful weight write already persisted the current state. + await state.flush(store) await _publish_dashboard( state, uid_map, @@ -1189,13 +1238,33 @@ async def tick( ) -async def main() -> None: - """Run the king-of-the-hill validator loop.""" +async def _run_validator( + *, + rehearsal: bool = False, + max_ticks: Optional[int] = None, + state_bucket: Optional[str] = None, + state_prefix: Optional[str] = None, +) -> None: + """Run the validator loop after the weight-write interlock is installed.""" log_header("Leoma Validator Starting (king of the hill)") - if not R2_OWN_BUCKET: - log("R2_OWN_BUCKET not set; validator disabled (cannot persist king state)", "error") + own_bucket = (state_bucket or R2_OWN_BUCKET or "").strip() + if not own_bucket: + log("Validator state bucket not set; validator disabled (cannot persist king state)", "error") return + if rehearsal: + if not state_bucket: + raise ValueError("rehearsal requires an explicit state_bucket") + if R2_OWN_BUCKET and own_bucket == R2_OWN_BUCKET: + raise ValueError("rehearsal state bucket must differ from the validator state bucket") + clean_prefix = (state_prefix or "").strip("/") + if not clean_prefix.startswith("rehearsals/") or ".." in clean_prefix.split("/"): + raise ValueError("rehearsal state_prefix must start with 'rehearsals/'") + log( + "REHEARSAL MODE: live reads/evaluations are enabled; every set_weights " + "extrinsic is structurally suppressed", + "warn", + ) subtensor = bt.AsyncSubtensor(network=NETWORK) wallet = bt.Wallet(name=WALLET_NAME, hotkey=HOTKEY_NAME) @@ -1208,17 +1277,23 @@ async def main() -> None: log(f"Consensus digest: {CONSENSUS_DIGEST} (eval code: {eval_code_digest()})", "info") own_client = create_own_write_client() - await ensure_bucket_exists(own_client, R2_OWN_BUCKET) - store = JsonBucketStore(own_client, R2_OWN_BUCKET) - dashboard_bucket = DASHBOARD_BUCKET or R2_OWN_BUCKET - if not DASHBOARD_BUCKET: + await ensure_bucket_exists(own_client, own_bucket) + store = JsonBucketStore( + own_client, + own_bucket, + key_prefix=(state_prefix or "") if rehearsal else "", + ) + # A rehearsal never publishes into the configured production dashboard bucket. + # Its state and dashboard stay together in the explicitly separate test bucket. + configured_dashboard_bucket = None if rehearsal else DASHBOARD_BUCKET + dashboard_bucket = configured_dashboard_bucket or own_bucket + if not configured_dashboard_bucket: log( - "LEOMA_DASHBOARD_BUCKET not set; dashboard.json is falling back to the " - "private state bucket", + "dashboard.json is using the validator state bucket", "warn", ) dashboard_client = own_client - elif dashboard_bucket != R2_OWN_BUCKET: + elif dashboard_bucket != own_bucket: try: dashboard_client = create_dashboard_write_client() await ensure_bucket_exists(dashboard_client, dashboard_bucket) @@ -1229,19 +1304,27 @@ async def main() -> None: # to the configured bucket on restart. log( f"Dashboard bucket {dashboard_bucket} unavailable ({e}); falling " - f"back to private state bucket {R2_OWN_BUCKET}", + f"back to private state bucket {own_bucket}", "warn", ) - dashboard_bucket = R2_OWN_BUCKET + dashboard_bucket = own_bucket dashboard_client = own_client else: dashboard_client = own_client dashboard_store = JsonBucketStore( dashboard_client, dashboard_bucket, - public_read=dashboard_bucket != R2_OWN_BUCKET, + public_read=dashboard_bucket != own_bucket, + key_prefix=(state_prefix or "") if rehearsal else "", ) - log(f"Public dashboard bucket: {dashboard_bucket}", "info") + if rehearsal: + log( + f"Rehearsal state/dashboard namespace: " + f"s3://{dashboard_bucket}/{state_prefix.strip('/')}/", + "info", + ) + else: + log(f"Public dashboard bucket: {dashboard_bucket}", "info") # A validator that cannot read its state must NOT run: on a chain-derived # system, running on blank state re-duels every past challenger and re-seeds @@ -1272,7 +1355,8 @@ async def main() -> None: except Exception as e: log(f"Startup weight-set failed (will retry on the first tick): {e}", "warn") - while True: + ticks = 0 + while max_ticks is None or ticks < max_ticks: try: await tick(subtensor, wallet, state, store, dashboard_store) except (StoreUnavailable, StateInconsistent) as e: @@ -1283,9 +1367,41 @@ async def main() -> None: except Exception as e: log(f"Validator tick error: {e}", "error") log_exception("Validator tick error", e) + ticks += 1 + if max_ticks is not None and ticks >= max_ticks: + mode = "Rehearsal" if rehearsal else "Bounded validator run" + log(f"{mode} completed after {ticks} validator tick(s)", "success") + break await asyncio.sleep(CHALLENGE_POLL_INTERVAL) +async def main( + *, + rehearsal: bool = False, + max_ticks: Optional[int] = None, + state_bucket: Optional[str] = None, + state_prefix: Optional[str] = None, +) -> None: + """Run production indefinitely, or a bounded no-extrinsic rehearsal. + + Rehearsal mode is deliberately a function argument rather than an environment + toggle accepted by the normal ``serve`` command. It requires a distinct state + bucket and a finite tick count; the CLI enforces both before calling this API. + """ + if rehearsal and (max_ticks is None or max_ticks < 1): + raise ValueError("rehearsal requires max_ticks >= 1") + token = _WEIGHT_WRITES_ALLOWED.set(not rehearsal) + try: + await _run_validator( + rehearsal=rehearsal, + max_ticks=max_ticks, + state_bucket=state_bucket, + state_prefix=state_prefix, + ) + finally: + _WEIGHT_WRITES_ALLOWED.reset(token) + + def main_sync() -> None: """Synchronous entry point for CLI.""" asyncio.run(main()) diff --git a/leoma/app/validator/state_store.py b/leoma/app/validator/state_store.py index 94f9333..5e3e7fb 100644 --- a/leoma/app/validator/state_store.py +++ b/leoma/app/validator/state_store.py @@ -110,6 +110,7 @@ def __init__( retries: int = 3, backoff: float = 0.5, public_read: bool = False, + key_prefix: str = "", ): self.client = client self.bucket = bucket @@ -120,13 +121,19 @@ def __init__( # ACL. An S3 overwrite resets that ACL, so it must be re-applied after every # dashboard.json PUT. Canonical state stores always leave this False. self.public_read = public_read + self.key_prefix = key_prefix.strip("/") + + def _storage_key(self, key: str) -> str: + clean = key.lstrip("/") + return f"{self.key_prefix}/{clean}" if self.key_prefix else clean # ---- blocking core (called via to_thread) ---------------------------- def get_sync(self, key: str) -> Optional[dict]: + storage_key = self._storage_key(key) last: Optional[BaseException] = None for attempt in range(self.retries): try: - resp = self.client.get_object(self.bucket, key) + resp = self.client.get_object(self.bucket, storage_key) except Exception as e: if _is_missing(e): return None @@ -134,7 +141,7 @@ def get_sync(self, key: str) -> Optional[dict]: if attempt < self.retries - 1: time.sleep(self.backoff * (2**attempt)) continue - raise StoreUnavailable(f"get {key}: {e}") from e + raise StoreUnavailable(f"get {storage_key}: {e}") from e try: data = resp.read() @@ -146,17 +153,20 @@ def get_sync(self, key: str) -> Optional[dict]: except (ValueError, TypeError) as e: # A corrupt object is NOT a miss. Surfacing it prevents the # caller from treating it as "fresh bucket" and overwriting. - raise StoreCorrupt(f"{key} is not valid JSON: {e}") from e + raise StoreCorrupt(f"{storage_key} is not valid JSON: {e}") from e - raise StoreUnavailable(f"get {key}: {last}") # pragma: no cover - defensive + raise StoreUnavailable( + f"get {storage_key}: {last}" + ) # pragma: no cover - defensive def put_sync(self, key: str, obj: Any) -> None: + storage_key = self._storage_key(key) payload = json.dumps(obj, default=str, sort_keys=True).encode("utf-8") for attempt in range(self.retries): try: self.client.put_object( self.bucket, - key, + storage_key, io.BytesIO(payload), length=len(payload), content_type="application/json", @@ -168,7 +178,7 @@ def put_sync(self, key: str, obj: Any) -> None: self.client._execute( "PUT", self.bucket, - key, + storage_key, headers={"x-amz-acl": "public-read"}, query_params={"acl": ""}, ) @@ -177,7 +187,7 @@ def put_sync(self, key: str, obj: Any) -> None: if attempt < self.retries - 1: time.sleep(self.backoff * (2**attempt)) continue - raise StoreUnavailable(f"put {key}: {e}") from e + raise StoreUnavailable(f"put {storage_key}: {e}") from e # ---- async wrappers (Minio blocks; the validator loop must not) ------- async def get(self, key: str) -> Optional[dict]: diff --git a/leoma/bootstrap/runtime.py b/leoma/bootstrap/runtime.py index 7cd0c34..e9cd3e4 100644 --- a/leoma/bootstrap/runtime.py +++ b/leoma/bootstrap/runtime.py @@ -359,7 +359,7 @@ class Settings: """Central settings loaded from the environment.""" def __init__(self) -> None: - self.netuid = _read_int("NETUID", 99) + self.netuid = _read_int("NETUID", 36) self.epoch_len = _read_int("EPOCH_LEN", 180) self.request_timeout = _read_int("REQUEST_TIMEOUT", 300) self.wallet_name = _read_str("WALLET_NAME", "default") @@ -408,12 +408,38 @@ def __init__(self) -> None: # ── King-of-the-hill: this validator's own state bucket ── # SAMPLING_ROTATION_INTERVAL is retained only for the (unused) allowlist snapshot. self.sampling_rotation_interval = _read_int("SAMPLING_ROTATION_INTERVAL", 100) - # This validator's own R2 bucket (write creds) for durable king state. - self.r2_own_endpoint = _read_str("R2_OWN_ENDPOINT", self.r2_endpoint_raw) - self.r2_own_region = _read_str("R2_OWN_REGION", self.r2_region) - self.r2_own_bucket = _read_optional_str("R2_OWN_BUCKET") - self.r2_own_write_access_key = _read_optional_str("R2_OWN_WRITE_ACCESS_KEY") - self.r2_own_write_secret_key = _read_optional_str("R2_OWN_WRITE_SECRET_KEY") + # This validator's own private bucket (write creds) for durable king state. + # ``R2_OWN_*`` predates Hippius support and remains a compatibility fallback; + # a Hippius deployment should not have to describe its state bucket as R2. + if self.object_storage_backend == "hippius": + self.r2_own_endpoint = ( + _read_optional_str("HIPPIUS_OWN_ENDPOINT") + or _read_optional_str("R2_OWN_ENDPOINT") + or self.hippius_endpoint + ) + self.r2_own_region = ( + _read_optional_str("HIPPIUS_OWN_REGION") + or _read_optional_str("R2_OWN_REGION") + or self.hippius_region + ) + self.r2_own_bucket = ( + _read_optional_str("HIPPIUS_OWN_BUCKET") + or _read_optional_str("R2_OWN_BUCKET") + ) + self.r2_own_write_access_key = ( + _read_optional_str("HIPPIUS_OWN_WRITE_ACCESS_KEY") + or _read_optional_str("R2_OWN_WRITE_ACCESS_KEY") + ) + self.r2_own_write_secret_key = ( + _read_optional_str("HIPPIUS_OWN_WRITE_SECRET_KEY") + or _read_optional_str("R2_OWN_WRITE_SECRET_KEY") + ) + else: + self.r2_own_endpoint = _read_str("R2_OWN_ENDPOINT", self.r2_endpoint_raw) + self.r2_own_region = _read_str("R2_OWN_REGION", self.r2_region) + self.r2_own_bucket = _read_optional_str("R2_OWN_BUCKET") + self.r2_own_write_access_key = _read_optional_str("R2_OWN_WRITE_ACCESS_KEY") + self.r2_own_write_secret_key = _read_optional_str("R2_OWN_WRITE_SECRET_KEY") # Public dashboard delivery is intentionally separated from canonical king # state. Endpoint + write credentials default to this validator owner's state # credentials, while optional LEOMA_DASHBOARD_* overrides support providers @@ -428,12 +454,47 @@ def __init__(self) -> None: self.dashboard_region = ( _read_optional_str("LEOMA_DASHBOARD_REGION") or self.r2_own_region ) - self.dashboard_write_access_key = _read_optional_str( + explicit_dashboard_access_key = _read_optional_str( "LEOMA_DASHBOARD_WRITE_ACCESS_KEY" - ) or self.r2_own_write_access_key - self.dashboard_write_secret_key = _read_optional_str( + ) + explicit_dashboard_secret_key = _read_optional_str( "LEOMA_DASHBOARD_WRITE_SECRET_KEY" - ) or self.r2_own_write_secret_key + ) + # Bucket-scoped Hippius credentials can safely serve both roles only when the + # operator explicitly chose the same bucket. State objects remain private; + # JsonBucketStore applies public-read solely to dashboard.json. + if self.r2_own_bucket and self.r2_own_bucket == self.dashboard_bucket: + # An explicitly selected Hippius bucket must not accidentally inherit + # stale legacy R2 credentials. If no canonical HIPPIUS_OWN key was set, + # the key scoped to this exact dashboard bucket is the authoritative one. + canonical_hippius_key = _read_optional_str("HIPPIUS_OWN_WRITE_ACCESS_KEY") + canonical_hippius_secret = _read_optional_str("HIPPIUS_OWN_WRITE_SECRET_KEY") + if self.object_storage_backend == "hippius" and _read_optional_str( + "HIPPIUS_OWN_BUCKET" + ): + self.r2_own_write_access_key = ( + canonical_hippius_key + or explicit_dashboard_access_key + or self.r2_own_write_access_key + ) + self.r2_own_write_secret_key = ( + canonical_hippius_secret + or explicit_dashboard_secret_key + or self.r2_own_write_secret_key + ) + else: + self.r2_own_write_access_key = ( + self.r2_own_write_access_key or explicit_dashboard_access_key + ) + self.r2_own_write_secret_key = ( + self.r2_own_write_secret_key or explicit_dashboard_secret_key + ) + self.dashboard_write_access_key = ( + explicit_dashboard_access_key or self.r2_own_write_access_key + ) + self.dashboard_write_secret_key = ( + explicit_dashboard_secret_key or self.r2_own_write_secret_key + ) self.openai_api_key = _read_optional_str("OPENAI_API_KEY") self.gemini_api_key = _read_optional_str("GEMINI_API_KEY") diff --git a/leoma/chain.toml b/leoma/chain.toml index 13ab3f8..c3d3d03 100644 --- a/leoma/chain.toml +++ b/leoma/chain.toml @@ -131,6 +131,16 @@ early_stop_factor = 20.0 # the number can be measured rather than invented. freeze_margin_fraction = 0.0 +[runtime] +# A source-code digest alone cannot prove two boxes execute the same diffusion +# graph. This pins the Python/CUDA/H100 axes plus the exact uv dependency resolution. +# Any uv.lock change is a coordinated consensus release: update this digest, rebuild +# immutable images, deploy every validator/evaluator, and recalibrate before dueling. +python = "3.12" +cuda = "12.4" +compute_capability = "9.0" +eval_lock_digest = "sha256:c6b16699b506f8386409b001ff26b4ba48ba1eeab4ed89481b53f79637c94cc3" + [determinism] torch_deterministic = true cudnn_benchmark = false diff --git a/leoma/delivery/commands.py b/leoma/delivery/commands.py index 67472e1..4fa3f25 100644 --- a/leoma/delivery/commands.py +++ b/leoma/delivery/commands.py @@ -34,12 +34,74 @@ def serve(): Scans on-chain miner reveals, duels each new challenger against the reigning king on the GPU eval server (deterministic, block-hash-seeded), crowns winners, and sets equal weights across the king chain (else burns UID 0). - Requires R2_OWN_BUCKET and a reachable EVAL_SERVER_URL. + Requires HIPPIUS_OWN_BUCKET (or legacy R2_OWN_BUCKET) and a reachable + EVAL_SERVER_URL. """ from leoma.app.validator.main import main _run_async(main()) +@cli.command() +@click.option( + "--ticks", + type=click.IntRange(min=1, max=10_000), + default=1, + show_default=True, + help="Number of live validator ticks before exiting.", +) +@click.option( + "--state-bucket", + envvar="LEOMA_REHEARSAL_BUCKET", + required=True, + help="Dedicated non-production bucket for rehearsal state and dashboard output.", +) +@click.option( + "--state-prefix", + envvar="LEOMA_REHEARSAL_PREFIX", + required=True, + help="Isolated object prefix beginning with rehearsals/ (for example rehearsals/abc123).", +) +def rehearse(ticks: int, state_bucket: str, state_prefix: str): + """Run bounded live-chain validation without submitting weight extrinsics. + + The complete reveal/evaluator/verdict/state path runs against live services, but + every set_weights call is suppressed at the final chain boundary. The dedicated + state bucket prevents a rehearsal from mutating production king or dashboard + state. The process exits after TICKS and is never installed as a service. + """ + import os + + from leoma.bootstrap import settings + + production_bucket = ( + os.environ.get("HIPPIUS_OWN_BUCKET") + or os.environ.get("R2_OWN_BUCKET") + or settings.r2_own_bucket + or "" + ).strip() + rehearsal_bucket = state_bucket.strip() + if not rehearsal_bucket: + raise click.ClickException("--state-bucket cannot be empty") + if production_bucket and rehearsal_bucket == production_bucket: + raise click.ClickException( + "rehearsal state bucket must differ from the configured validator state bucket" + ) + rehearsal_prefix = state_prefix.strip("/") + if not rehearsal_prefix.startswith("rehearsals/") or ".." in rehearsal_prefix.split("/"): + raise click.ClickException("--state-prefix must start with rehearsals/") + + from leoma.app.validator.main import main + + _run_async( + main( + rehearsal=True, + max_ticks=ticks, + state_bucket=rehearsal_bucket, + state_prefix=rehearsal_prefix, + ) + ) + + @cli.group() def servers(): """Start individual services. @@ -76,16 +138,26 @@ def preflight(): """Check whether this validator is ready to launch — a hard gate, not a hope. Verifies every pin the subnet needs before it will crown anyone: the genesis - king, the corpus manifest, the consensus surface, and (if configured) that the - eval box is on the same chain.toml + scoring code. Exits non-zero if anything is - a hard FAIL, so it can gate a launch script. + king, corpus, live wallet/subnet registration, and every evaluator's consensus, + scoring code, and numerical runtime. Exits non-zero if anything is a hard FAIL, + so it can gate a launch script. """ import os from leoma.bootstrap import emit_log as log, emit_header as log_header - from leoma.app.preflight import PASS, WARN, FAIL, run_preflight + from leoma.app.preflight import ( + PASS, + WARN, + FAIL, + ChainProbe, + WalletProbe, + expected_subnet_name, + run_preflight, + ) + from leoma.bootstrap import HOTKEY_NAME, NETUID, NETWORK, WALLET_NAME, settings from leoma.eval.codehash import eval_code_digest - from leoma.infra.chain_config import CONSENSUS_DIGEST, SEED_DIGEST, SPEC + from leoma.eval.runtime_lock import expected_eval_runtime_digest + from leoma.infra.chain_config import CONSENSUS_DIGEST, NAME, SEED_DIGEST, SPEC log_header("Leoma Preflight") @@ -99,11 +171,21 @@ def preflight(): manifest = fetch_manifest(create_source_read_client(), SPEC.corpus) corpus_fetched_digest = manifest.source_digest - except Exception as e: # noqa: BLE001 — a fetch failure is a WARN, not a crash + except Exception as e: # noqa: BLE001 — converted into a blocking check result corpus_error = str(e) dashboard_bucket = os.environ.get("LEOMA_DASHBOARD_BUCKET") - own_bucket = os.environ.get("R2_OWN_BUCKET") + own_bucket = settings.r2_own_bucket + state_error = None + if own_bucket: + try: + from leoma.infra.storage_backend import create_own_write_client + + state_client = create_own_write_client() + if not state_client.bucket_exists(own_bucket): + state_error = "bucket does not exist" + except Exception as e: # noqa: BLE001 — converted into a blocking check result + state_error = str(e) dashboard_error = None if dashboard_bucket and dashboard_bucket != own_bucket: try: @@ -134,25 +216,96 @@ def preflight(): try: token = os.environ.get("LEOMA_EVAL_TOKEN", "") headers = {"Authorization": f"Bearer {token}"} if token else {} - health = httpx.get( + response = httpx.get( f"{url}/health", timeout=httpx.Timeout(15.0), headers=headers - ).json() + ) + response.raise_for_status() + health = response.json() probes.append(EvalServerProbe(url, health, None)) except Exception as e: # noqa: BLE001 probes.append(EvalServerProbe(url, None, str(e))) eval_probes = tuple(probes) + # Verify the effective wallet (including defaults), then prove its public hotkey + # is registered and permitted on the configured live subnet. Merely checking that + # WALLET_NAME/HOTKEY_NAME strings are non-empty allowed preflight to say Ready for + # a wallet that did not exist or could never set weights. + import bittensor as bt + + wallet = bt.Wallet(name=WALLET_NAME, hotkey=HOTKEY_NAME) + hotkey_exists = False + hotkey_ss58 = None + wallet_error = None + try: + hotkey_exists = bool(wallet.hotkey_file.exists_on_device()) + if hotkey_exists: + hotkey_ss58 = wallet.hotkey.ss58_address + except Exception as e: # noqa: BLE001 — converted into a blocking check result + wallet_error = str(e) + wallet_probe = WalletProbe( + WALLET_NAME, HOTKEY_NAME, hotkey_exists, hotkey_ss58, wallet_error + ) + + async def _probe_chain() -> ChainProbe: + if not hotkey_ss58: + return ChainProbe( + NETWORK, NETUID, None, None, None, None, False, + "wallet hotkey address is unavailable", + ) + try: + async with bt.AsyncSubtensor(network=NETWORK) as subtensor: + block = await subtensor.get_current_block() + subnet = await subtensor.subnet(NETUID, block=block) + raw_name = getattr(subnet, "subnet_name", None) if subnet else None + if isinstance(raw_name, bytes): + subnet_name = raw_name.decode("utf-8", errors="strict") + elif raw_name is None: + subnet_name = None + else: + subnet_name = str(raw_name) + + meta = await subtensor.metagraph(NETUID, block=block) + hotkeys = list(getattr(meta, "hotkeys", []) or []) + if hotkey_ss58 not in hotkeys: + return ChainProbe( + NETWORK, NETUID, subnet_name, block, hotkey_ss58, None, False + ) + index = hotkeys.index(hotkey_ss58) + uids = getattr(meta, "uids", None) + uid = int(uids[index]) if uids is not None else index + permits = getattr(meta, "validator_permit", None) + permit = bool(permits[index]) if permits is not None else False + return ChainProbe( + NETWORK, NETUID, subnet_name, block, hotkey_ss58, uid, permit + ) + except Exception as e: # noqa: BLE001 — converted into a blocking check result + return ChainProbe( + NETWORK, NETUID, None, None, hotkey_ss58, None, False, str(e) + ) + + chain_probe = asyncio.run(_probe_chain()) + + try: + required_subnet_name = expected_subnet_name( + NETWORK, NAME, os.environ.get("LEOMA_TESTNET_SUBNET_NAME") + ) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + report = run_preflight( seed_digest=SEED_DIGEST, corpus_pinned=SPEC.corpus.pinned, manifest_digest=SPEC.corpus.manifest_digest, consensus_digest=CONSENSUS_DIGEST, eval_code_digest=eval_code_digest(), + eval_runtime_digest=expected_eval_runtime_digest(SPEC.runtime), own_bucket=own_bucket, + state_error=state_error, dashboard_bucket=dashboard_bucket, dashboard_error=dashboard_error, - wallet_name=os.environ.get("WALLET_NAME"), - hotkey_name=os.environ.get("HOTKEY_NAME"), + wallet_probe=wallet_probe, + chain_probe=chain_probe, + expected_subnet_name=required_subnet_name, corpus_fetched_digest=corpus_fetched_digest, corpus_error=corpus_error, eval_servers=eval_probes, diff --git a/leoma/eval/codehash.py b/leoma/eval/codehash.py index 6c5c906..ba47167 100644 --- a/leoma/eval/codehash.py +++ b/leoma/eval/codehash.py @@ -35,6 +35,7 @@ "eval/spec.py", "eval/digests.py", "eval/determinism.py", + "eval/runtime_lock.py", "eval_server.py", "app/validator/prescreen.py", "infra/model_store.py", diff --git a/leoma/eval/runtime_lock.py b/leoma/eval/runtime_lock.py new file mode 100644 index 0000000..1aaf85d --- /dev/null +++ b/leoma/eval/runtime_lock.py @@ -0,0 +1,171 @@ +"""Fail-closed identity for the evaluator's numerical runtime. + +The scoring-code digest catches stale Leoma source, but the same Python files can +produce different frames under another Torch, Diffusers, CUDA, or GPU runtime. +This module turns those previously audit-only facts into a compatibility contract: + +* ``chain.toml [runtime]`` pins Python, CUDA, compute capability, and uv.lock; +* the key generation/scoring package versions below pin what is actually installed; +* the evaluator publishes a canonical digest over the observed identity; and +* the validator derives the expected digest locally and rejects any difference. + +The health report is self-reported, so it is an operator-drift guard rather than a +remote-attestation system. Immutable image digests remain the deployment trust root. +""" +from __future__ import annotations + +import hashlib +import os +import pathlib +import sys +from functools import lru_cache +from importlib import metadata +from typing import Any + +from leoma.eval.digests import digest_obj +from leoma.eval.spec import EvalRuntimeSpec + + +# Packages that directly implement model loading, generation, decoding, and scoring. +# These values deliberately mirror the exact versions resolved in uv.lock. Updating +# one is a consensus/runtime change, not an ordinary unattended dependency bump. +EXPECTED_EVAL_PACKAGES: dict[str, str] = { + "accelerate": "1.10.1", + "diffusers": "0.35.2", + "huggingface-hub": "1.6.0", + "lpips": "0.1.4", + "numpy": "2.2.6", + "open-clip-torch": "3.3.0", + "opencv-python-headless": "4.12.0.88", + "pillow": "12.3.0", + "safetensors": "0.8.0", + "scipy": "1.18.0", + "tokenizers": "0.22.2", + "torch": "2.6.0", + "torchvision": "0.21.0", + "transformers": "5.14.1", +} + + +def _lock_path() -> pathlib.Path | None: + configured = os.environ.get("LEOMA_UV_LOCK_PATH", "").strip() + candidates = [pathlib.Path(configured)] if configured else [] + candidates.extend( + ( + pathlib.Path.cwd() / "uv.lock", + pathlib.Path(__file__).resolve().parents[2] / "uv.lock", + ) + ) + for candidate in candidates: + if candidate.is_file(): + return candidate + return None + + +def observed_lock_digest() -> str | None: + """Digest the lock file used to build this evaluator, or return ``None``.""" + path = _lock_path() + if path is None: + return None + try: + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + except OSError: + return None + + +def expected_runtime_identity(spec: EvalRuntimeSpec) -> dict[str, Any]: + """Canonical runtime identity every healthy evaluator must report.""" + return { + "python": spec.python, + "cuda": spec.cuda, + "compute_capabilities": [spec.compute_capability], + "eval_lock_digest": spec.eval_lock_digest, + "packages": dict(sorted(EXPECTED_EVAL_PACKAGES.items())), + } + + +def expected_eval_runtime_digest(spec: EvalRuntimeSpec) -> str: + return digest_obj(expected_runtime_identity(spec)) + + +def _installed_packages() -> dict[str, str | None]: + installed: dict[str, str | None] = {} + for package in EXPECTED_EVAL_PACKAGES: + try: + installed[package] = metadata.version(package) + except metadata.PackageNotFoundError: + installed[package] = None + return dict(sorted(installed.items())) + + +def observed_runtime_identity() -> dict[str, Any]: + """Read the evaluator environment without silently substituting defaults.""" + cuda: str | None = None + capabilities: list[str] = [] + try: + import torch + + cuda = getattr(torch.version, "cuda", None) + if torch.cuda.is_available(): + capabilities = sorted( + { + f"{major}.{minor}" + for major, minor in ( + torch.cuda.get_device_capability(index) + for index in range(torch.cuda.device_count()) + ) + } + ) + except Exception: # noqa: BLE001 — incompatibility is data in the health report + pass + + return { + "python": f"{sys.version_info.major}.{sys.version_info.minor}", + "cuda": cuda, + "compute_capabilities": capabilities, + "eval_lock_digest": observed_lock_digest(), + "packages": _installed_packages(), + } + + +def _identity_diff(expected: dict[str, Any], observed: dict[str, Any]) -> list[str]: + issues: list[str] = [] + for field in ("python", "cuda", "compute_capabilities", "eval_lock_digest"): + if observed.get(field) != expected.get(field): + issues.append( + f"{field}: expected {expected.get(field)!r}, observed {observed.get(field)!r}" + ) + expected_packages = expected["packages"] + observed_packages = observed.get("packages") or {} + for package, version in expected_packages.items(): + if observed_packages.get(package) != version: + issues.append( + f"package {package}: expected {version!r}, " + f"observed {observed_packages.get(package)!r}" + ) + return issues + + +@lru_cache(maxsize=1) +def eval_runtime_report(spec: EvalRuntimeSpec) -> dict[str, Any]: + """Observed identity, expected digest, and a fail-closed compatibility verdict.""" + expected = expected_runtime_identity(spec) + observed = observed_runtime_identity() + issues = _identity_diff(expected, observed) + return { + "compatible": not issues, + "digest": digest_obj(observed), + "expected_digest": digest_obj(expected), + "identity": observed, + "issues": issues, + } + + +__all__ = [ + "EXPECTED_EVAL_PACKAGES", + "eval_runtime_report", + "expected_eval_runtime_digest", + "expected_runtime_identity", + "observed_lock_digest", + "observed_runtime_identity", +] diff --git a/leoma/eval/spec.py b/leoma/eval/spec.py index 7f5f5aa..06e598d 100644 --- a/leoma/eval/spec.py +++ b/leoma/eval/spec.py @@ -136,6 +136,21 @@ class DeterminismSpec(_Pinned): allow_tf32: bool +class EvalRuntimeSpec(_Pinned): + """The execution environment allowed to produce consensus verdicts. + + Source-code equality is insufficient for diffusion inference: changing Torch, + Diffusers, CUDA, Python, or the GPU architecture can change generated frames. + The complete dependency resolution is anchored by ``eval_lock_digest`` while + the remaining fields pin the hardware/runtime axes that do not live in uv.lock. + """ + + python: str = Field(pattern=r"^\d+\.\d+$") + cuda: str = Field(pattern=r"^\d+\.\d+$") + compute_capability: str = Field(pattern=r"^\d+\.\d+$") + eval_lock_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + class ConsensusSpec(_Pinned): """Everything that can change a verdict — and nothing that can't.""" @@ -144,6 +159,7 @@ class ConsensusSpec(_Pinned): duel: DuelSpec arch: ArchSpec determinism: DeterminismSpec + runtime: EvalRuntimeSpec def digest(self) -> str: """The one hash that says "we are running the same exam".""" @@ -223,6 +239,7 @@ def _diff(a: ConsensusSpec, b: ConsensusSpec) -> str: "CorpusSpec", "DeterminismSpec", "DuelSpec", + "EvalRuntimeSpec", "GenSpec", "verify_echo", ] diff --git a/leoma/eval/video_runner.py b/leoma/eval/video_runner.py index 4b9981f..2209946 100644 --- a/leoma/eval/video_runner.py +++ b/leoma/eval/video_runner.py @@ -416,7 +416,16 @@ def load_video_pipeline(snapshot_dir: str, *, gen, device: Optional[str] = None, dtype = torch.float32 # CPU has no usable bf16 path; tests only target_device = device or ("cuda" if torch.cuda.is_available() else "cpu") - pipe = pipeline_cls.from_pretrained(snapshot_dir, torch_dtype=dtype) + # Miner snapshots are untrusted. model_store enforces a safetensors/config-only + # filesystem allowlist, and these explicit flags prevent a future loader default + # from re-enabling network fetches, pickle weights, or repository code. + pipe = pipeline_cls.from_pretrained( + snapshot_dir, + torch_dtype=dtype, + local_files_only=True, + trust_remote_code=False, + use_safetensors=True, + ) pipe = _place_pipeline(pipe, offload=gen.offload, device=target_device) if cache_key: diff --git a/leoma/eval_server.py b/leoma/eval_server.py index fd53480..5f73efd 100644 --- a/leoma/eval_server.py +++ b/leoma/eval_server.py @@ -269,6 +269,9 @@ def expired(self, now: float) -> bool: def create_app(runner: Optional[Runner] = None) -> FastAPI: app = FastAPI(title="leoma-eval-server") + # Supplying a runner is the explicit unit-test/in-process injection seam. Only + # the real production runner requires CUDA and the pinned eval dependency graph. + enforce_runtime = runner is None run_job: Runner = runner or run_eval_job @app.middleware("http") @@ -369,29 +372,51 @@ def _watch(job: _Job, worker: threading.Thread) -> None: def health() -> dict: """Enough for a validator to reject a stale box *before* handing it a duel. - ``consensus_digest`` and ``eval_code_digest`` are the two that matter: a box - whose chain.toml or scoring code has drifted will produce distances nobody can - reproduce, and an hours-long duel is a very expensive way to find that out. + Source/config digests are necessary but not sufficient: the numerical + runtime (Torch, Diffusers, CUDA, GPU capability, and uv.lock) is enforced too. """ - from leoma.infra.chain_config import CONSENSUS_DIGEST + from leoma.infra.chain_config import CONSENSUS_DIGEST, SPEC from leoma.eval.codehash import eval_code_digest + from leoma.eval.runtime_lock import eval_runtime_report with lock_guard: busy_with = lock_holder["eval_id"] job = jobs.get(busy_with) if busy_with else None + runtime = eval_runtime_report(SPEC.runtime) + runtime_ok = bool(runtime["compatible"] or not enforce_runtime) return { - "status": "ok", + "status": "ok" if runtime_ok else "runtime_mismatch", "busy": busy_with is not None, "eval_id": busy_with, "phase": job.phase if job else None, "stalled_for": round(job.stalled_for(), 1) if job else None, "consensus_digest": CONSENSUS_DIGEST, "eval_code_digest": eval_code_digest(), + "eval_runtime_enforced": enforce_runtime, + "eval_runtime_compatible": runtime["compatible"], + "eval_runtime_digest": runtime["digest"], + "expected_eval_runtime_digest": runtime["expected_digest"], + "eval_runtime": runtime["identity"], + "eval_runtime_issues": runtime["issues"], } @app.post("/eval") def start_eval(req: EvalRequest): + if enforce_runtime: + from leoma.infra.chain_config import SPEC + from leoma.eval.runtime_lock import eval_runtime_report + + runtime = eval_runtime_report(SPEC.runtime) + if not runtime["compatible"]: + return JSONResponse( + status_code=503, + content={ + "error": "evaluator runtime does not match chain.toml [runtime]", + "reason": "runtime_mismatch", + "issues": runtime["issues"], + }, + ) _reap(time.monotonic()) if not lock.acquire(blocking=False): @@ -567,6 +592,7 @@ def run_eval_job(req: EvalRequest, emit: Emit, should_cancel: ShouldCancel = lam from leoma.eval.dataset import build_duel_clips, corpus_audit, fetch_manifest from leoma.eval.determinism import apply_determinism, runtime_env from leoma.eval.digests import digest_obj + from leoma.eval.runtime_lock import eval_runtime_report from leoma.app.validator.seeds import eval_seed check_request(req) @@ -676,6 +702,7 @@ def _materialize(ref, which: str) -> str: "eval_code_digest": eval_code_digest(), "corpus": corpus_audit(manifest, entries), "env": runtime_env(), + "eval_runtime": eval_runtime_report(spec.runtime), # Not part of the consensus surface (see eval/devices.py) — recorded here so a # distance disagreement between validators can be checked against "did the two # duelists even run on the same kind of device" before assuming a real bug. diff --git a/leoma/infra/chain_config.py b/leoma/infra/chain_config.py index 3d2fdea..9946e64 100644 --- a/leoma/infra/chain_config.py +++ b/leoma/infra/chain_config.py @@ -28,6 +28,7 @@ CorpusSpec, DeterminismSpec, DuelSpec, + EvalRuntimeSpec, GenSpec, ) @@ -77,6 +78,7 @@ def _resolve_toml_path() -> pathlib.Path: _gen = _doc.get("gen", {}) _duel = _doc.get("duel", {}) _determinism = _doc.get("determinism", {}) +_runtime = _doc.get("runtime", {}) _VALID_SEED_REPO_BACKENDS = {"hf", "hippius"} @@ -145,11 +147,13 @@ def _build_spec() -> ConsensusSpec: pipeline=ARCH_PIPELINE, ), determinism=DeterminismSpec(**_determinism), + runtime=EvalRuntimeSpec(**_runtime), ) except Exception as e: # pydantic ValidationError / TypeError raise RuntimeError( f"chain.toml does not define a valid consensus surface ({_TOML_PATH}):\n{e}\n\n" - "Every field of [corpus], [gen], [duel] and [determinism] is REQUIRED and has " + "Every field of [corpus], [gen], [duel], [determinism] and [runtime] is " + "REQUIRED and has " "no default. A field with a default is a field a validator can silently forget, " "and a forgotten field means two validators grade the same challenger " "differently." diff --git a/leoma/infra/model_store.py b/leoma/infra/model_store.py index 31b1558..1150d19 100644 --- a/leoma/infra/model_store.py +++ b/leoma/infra/model_store.py @@ -16,10 +16,13 @@ import os import re import shutil +from fnmatch import fnmatch from dataclasses import dataclass from pathlib import Path from typing import Iterable, Optional +from leoma.eval.errors import ChallengerFault + MODEL_CACHE_DIR = os.environ.get("LEOMA_MODEL_CACHE_DIR", "/tmp/leoma/hippius_models") HUB_TOKEN_PATH = Path("~/.cache/hippius/hub/token").expanduser() @@ -70,6 +73,12 @@ class HippiusHubAuthError(RuntimeError): """Raised when an authenticated Hub operation has no usable credentials.""" +class UnsafeSnapshotError(ChallengerFault): + """A downloaded model contains executable or otherwise forbidden files.""" + + reason = "unsafe_snapshot" + + def _get_first_env(names: tuple[str, ...]) -> Optional[str]: for name in names: value = (os.environ.get(name) or "").strip() @@ -308,6 +317,47 @@ def _mark_complete(target: Path, ref: ModelRef) -> None: _marker_path(target).write_text(json.dumps(payload, indent=2)) +def validate_snapshot_files( + target: str | os.PathLike[str], *, require_weights: bool = True +) -> None: + """Reject executable files, links, and non-safetensors model weights. + + Miner repositories are untrusted input. Diffusers versions before 0.38 have + known remote-code guard bypasses, so the evaluator does not rely on that guard + alone: the registry download allowlist is enforced again on disk before every + load. A poisoned local cache therefore cannot add ``.py``/pickle files after the + original download and become executable on the next duel. + """ + root = Path(target) + if not root.is_dir() or root.is_symlink(): + raise UnsafeSnapshotError(f"model snapshot is not a real directory: {root}") + + found_weights = False + for directory, dirnames, filenames in os.walk(root, followlinks=False): + directory_path = Path(directory) + for name in dirnames: + path = directory_path / name + if path.is_symlink(): + raise UnsafeSnapshotError(f"model snapshot contains a directory symlink: {path}") + for name in filenames: + path = directory_path / name + if path.is_symlink() or not path.is_file(): + raise UnsafeSnapshotError(f"model snapshot contains a non-regular file: {path}") + if name == COMPLETION_MARKER: + continue + if not any(fnmatch(name, pattern) for pattern in ALLOW_PATTERNS): + relative = path.relative_to(root).as_posix() + raise UnsafeSnapshotError( + f"model snapshot contains forbidden file {relative!r}; only configs, " + "tokenizer data, and .safetensors weights are accepted" + ) + if fnmatch(name, "*.safetensors"): + found_weights = True + + if require_weights and not found_weights: + raise UnsafeSnapshotError("model snapshot contains no .safetensors weights") + + def _cached_snapshots(root: Path) -> list[tuple[float, Path]]: """Completed snapshots on disk, oldest use first. Incomplete ones are junk.""" found: list[tuple[float, Path]] = [] @@ -370,6 +420,7 @@ def materialize_model(ref: ModelRef, local_dir: Optional[str] = None, max_worker target = Path(local_dir) if local_dir else _cache_snapshot_path(ref) if is_complete(target): + validate_snapshot_files(target, require_weights=not config_only) return str(target) # Not complete: either absent, or the debris of an interrupted download. Either @@ -385,8 +436,14 @@ def materialize_model(ref: ModelRef, local_dir: Optional[str] = None, max_worker target.parent.mkdir(parents=True, exist_ok=True) patterns = CONFIG_ONLY_PATTERNS if config_only else ALLOW_PATTERNS result = _call_snapshot_download(ref, str(target), max_workers, allow_patterns=patterns) + result_path = Path(result) + if result_path.resolve() != target.resolve(): + raise UnsafeSnapshotError( + f"registry returned snapshot outside the requested cache path: {result_path}" + ) - _mark_complete(Path(result), ref) + validate_snapshot_files(result_path, require_weights=not config_only) + _mark_complete(result_path, ref) return result diff --git a/leoma/infra/storage_backend.py b/leoma/infra/storage_backend.py index 6354203..3095372 100644 --- a/leoma/infra/storage_backend.py +++ b/leoma/infra/storage_backend.py @@ -125,7 +125,8 @@ def _build_minio_client( if not access_key or not secret_key: raise ValueError( f"Missing object storage credentials for {purpose}. " - "Set the matching access key and secret key (R2_OWN_*)." + "Set the matching access key and secret key (HIPPIUS_OWN_* for Hippius, " + "R2_OWN_* for R2)." ) return Minio( normalize_s3_endpoint_host(endpoint_raw), diff --git a/pyproject.toml b/pyproject.toml index 710861d..fd1c7e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,14 +19,14 @@ dependencies = [ "numpy", "openai>=1.0.0,<2", "google-genai>=0.3.0", - "click", + "click>=8.3.3,<9", "pydantic>=2.0.0,<3", - "yt-dlp", + "yt-dlp>=2026.7.4", # Eval server HTTP (validator dispatches duels here) - "fastapi>=0.109.0,<0.116.0", + "fastapi>=0.141.1,<0.142.0", "uvicorn[standard]>=0.27.0", "httpx", - "pillow", + "pillow>=12.3.0,<13", ] [project.optional-dependencies] @@ -74,8 +74,26 @@ include = ["leoma*"] [tool.setuptools.package-data] leoma = ["chain.toml"] +[tool.uv] +# Security floors for dependencies brought in by Bittensor and the HTTP clients. +# They are constraints, not direct imports; uv.lock remains the deployment source +# of truth and prevents a rebuild from resolving a different transitive graph. +constraint-dependencies = [ + "cryptography>=48.0.1", + "idna>=3.15", + "msgpack>=1.2.1", + "pyasn1>=0.6.4", + "pygments>=2.20.0", + "setuptools>=83.0.0", + # Keep the ASGI layer on the audited 1.3 line. FastAPI intentionally exposes a + # broad Starlette floor, so this ceiling prevents an unreviewed future major/minor + # from changing the evaluator's HTTP/SSE behavior during a lock refresh. + "starlette>=1.3.1,<1.4", + "urllib3>=2.7.0", +] + [build-system] -requires = ["setuptools>=61.0"] +requires = ["setuptools==83.0.0"] build-backend = "setuptools.build_meta" [tool.pytest.ini_options] diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 00a76b6..73f3891 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -142,6 +142,10 @@ def make_verdict(spec, *, accepted: bool, **extra) -> dict: guard standing between the subnet and a verdict produced under someone else's parameters. So every fake verdict carries one. """ + from leoma.eval.codehash import eval_code_digest + from leoma.eval.runtime_lock import expected_eval_runtime_digest + + runtime_digest = expected_eval_runtime_digest(spec.runtime) return { "accepted": accepted, "verdict": "challenger" if accepted else "king", @@ -149,7 +153,12 @@ def make_verdict(spec, *, accepted: bool, **extra) -> dict: "mu_hat": 0.02 if accepted else -0.02, "n_clips": spec.duel.n_clips, "echo": spec.model_dump(mode="json"), - "audit": {"consensus_digest": spec.digest(), "corpus": {"clip_keys_digest": "sha256:x"}}, + "audit": { + "consensus_digest": spec.digest(), + "eval_code_digest": eval_code_digest(), + "eval_runtime": {"compatible": True, "digest": runtime_digest}, + "corpus": {"clip_keys_digest": "sha256:x"}, + }, "verdict_digest": "sha256:v", "produced_at": "2026-07-13T00:00:00+00:00", **extra, diff --git a/tests/unit/test_chain_config.py b/tests/unit/test_chain_config.py index 85de450..440a2f7 100644 --- a/tests/unit/test_chain_config.py +++ b/tests/unit/test_chain_config.py @@ -41,6 +41,13 @@ def test_wan22_base_and_genesis_are_immutably_pinned_together(self): assert chain_config.SEED_REPO == chain_config.ARCH_BASE_REPO assert chain_config.SEED_REPO_BACKEND == "hippius" + def test_eval_runtime_is_pinned_to_the_h100_lock(self): + runtime = chain_config.SPEC.runtime + assert runtime.python == "3.12" + assert runtime.cuda == "12.4" + assert runtime.compute_capability == "9.0" + assert runtime.eval_lock_digest.startswith("sha256:") + class TestOverride: def test_override_absolute_path(self, tmp_path, monkeypatch): diff --git a/tests/unit/test_cli_smoke.py b/tests/unit/test_cli_smoke.py index 389d404..7b91699 100644 --- a/tests/unit/test_cli_smoke.py +++ b/tests/unit/test_cli_smoke.py @@ -7,6 +7,7 @@ and file-path sources, now exit cleanly with a readable message. """ import json +from importlib.metadata import version import httpx import pytest @@ -20,6 +21,13 @@ def runner(): return CliRunner() +def test_cli_version_uses_installed_distribution_metadata(runner): + result = runner.invoke(cli, ["--version"]) + + assert result.exit_code == 0 + assert version("leoma") in result.output + + def _good_dashboard(): return { "history": [{"accepted": True, "verdict": "challenger", "hotkey": "5win", @@ -96,3 +104,32 @@ def test_no_source_and_no_env_var_is_a_clean_usage_error(self, runner, monkeypat result = runner.invoke(cli, ["smoke"]) assert result.exit_code != 0 assert "dashboard.json URL or path" in result.output + + +class TestRehearsalSafety: + def test_requires_a_separate_state_bucket(self, runner, monkeypatch): + monkeypatch.setenv("R2_OWN_BUCKET", "production-state") + result = runner.invoke( + cli, + [ + "rehearse", "--ticks", "1", + "--state-bucket", "production-state", + "--state-prefix", "rehearsals/test", + ], + ) + + assert result.exit_code != 0 + assert "must differ from the configured validator state bucket" in result.output + + def test_requires_a_namespaced_rehearsal_prefix(self, runner): + result = runner.invoke( + cli, + [ + "rehearse", "--ticks", "1", + "--state-bucket", "test-state", + "--state-prefix", "state", + ], + ) + + assert result.exit_code != 0 + assert "must start with rehearsals/" in result.output diff --git a/tests/unit/test_consensus_spec.py b/tests/unit/test_consensus_spec.py index 31a0764..fa6e6fa 100644 --- a/tests/unit/test_consensus_spec.py +++ b/tests/unit/test_consensus_spec.py @@ -11,7 +11,15 @@ from leoma.eval.digests import canonical_json, digest_obj from leoma.eval.errors import ConsensusConfigError -from leoma.eval.spec import ArchSpec, ConsensusSpec, CorpusSpec, DuelSpec, GenSpec, verify_echo +from leoma.eval.spec import ( + ArchSpec, + ConsensusSpec, + CorpusSpec, + DuelSpec, + EvalRuntimeSpec, + GenSpec, + verify_echo, +) from .conftest import pinned_spec @@ -52,6 +60,16 @@ def test_a_missing_arch_field_raises(self, missing): with pytest.raises(ValidationError): ArchSpec(**fields) + @pytest.mark.parametrize( + "missing", ["python", "cuda", "compute_capability", "eval_lock_digest"] + ) + def test_a_missing_runtime_field_raises(self, missing): + spec = pinned_spec() + fields = spec.runtime.model_dump() + fields.pop(missing) + with pytest.raises(ValidationError): + EvalRuntimeSpec(**fields) + class TestDigest: def test_same_spec_same_digest(self): @@ -72,6 +90,17 @@ def test_base_architecture_digest_changes_the_consensus_digest(self): ) assert moved.digest() != base.digest() + def test_runtime_lock_changes_the_consensus_digest(self): + base = pinned_spec() + moved = base.model_copy( + update={ + "runtime": base.runtime.model_copy( + update={"eval_lock_digest": "sha256:" + "f" * 64} + ) + } + ) + assert moved.digest() != base.digest() + def test_digest_survives_a_json_round_trip(self): """The spec crosses HTTP. If a float came back with a different last bit the digest would flip and every duel would be refused — so floats are quantized.""" diff --git a/tests/unit/test_dashboard.py b/tests/unit/test_dashboard.py index 9b34a97..4061993 100644 --- a/tests/unit/test_dashboard.py +++ b/tests/unit/test_dashboard.py @@ -69,11 +69,12 @@ def test_carries_meta_and_params(self): st = _two_king_state() d = build_dashboard( st, {"A": 10, "B": 20}, - chain_meta={"name": "leoma", "netuid": 99}, + chain_meta={"name": "leoma", "netuid": 36}, duel_params={"metric": "lpips", "delta_threshold": 0.0025}, updated_at="2026-01-01T00:00:00Z", ) assert d["chain"]["name"] == "leoma" + assert d["chain"]["netuid"] == 36 assert d["duel_params"]["metric"] == "lpips" assert d["updated_at"] == "2026-01-01T00:00:00Z" # `transient_errors` is tracked separately from `failed` so that retries diff --git a/tests/unit/test_eval_compat.py b/tests/unit/test_eval_compat.py new file mode 100644 index 0000000..6e9dffa --- /dev/null +++ b/tests/unit/test_eval_compat.py @@ -0,0 +1,79 @@ +"""Validator/evaluator compatibility is checked before and after every duel.""" + +import pytest + +import leoma.app.validator.main as vmain +from leoma.app.validator.failures import EvalJobFailed +from leoma.eval.codehash import eval_code_digest +from leoma.eval.runtime_lock import expected_eval_runtime_digest + +from .conftest import make_verdict, pinned_spec + + +class _Response: + def __init__(self, body): + self._body = body + + def raise_for_status(self): + return None + + def json(self): + return self._body + + +class _Client: + def __init__(self, body): + self.body = body + + async def get(self, url): + return _Response(self.body) + + +def _health(spec): + return { + "consensus_digest": spec.digest(), + "eval_code_digest": eval_code_digest(), + "eval_runtime_compatible": True, + "eval_runtime_digest": expected_eval_runtime_digest(spec.runtime), + } + + +@pytest.mark.asyncio +async def test_dispatch_preflight_requires_code_and_runtime_digests(monkeypatch): + spec = pinned_spec() + monkeypatch.setattr(vmain, "SPEC", spec) + monkeypatch.setattr(vmain, "CONSENSUS_DIGEST", spec.digest()) + + await vmain.preflight_eval_server(_Client(_health(spec)), "http://eval") + + missing_code = _health(spec) + missing_code.pop("eval_code_digest") + with pytest.raises(EvalJobFailed) as code_error: + await vmain.preflight_eval_server(_Client(missing_code), "http://eval") + assert code_error.value.reason == "code_mismatch" + + missing_runtime = _health(spec) + missing_runtime.pop("eval_runtime_digest") + with pytest.raises(EvalJobFailed) as runtime_error: + await vmain.preflight_eval_server(_Client(missing_runtime), "http://eval") + assert runtime_error.value.reason == "runtime_mismatch" + + +def test_terminal_verdict_must_repeat_code_and_runtime_proof(monkeypatch): + spec = pinned_spec() + monkeypatch.setattr(vmain, "SPEC", spec) + + valid = make_verdict(spec, accepted=False) + assert vmain._verified_verdict(valid) is valid + + missing_code = make_verdict(spec, accepted=False) + missing_code["audit"].pop("eval_code_digest") + with pytest.raises(EvalJobFailed) as code_error: + vmain._verified_verdict(missing_code) + assert code_error.value.reason == "code_mismatch" + + missing_runtime = make_verdict(spec, accepted=False) + missing_runtime["audit"].pop("eval_runtime") + with pytest.raises(EvalJobFailed) as runtime_error: + vmain._verified_verdict(missing_runtime) + assert runtime_error.value.reason == "runtime_mismatch" diff --git a/tests/unit/test_eval_server.py b/tests/unit/test_eval_server.py index 4a19962..3f0bfdf 100644 --- a/tests/unit/test_eval_server.py +++ b/tests/unit/test_eval_server.py @@ -53,6 +53,33 @@ def test_health_publishes_the_digests_a_validator_preflights_on(): body = c.get("/health").json() assert body["consensus_digest"].startswith("sha256:") assert body["eval_code_digest"].startswith("sha256:") + assert body["eval_runtime_digest"].startswith("sha256:") + assert body["expected_eval_runtime_digest"].startswith("sha256:") + assert body["eval_runtime_enforced"] is False + assert isinstance(body["eval_runtime_issues"], list) + + +def test_production_server_rejects_an_incompatible_runtime(monkeypatch): + from leoma.eval import runtime_lock + + runtime_lock.eval_runtime_report.cache_clear() + monkeypatch.setattr( + runtime_lock, + "eval_runtime_report", + lambda spec: { + "compatible": False, + "digest": "sha256:" + "a" * 64, + "expected_digest": "sha256:" + "b" * 64, + "identity": {}, + "issues": ["torch: expected '2.6.0', observed '9.9.9'"], + }, + ) + c = TestClient(create_app()) + health = c.get("/health").json() + assert health["status"] == "runtime_mismatch" + response = c.post("/eval", json=REQ) + assert response.status_code == 503 + assert response.json()["reason"] == "runtime_mismatch" def test_configured_token_protects_every_route(monkeypatch): diff --git a/tests/unit/test_king_state_store.py b/tests/unit/test_king_state_store.py index 7103e92..2a780e2 100644 --- a/tests/unit/test_king_state_store.py +++ b/tests/unit/test_king_state_store.py @@ -47,6 +47,19 @@ async def test_put_get_roundtrip(self): async def test_missing_key_returns_none(self): assert await _store().get("absent.json") is None + async def test_key_prefix_isolates_rehearsal_objects(self): + client = FakeMinio() + store = JsonBucketStore( + client, BUCKET, backoff=0, key_prefix="rehearsals/release-123" + ) + + await store.put("state/state.json", {"ok": True}) + + assert await store.get("state/state.json") == {"ok": True} + assert client.put_calls == ["rehearsals/release-123/state/state.json"] + assert client.get_calls == ["rehearsals/release-123/state/state.json"] + assert (BUCKET, "state/state.json") not in client.blobs + # ── the headline: a transport error is NOT a miss ────────────────────── async def test_transport_error_raises_not_none(self): """An outage must RAISE. Returning None here is what blanked the state.""" diff --git a/tests/unit/test_model_security.py b/tests/unit/test_model_security.py new file mode 100644 index 0000000..05c1e60 --- /dev/null +++ b/tests/unit/test_model_security.py @@ -0,0 +1,105 @@ +"""Untrusted miner repositories cannot become evaluator code.""" + +import json +import sys +from types import SimpleNamespace + +import pytest + +from leoma.infra import model_store as ms +from leoma.infra.model_store import ( + COMPLETION_MARKER, + ModelRef, + UnsafeSnapshotError, + materialize_model, + validate_snapshot_files, +) +from leoma.app.validator.failures import ErrorClass, classify + + +DIGEST = "sha256:" + "a" * 64 + + +def _snapshot(root): + root.mkdir(parents=True) + (root / "model_index.json").write_text(json.dumps({"_class_name": "WanImageToVideoPipeline"})) + component = root / "transformer" + component.mkdir() + (component / "config.json").write_text("{}") + (component / "weights.safetensors").write_bytes(b"safe") + return root + + +def test_snapshot_allowlist_accepts_only_data_and_safetensors(tmp_path): + snapshot = _snapshot(tmp_path / "model") + validate_snapshot_files(snapshot) + + (snapshot / "pipeline.py").write_text("raise RuntimeError('executed')") + with pytest.raises(UnsafeSnapshotError, match="forbidden file 'pipeline.py'"): + validate_snapshot_files(snapshot) + + +def test_snapshot_allowlist_rejects_pickle_weights(tmp_path): + snapshot = _snapshot(tmp_path / "model") + (snapshot / "pytorch_model.bin").write_bytes(b"pickle") + with pytest.raises(UnsafeSnapshotError, match="pytorch_model.bin"): + validate_snapshot_files(snapshot) + + +def test_snapshot_allowlist_rejects_symlinks(tmp_path): + snapshot = _snapshot(tmp_path / "model") + (snapshot / "linked.json").symlink_to(snapshot / "model_index.json") + with pytest.raises(UnsafeSnapshotError, match="non-regular file"): + validate_snapshot_files(snapshot) + + +def test_completed_cache_is_revalidated_before_reuse(tmp_path, monkeypatch): + monkeypatch.setattr(ms, "MODEL_CACHE_DIR", str(tmp_path)) + ref = ModelRef("u/leoma-model", DIGEST) + target = _snapshot(tmp_path / "u--leoma-model" / "snapshots" / f"sha256-{'a' * 64}") + (target / COMPLETION_MARKER).write_text("{}") + (target / "injected.py").write_text("raise RuntimeError('executed')") + + with pytest.raises(UnsafeSnapshotError, match="injected.py"): + materialize_model(ref) + + assert classify(UnsafeSnapshotError("forbidden")).kind is ErrorClass.PERMANENT + + +def test_pipeline_loader_forces_offline_safetensors_and_disables_remote_code(monkeypatch): + from leoma.eval import video_runner + + captured = {} + + class Pipeline: + @classmethod + def from_pretrained(cls, path, **kwargs): + captured.update(path=path, **kwargs) + return cls() + + def to(self, device): + captured["device"] = device + return self + + fake_torch = SimpleNamespace( + cuda=SimpleNamespace(is_available=lambda: False), + float32="float32", + ) + fake_diffusers = SimpleNamespace( + __version__="test", + WanImageToVideoPipeline=Pipeline, + ) + monkeypatch.setitem(sys.modules, "torch", fake_torch) + monkeypatch.setitem(sys.modules, "diffusers", fake_diffusers) + + gen = SimpleNamespace(dtype="bfloat16", offload="none") + video_runner.load_video_pipeline("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/safe/model", gen=gen, device="cpu") + + assert captured == { + "path": "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/safe/model", + "torch_dtype": "float32", + "local_files_only": True, + "trust_remote_code": False, + "use_safetensors": True, + "device": "cpu", + } diff --git a/tests/unit/test_preflight.py b/tests/unit/test_preflight.py index 8d1d4b3..ea0c342 100644 --- a/tests/unit/test_preflight.py +++ b/tests/unit/test_preflight.py @@ -6,90 +6,143 @@ tests pin the classification of each check and the overall ready/not-ready verdict. """ +import pytest + from leoma.app.preflight import ( FAIL, PASS, WARN, + ChainProbe, EvalServerProbe, + WalletProbe, + check_chain, check_corpus_reachable, check_eval_server, check_eval_servers, check_seed, + check_wallet, + expected_subnet_name, run_preflight, ) +RUNTIME_DIGEST = "sha256:" + "r" * 64 +WALLET = WalletProbe("val", "hk", True, "5ValidatorHotkey", None) +CHAIN = ChainProbe( + "finney", 36, "Leoma", 8753870, "5ValidatorHotkey", 12, True, None +) + GOOD = dict( seed_digest="sha256:" + "5" * 64, corpus_pinned=True, manifest_digest="sha256:" + "a" * 64, consensus_digest="sha256:" + "c" * 64, eval_code_digest="sha256:" + "e" * 64, + eval_runtime_digest=RUNTIME_DIGEST, own_bucket="leoma-state", dashboard_bucket="leoma-dashboard", - wallet_name="val", - hotkey_name="hk", + wallet_probe=WALLET, + chain_probe=CHAIN, + expected_subnet_name="leoma", ) +def _health(*, consensus=None, code=None, runtime=None, compatible=True): + return { + "consensus_digest": consensus or GOOD["consensus_digest"], + "eval_code_digest": code or GOOD["eval_code_digest"], + "eval_runtime_digest": runtime or GOOD["eval_runtime_digest"], + "eval_runtime_compatible": compatible, + "eval_runtime_issues": [] if compatible else ["torch drifted"], + } + + def _good_probe(): - return EvalServerProbe( - "http://eval:9000", - {"consensus_digest": GOOD["consensus_digest"], "eval_code_digest": GOOD["eval_code_digest"]}, - None, + return EvalServerProbe("http://eval:9000", _health(), None) + + +def _run(**overrides): + return run_preflight( + **{**GOOD, **overrides}, + corpus_fetched_digest=overrides.get( + "manifest_digest", GOOD["manifest_digest"] + ), + eval_servers=(_good_probe(),), ) class TestOverallVerdict: def test_a_fully_configured_validator_is_ready(self): - r = run_preflight(**GOOD, corpus_fetched_digest=GOOD["manifest_digest"], - eval_servers=(_good_probe(),)) + r = _run() assert r.ready assert r.failures == () def test_an_unpinned_seed_blocks_launch(self): - r = run_preflight(**{**GOOD, "seed_digest": ""}) + r = _run(seed_digest="") assert not r.ready assert any(c.name == "seed_digest" and c.status == FAIL for c in r.checks) def test_an_unpinned_corpus_blocks_launch(self): - r = run_preflight(**{**GOOD, "corpus_pinned": False, "manifest_digest": ""}) + r = _run(corpus_pinned=False, manifest_digest="") assert not r.ready assert any(c.name == "corpus_pin" and c.status == FAIL for c in r.checks) def test_a_missing_state_bucket_blocks_launch(self): - r = run_preflight(**{**GOOD, "own_bucket": None}) + r = _run(own_bucket=None) + assert not r.ready + + def test_an_unreachable_state_bucket_blocks_launch(self): + r = _run(state_error="AccessDenied") + check = next(c for c in r.checks if c.name == "state_bucket") + assert check.status == FAIL + assert "AccessDenied" in check.detail assert not r.ready def test_a_separate_dashboard_bucket_passes(self): - r = run_preflight(**GOOD) + r = _run() check = next(c for c in r.checks if c.name == "dashboard_bucket") assert check.status == PASS def test_dashboard_falling_back_to_state_warns(self): - r = run_preflight(**{**GOOD, "dashboard_bucket": None}) + r = _run(dashboard_bucket=None) check = next(c for c in r.checks if c.name == "dashboard_bucket") assert check.status == WARN assert r.ready def test_dashboard_sharing_state_warns(self): - r = run_preflight(**{**GOOD, "dashboard_bucket": GOOD["own_bucket"]}) + r = _run(dashboard_bucket=GOOD["own_bucket"]) check = next(c for c in r.checks if c.name == "dashboard_bucket") assert check.status == WARN def test_an_unreachable_dashboard_bucket_warns_but_does_not_block_consensus(self): - r = run_preflight(**{**GOOD, "dashboard_error": "AccessDenied"}) + r = _run(dashboard_error="AccessDenied") check = next(c for c in r.checks if c.name == "dashboard_bucket") assert check.status == WARN assert "AccessDenied" in check.detail assert r.ready - def test_warnings_do_not_block_launch(self): - # No eval server configured and no corpus fetch -> warnings only, still ready. - r = run_preflight(**GOOD) + def test_only_non_consensus_warnings_do_not_block_launch(self): + r = _run(dashboard_bucket=None) assert r.ready - assert len(r.warnings) >= 1 # eval_server + corpus_fetch are warns here + assert [warning.name for warning in r.warnings] == ["dashboard_bucket"] + + def test_missing_eval_and_corpus_access_now_block_launch(self): + r = run_preflight(**GOOD) + assert not r.ready + assert {failure.name for failure in r.failures} >= {"eval_server", "corpus_fetch"} +class TestSubnetNameOverride: + def test_non_mainnet_can_explicitly_name_a_borrowed_test_subnet(self): + assert expected_subnet_name("test", "leoma", "fish") == "fish" + + def test_no_override_keeps_the_chain_toml_identity(self): + assert expected_subnet_name("test", "leoma", "") == "leoma" + + @pytest.mark.parametrize("network", ["finney", "mainnet", "FINNEY"]) + def test_mainnet_identity_can_never_be_overridden(self, network): + with pytest.raises(ValueError, match="cannot override"): + expected_subnet_name(network, "leoma", "fish") + class TestCorpusReachable: def test_matching_digest_passes(self): c = check_corpus_reachable("sha256:aaa", "sha256:aaa", None) @@ -102,54 +155,74 @@ def test_a_drifted_manifest_is_a_hard_fail(self): assert c.status == FAIL assert "does NOT match" in c.detail - def test_a_fetch_error_is_only_a_warning(self): + def test_a_fetch_error_is_a_hard_fail(self): c = check_corpus_reachable(None, "sha256:aaa", "connection refused") - assert c.status == WARN + assert c.status == FAIL - def test_no_credentials_is_a_warning_not_a_block(self): + def test_no_credentials_is_a_hard_fail(self): c = check_corpus_reachable(None, "sha256:aaa", None) - assert c.status == WARN + assert c.status == FAIL class TestEvalServer: def test_matching_box_passes(self): - c = check_eval_server({"consensus_digest": "sha256:c", "eval_code_digest": "sha256:e"}, - "sha256:c", "sha256:e") + c = check_eval_server(_health(), GOOD["consensus_digest"], + GOOD["eval_code_digest"], RUNTIME_DIGEST) assert c.status == PASS def test_a_stale_consensus_surface_is_a_hard_fail(self): """The single most likely consensus failure — an operator who redeployed some boxes but not others. Must block.""" - c = check_eval_server({"consensus_digest": "sha256:OLD", "eval_code_digest": "sha256:e"}, - "sha256:NEW", "sha256:e") + c = check_eval_server(_health(consensus="sha256:OLD"), + "sha256:NEW", GOOD["eval_code_digest"], RUNTIME_DIGEST) assert c.status == FAIL assert "DIFFERENT consensus surface" in c.detail def test_stale_scoring_code_is_a_hard_fail(self): - c = check_eval_server({"consensus_digest": "sha256:c", "eval_code_digest": "sha256:OLD"}, - "sha256:c", "sha256:NEW") + c = check_eval_server(_health(code="sha256:OLD"), GOOD["consensus_digest"], + "sha256:NEW", RUNTIME_DIGEST) assert c.status == FAIL assert "DIFFERENT scoring code" in c.detail - def test_no_eval_server_configured_is_a_warning(self): - c = check_eval_server(None, "sha256:c", "sha256:e") - assert c.status == WARN + def test_no_eval_server_configured_is_a_hard_fail(self): + c = check_eval_server(None, "sha256:c", "sha256:e", RUNTIME_DIGEST) + assert c.status == FAIL - def test_unreachable_eval_server_is_a_warning(self): - c = check_eval_server(None, "sha256:c", "sha256:e", error="timeout") - assert c.status == WARN + def test_unreachable_eval_server_is_a_hard_fail(self): + c = check_eval_server(None, "sha256:c", "sha256:e", RUNTIME_DIGEST, + error="timeout") + assert c.status == FAIL - def test_missing_eval_code_digest_is_a_warning_not_a_silent_pass(self): + def test_missing_eval_code_digest_is_a_hard_fail(self): """A box whose /health doesn't report eval_code_digest at all (a build old enough to predate the field) gives us zero evidence its scoring code matches — this must surface, not silently PASS.""" - c = check_eval_server({"consensus_digest": "sha256:c"}, "sha256:c", "sha256:e") - assert c.status == WARN + c = check_eval_server({"consensus_digest": "sha256:c"}, "sha256:c", "sha256:e", + RUNTIME_DIGEST) + assert c.status == FAIL assert "could not be verified" in c.detail + def test_missing_or_drifted_runtime_is_a_hard_fail(self): + missing = check_eval_server( + {"consensus_digest": "sha256:c", "eval_code_digest": "sha256:e"}, + "sha256:c", "sha256:e", RUNTIME_DIGEST, + ) + drifted = check_eval_server( + _health(runtime="sha256:OLD"), GOOD["consensus_digest"], + GOOD["eval_code_digest"], RUNTIME_DIGEST, + ) + incompatible = check_eval_server( + _health(compatible=False), GOOD["consensus_digest"], + GOOD["eval_code_digest"], RUNTIME_DIGEST, + ) + assert missing.status == FAIL + assert drifted.status == FAIL + assert incompatible.status == FAIL + def test_a_custom_name_labels_the_check(self): - c = check_eval_server({"consensus_digest": "sha256:c", "eval_code_digest": "sha256:e"}, - "sha256:c", "sha256:e", name="eval_server[http://a:9000]") + c = check_eval_server(_health(), GOOD["consensus_digest"], + GOOD["eval_code_digest"], RUNTIME_DIGEST, + name="eval_server[http://a:9000]") assert c.name == "eval_server[http://a:9000]" @@ -177,18 +250,39 @@ def test_wrong_prefix_fails(self): assert c.status == FAIL +class TestWalletAndChain: + def test_real_wallet_and_permitted_registration_pass(self): + assert check_wallet(WALLET).status == PASS + assert check_chain(CHAIN, "leoma").status == PASS + + def test_missing_hotkey_file_blocks_launch(self): + probe = WalletProbe("val", "missing", False, None, None) + assert check_wallet(probe).status == FAIL + + def test_wrong_subnet_identity_blocks_launch(self): + probe = CHAIN._replace(subnet_name="Thirty Spokes") + assert check_chain(probe, "leoma").status == FAIL + + def test_unregistered_or_unpermitted_hotkey_blocks_launch(self): + unregistered = CHAIN._replace(uid=None, validator_permit=False) + unpermitted = CHAIN._replace(validator_permit=False) + assert check_chain(unregistered, "leoma").status == FAIL + assert check_chain(unpermitted, "leoma").status == FAIL + + class TestEvalServersFleet: - def test_no_probes_gives_one_warning_check(self): - checks = check_eval_servers((), "sha256:c", "sha256:e") + def test_no_probes_gives_one_failing_check(self): + checks = check_eval_servers((), "sha256:c", "sha256:e", RUNTIME_DIGEST) assert len(checks) == 1 assert checks[0].name == "eval_server" - assert checks[0].status == WARN + assert checks[0].status == FAIL def test_a_single_probe_keeps_the_unlabeled_name(self): """Single-server operators shouldn't see a URL-qualified name change under them.""" - probe = EvalServerProbe("http://only:9000", {"consensus_digest": "sha256:c", - "eval_code_digest": "sha256:e"}, None) - checks = check_eval_servers((probe,), "sha256:c", "sha256:e") + probe = EvalServerProbe("http://only:9000", _health(), None) + checks = check_eval_servers( + (probe,), GOOD["consensus_digest"], GOOD["eval_code_digest"], RUNTIME_DIGEST + ) assert len(checks) == 1 assert checks[0].name == "eval_server" assert checks[0].status == PASS @@ -196,11 +290,12 @@ def test_a_single_probe_keeps_the_unlabeled_name(self): def test_several_probes_are_each_checked_and_labeled_by_url(self): """A stale box among several configured servers must not hide behind a healthy sibling — every configured URL gets checked independently.""" - healthy = EvalServerProbe("http://a:9000", {"consensus_digest": "sha256:c", - "eval_code_digest": "sha256:e"}, None) - stale = EvalServerProbe("http://b:9000", {"consensus_digest": "sha256:OLD", - "eval_code_digest": "sha256:e"}, None) - checks = check_eval_servers((healthy, stale), "sha256:c", "sha256:e") + healthy = EvalServerProbe("http://a:9000", _health(), None) + stale = EvalServerProbe("http://b:9000", _health(consensus="sha256:OLD"), None) + checks = check_eval_servers( + (healthy, stale), GOOD["consensus_digest"], GOOD["eval_code_digest"], + RUNTIME_DIGEST, + ) assert len(checks) == 2 by_name = {c.name: c for c in checks} assert by_name["eval_server[http://a:9000]"].status == PASS @@ -209,5 +304,7 @@ def test_several_probes_are_each_checked_and_labeled_by_url(self): def test_every_server_stale_fails_the_whole_fleet_check(self): stale_a = EvalServerProbe("http://a:9000", {"consensus_digest": "sha256:OLD"}, None) stale_b = EvalServerProbe("http://b:9000", None, "connection refused") - checks = check_eval_servers((stale_a, stale_b), "sha256:c", "sha256:e") + checks = check_eval_servers( + (stale_a, stale_b), "sha256:c", "sha256:e", RUNTIME_DIGEST + ) assert all(c.status != PASS for c in checks) diff --git a/tests/unit/test_release_artifacts.py b/tests/unit/test_release_artifacts.py new file mode 100644 index 0000000..d141d85 --- /dev/null +++ b/tests/unit/test_release_artifacts.py @@ -0,0 +1,42 @@ +"""Production artifacts cannot silently resolve mutable runtime inputs.""" + +import pathlib +import re + + +ROOT = pathlib.Path(__file__).resolve().parents[2] + + +def _read(name: str) -> str: + return (ROOT / name).read_text() + + +def test_container_bases_uv_and_python_graph_are_locked(): + for name in ("Dockerfile", "Dockerfile.eval"): + dockerfile = _read(name) + first_from = next(line for line in dockerfile.splitlines() if line.startswith("FROM ")) + assert re.search(r"@sha256:[0-9a-f]{64}$", first_from) + assert "pip install --no-cache-dir uv==0.11.6" in dockerfile + assert "ENV VIRTUAL_ENV=/opt/venv" in dockerfile + assert "uv sync --active --frozen" in dockerfile + assert "COPY pyproject.toml uv.lock README.md ./" in dockerfile + + +def test_production_compose_is_pull_only_and_digest_addressed(): + for name, variable in ( + ("docker-compose.validator.production.yml", "LEOMA_VALIDATOR_IMAGE_DIGEST"), + ("docker-compose.eval.8xh100.production.yml", "LEOMA_EVAL_IMAGE_DIGEST"), + ): + compose = _read(name) + assert "build:" not in compose + assert ":latest" not in compose + assert f"rendixnetwork/leoma@${{{variable}:?" in compose + assert "pull_policy: always" in compose + + +def test_h100_compose_has_four_isolated_pairs_and_loopback_ports(): + compose = _read("docker-compose.eval.8xh100.production.yml") + for pair in ((0, 1), (2, 3), (4, 5), (6, 7)): + assert f'device_ids: ["{pair[0]}", "{pair[1]}"]' in compose + for port in range(9000, 9004): + assert f'"127.0.0.1:{port}:9000"' in compose diff --git a/tests/unit/test_runtime_lock.py b/tests/unit/test_runtime_lock.py new file mode 100644 index 0000000..03848d7 --- /dev/null +++ b/tests/unit/test_runtime_lock.py @@ -0,0 +1,52 @@ +"""The evaluator runtime is consensus-checked, not merely written to an audit log.""" + +import pathlib +import tomllib + +from leoma.eval import runtime_lock +from leoma.infra.chain_config import SPEC + + +def test_shipped_uv_lock_matches_the_consensus_pin(): + assert runtime_lock.observed_lock_digest() == SPEC.runtime.eval_lock_digest + + +def test_expected_numerical_packages_match_uv_lock_exactly(): + root = pathlib.Path(__file__).resolve().parents[2] + with open(root / "uv.lock", "rb") as stream: + packages = tomllib.load(stream)["package"] + locked: dict[str, set[str]] = {} + for package in packages: + locked.setdefault(package["name"], set()).add(package["version"]) + + for name, expected in runtime_lock.EXPECTED_EVAL_PACKAGES.items(): + assert locked.get(name) == {expected}, f"{name} drifted from the runtime contract" + + +def test_expected_runtime_digest_is_stable_and_content_addressed(): + digest = runtime_lock.expected_eval_runtime_digest(SPEC.runtime) + assert digest.startswith("sha256:") + assert digest == runtime_lock.expected_eval_runtime_digest(SPEC.runtime) + + +def test_matching_observed_runtime_is_compatible(monkeypatch): + expected = runtime_lock.expected_runtime_identity(SPEC.runtime) + runtime_lock.eval_runtime_report.cache_clear() + monkeypatch.setattr(runtime_lock, "observed_runtime_identity", lambda: expected) + report = runtime_lock.eval_runtime_report(SPEC.runtime) + assert report["compatible"] is True + assert report["digest"] == report["expected_digest"] + assert report["issues"] == [] + runtime_lock.eval_runtime_report.cache_clear() + + +def test_one_package_drift_changes_digest_and_fails_closed(monkeypatch): + observed = runtime_lock.expected_runtime_identity(SPEC.runtime) + observed = {**observed, "packages": {**observed["packages"], "torch": "9.9.9"}} + runtime_lock.eval_runtime_report.cache_clear() + monkeypatch.setattr(runtime_lock, "observed_runtime_identity", lambda: observed) + report = runtime_lock.eval_runtime_report(SPEC.runtime) + assert report["compatible"] is False + assert report["digest"] != report["expected_digest"] + assert any("package torch" in issue for issue in report["issues"]) + runtime_lock.eval_runtime_report.cache_clear() diff --git a/tests/unit/test_storage_backend_selection.py b/tests/unit/test_storage_backend_selection.py index 241e899..ad1e239 100644 --- a/tests/unit/test_storage_backend_selection.py +++ b/tests/unit/test_storage_backend_selection.py @@ -23,6 +23,13 @@ def _settings(monkeypatch, **env): return rt +class TestMainnetDefaults: + def test_default_netuid_is_leoma_mainnet(self, monkeypatch): + monkeypatch.delenv("NETUID", raising=False) + rt = _settings(monkeypatch) + assert rt.settings.netuid == 36 + + class TestBackendSelection: def test_the_default_is_still_r2_so_nothing_changes_for_operators(self, monkeypatch): monkeypatch.delenv("OBJECT_STORAGE_BACKEND", raising=False) @@ -82,6 +89,67 @@ def test_dashboard_defaults_to_state_endpoint_and_credentials(self, monkeypatch) assert rt.settings.dashboard_write_access_key == "owner-key" assert rt.settings.dashboard_write_secret_key == "owner-secret" + +class TestHippiusValidatorState: + def test_hippius_own_names_select_the_hippius_state_bucket(self, monkeypatch): + rt = _settings( + monkeypatch, + OBJECT_STORAGE_BACKEND="hippius", + HIPPIUS_ENDPOINT="s3.hippius.example", + HIPPIUS_REGION="decentralized", + HIPPIUS_OWN_BUCKET="leoma-state", + HIPPIUS_OWN_WRITE_ACCESS_KEY="state-key", + HIPPIUS_OWN_WRITE_SECRET_KEY="state-secret", + ) + + assert rt.settings.r2_own_bucket == "leoma-state" + assert rt.settings.r2_own_endpoint == "s3.hippius.example" + assert rt.settings.r2_own_region == "decentralized" + assert rt.settings.r2_own_write_access_key == "state-key" + assert rt.settings.r2_own_write_secret_key == "state-secret" + + def test_same_hippius_bucket_can_reuse_its_bucket_scoped_dashboard_key(self, monkeypatch): + rt = _settings( + monkeypatch, + OBJECT_STORAGE_BACKEND="hippius", + HIPPIUS_OWN_BUCKET="leoma-dashboard", + LEOMA_DASHBOARD_BUCKET="leoma-dashboard", + LEOMA_DASHBOARD_WRITE_ACCESS_KEY="bucket-key", + LEOMA_DASHBOARD_WRITE_SECRET_KEY="bucket-secret", + ) + + assert rt.settings.r2_own_write_access_key == "bucket-key" + assert rt.settings.r2_own_write_secret_key == "bucket-secret" + + def test_canonical_hippius_bucket_does_not_inherit_stale_legacy_r2_keys(self, monkeypatch): + rt = _settings( + monkeypatch, + OBJECT_STORAGE_BACKEND="hippius", + HIPPIUS_OWN_BUCKET="leoma-dashboard", + R2_OWN_WRITE_ACCESS_KEY="wrong-legacy-key", + R2_OWN_WRITE_SECRET_KEY="wrong-legacy-secret", + LEOMA_DASHBOARD_BUCKET="leoma-dashboard", + LEOMA_DASHBOARD_WRITE_ACCESS_KEY="bucket-key", + LEOMA_DASHBOARD_WRITE_SECRET_KEY="bucket-secret", + ) + + assert rt.settings.r2_own_write_access_key == "bucket-key" + assert rt.settings.r2_own_write_secret_key == "bucket-secret" + + def test_legacy_r2_own_names_remain_a_hippius_fallback(self, monkeypatch): + rt = _settings( + monkeypatch, + OBJECT_STORAGE_BACKEND="hippius", + R2_OWN_BUCKET="legacy-state", + R2_OWN_ENDPOINT="legacy.hippius.example", + R2_OWN_WRITE_ACCESS_KEY="legacy-key", + R2_OWN_WRITE_SECRET_KEY="legacy-secret", + ) + + assert rt.settings.r2_own_bucket == "legacy-state" + assert rt.settings.r2_own_endpoint == "legacy.hippius.example" + assert rt.settings.r2_own_write_access_key == "legacy-key" + def test_dashboard_can_use_bucket_scoped_credentials(self, monkeypatch): rt = _settings( monkeypatch, diff --git a/tests/unit/test_weights.py b/tests/unit/test_weights.py index da78ed0..230b940 100644 --- a/tests/unit/test_weights.py +++ b/tests/unit/test_weights.py @@ -12,6 +12,7 @@ import pytest from leoma.app.validator import king as K +from leoma.app.validator import main as validator_main from leoma.app.validator.main import ( _is_rate_limited, _unpack_set_weights, @@ -104,6 +105,63 @@ def test_is_rate_limited(self, msg, expected): class TestMaybeSetWeights: + async def test_rehearsal_suppresses_even_forced_extrinsic(self): + sub, st, store = _FakeSubtensor((True, "ok")), _state_with_king(), _store() + token = validator_main._WEIGHT_WRITES_ALLOWED.set(False) + try: + ok = await maybe_set_weights( + sub, _Wallet(), st, {"5A": 7}, store, force=True + ) + finally: + validator_main._WEIGHT_WRITES_ALLOWED.reset(token) + + assert ok is False + assert sub.calls == [] + assert st.last_weight_block == 0 + assert st.weight_failures == 0 + + async def test_rehearsal_context_is_restored_after_bad_bounds(self): + with pytest.raises(ValueError, match="max_ticks"): + await validator_main.main( + rehearsal=True, max_ticks=None, state_bucket="rehearsal-state" + ) + + assert validator_main._WEIGHT_WRITES_ALLOWED.get() is True + + async def test_tick_persists_dirty_state_when_weight_write_is_a_noop(self, monkeypatch): + state, store = KingState(), _store() + + class Subtensor: + async def get_current_block(self): + return 5000 + + async def empty_async(*args, **kwargs): + return [] + + async def no_weight(*args, **kwargs): + return False + + async def no_publish(*args, **kwargs): + return None + + def seed(state, block): + state.king = {"hotkey": "", "model_repo": "leoma/base", "model_digest": "sha256:a"} + state.touch() + + monkeypatch.setattr(validator_main, "refresh_uid_map", empty_async) + monkeypatch.setattr(validator_main, "ensure_genesis_king", seed) + monkeypatch.setattr(validator_main, "_load_revealed_commitments", empty_async) + monkeypatch.setattr(validator_main, "scan_reveals", lambda *args, **kwargs: []) + monkeypatch.setattr(validator_main, "process_challengers", no_publish) + monkeypatch.setattr(validator_main, "maybe_set_weights", no_weight) + monkeypatch.setattr(validator_main, "_publish_dashboard", no_publish) + monkeypatch.setattr(validator_main, "_build_queue", lambda *args, **kwargs: []) + + await validator_main.tick(Subtensor(), _Wallet(), state, store) + + restored = await KingState.load(store) + assert restored.king["model_repo"] == "leoma/base" + async def test_success_advances_last_weight_block(self): sub, st, store = _FakeSubtensor((True, "ok")), _state_with_king(), _store() ok = await maybe_set_weights(sub, _Wallet(), st, {"5A": 7}, store, force=True) diff --git a/uv.lock b/uv.lock index 098acad..b0e7455 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,18 @@ resolution-markers = [ "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] +[manifest] +constraints = [ + { name = "cryptography", specifier = ">=48.0.1" }, + { name = "idna", specifier = ">=3.15" }, + { name = "msgpack", specifier = ">=1.2.1" }, + { name = "pyasn1", specifier = ">=0.6.4" }, + { name = "pygments", specifier = ">=2.20.0" }, + { name = "setuptools", specifier = ">=83.0.0" }, + { name = "starlette", specifier = ">=1.3.1,<1.4" }, + { name = "urllib3", specifier = ">=2.7.0" }, +] + [[package]] name = "accelerate" version = "1.10.1" @@ -494,14 +506,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -599,55 +611,52 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, ] [[package]] @@ -724,16 +733,18 @@ wheels = [ [[package]] name = "fastapi" -version = "0.110.3" +version = "0.141.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/22/7b9ee50b0a8c48f076a111d6e4071a9d4c25623dc67689c5f3aa375f779b/fastapi-0.110.3.tar.gz", hash = "sha256:555700b0159379e94fdbfc6bb66a0f1c43f4cf7060f25239af3d84b63a656626", size = 287508, upload-time = "2024-04-30T00:38:02.576Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/d1/5958526c3bdbed74f88bf69b86506db5b25a600207f0f688473667690de6/fastapi-0.110.3-py3-none-any.whl", hash = "sha256:fd7600612f755e4050beb74001310b5a7e1796d149c2ee363124abdfa0289d32", size = 91834, upload-time = "2024-04-30T00:37:58.935Z" }, + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, ] [[package]] @@ -1030,11 +1041,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/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/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1195,9 +1206,9 @@ requires-dist = [ { name = "av", marker = "extra == 'caption'", specifier = "==18.0.0" }, { name = "bittensor", specifier = "==10.5.0" }, { name = "certifi" }, - { name = "click" }, + { name = "click", specifier = ">=8.3.3,<9" }, { name = "diffusers", marker = "extra == 'eval'", specifier = "==0.35.2" }, - { name = "fastapi", specifier = ">=0.109.0,<0.116.0" }, + { name = "fastapi", specifier = ">=0.141.1,<0.142.0" }, { name = "google-genai", specifier = ">=0.3.0" }, { name = "hippius-hub", specifier = "==0.6.1" }, { name = "httpx" }, @@ -1208,7 +1219,7 @@ requires-dist = [ { name = "open-clip-torch", marker = "extra == 'eval'", specifier = "==3.3.0" }, { name = "openai", specifier = ">=1.0.0,<2" }, { name = "opencv-python-headless", marker = "extra == 'eval'", specifier = "==4.12.0.88" }, - { name = "pillow" }, + { name = "pillow", specifier = ">=12.3.0,<13" }, { name = "pydantic", specifier = ">=2.0.0,<3" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.23.0" }, @@ -1222,7 +1233,7 @@ requires-dist = [ { name = "transformers", marker = "extra == 'caption'", specifier = "==5.14.1" }, { name = "transformers", marker = "extra == 'eval'", specifier = "==5.14.1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" }, - { name = "yt-dlp" }, + { name = "yt-dlp", specifier = ">=2026.7.4" }, ] provides-extras = ["test", "caption", "eval"] @@ -1353,46 +1364,54 @@ wheels = [ [[package]] name = "msgpack" -version = "1.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, - { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, - { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, - { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, - { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, ] [[package]] @@ -1747,71 +1766,73 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, ] [[package]] @@ -1946,11 +1967,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.3" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, ] [[package]] @@ -2092,11 +2113,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -2424,11 +2445,11 @@ wheels = [ [[package]] name = "setuptools" -version = "70.0.0" +version = "83.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/60/5db2249526c9b453c5bb8b9f6965fcab0ddb7f40ad734420b3b421f7da44/setuptools-70.0.0.tar.gz", hash = "sha256:f211a66637b8fa059bb28183da127d4e86396c991a942b028c6650d4319c3fd0", size = 2265182, upload-time = "2024-05-21T10:28:18.891Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/88/70c5767a0e43eb4451c2200f07d042a4bcd7639276003a9c54a68cfcc1f8/setuptools-70.0.0-py3-none-any.whl", hash = "sha256:54faa7f2e8d2d11bcd2c07bed282eef1046b5c080d1c32add737d7b5817b1ad4", size = 863432, upload-time = "2024-05-21T10:28:12.781Z" }, + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] [[package]] @@ -2451,14 +2472,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.37.2" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/b5/6bceb93ff20bd7ca36e6f7c540581abb18f53130fabb30ba526e26fd819b/starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823", size = 2843736, upload-time = "2024-03-05T16:16:54.267Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/18/31fa32ed6c68ba66220204ef0be798c349d0a20c1901f9d4a794e08c76d8/starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee", size = 71908, upload-time = "2024-03-05T16:16:50.957Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -2663,11 +2685,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] @@ -3029,11 +3051,11 @@ wheels = [ [[package]] name = "yt-dlp" -version = "2026.3.3" +version = "2026.7.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/6f/7427d23609353e5ef3470ff43ef551b8bd7b166dd4fef48957f0d0e040fe/yt_dlp-2026.3.3.tar.gz", hash = "sha256:3db7969e3a8964dc786bdebcffa2653f31123bf2a630f04a17bdafb7bbd39952", size = 3118658, upload-time = "2026-03-03T16:54:53.909Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c5/9972af4b472b0d55badf841ebafd2f98944cb0ae0f46e11d01f363ea5b91/yt_dlp-2026.7.4.tar.gz", hash = "sha256:b094813404f87a9dd2186f00815231df32e5fd8a5403be0f807b3bb2d21a4432", size = 3049326, upload-time = "2026-07-04T22:42:14.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/a4/8b5cd28ab87aef48ef15e74241befec3445496327db028f34147a9e0f14f/yt_dlp-2026.3.3-py3-none-any.whl", hash = "sha256:166c6e68c49ba526474bd400e0129f58aa522c2896204aa73be669c3d2f15e63", size = 3315599, upload-time = "2026-03-03T16:54:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8a/cd4c9b02c10c563adfe78118310129641900e1cd6de888cfae2452072696/yt_dlp-2026.7.4-py3-none-any.whl", hash = "sha256:f11f2b11d5a8ac4059f9bdf29fa4407dc7c6bb00c5097e95ca22a7a9db518266", size = 3184705, upload-time = "2026-07-04T22:42:12.989Z" }, ] [[package]]