From 2e5da2438fa68f881efde904d5301245c89fdc23 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 29 Apr 2026 00:19:43 +0200 Subject: [PATCH 01/21] feat(runner): minimal Pipeline SDK + BYOC hello-world E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds livepeer_gateway.runner — a Pipeline ABC and a thin aiohttp serve layer — plus a hello-world example that runs end-to-end against an unmodified go-livepeer BYOC stack. Surface: - livepeer_gateway.runner.Pipeline — ABC with predict() - livepeer_gateway.runner.serve(pipeline) → aiohttp app: - POST /predict — body JSON kwargs to predict(); TypeError → 400, other exception → 500 - GET /health — {"status": "ready"} - examples/runner/hello_world/ — Pipeline subclass + Dockerfile + docker-compose + capability registration + e2e curl test The container's /predict path matches the existing go-livepeer BYOC contract — no go-livepeer changes required. ./examples/runner/hello_world/test.sh printing PASS proves the round-trip: curl → gateway → orchestrator → SDK container → response. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/runner/hello_world/Dockerfile | 15 +++++ examples/runner/hello_world/README.md | 45 +++++++++++++ .../runner/hello_world/docker-compose.yml | 67 +++++++++++++++++++ examples/runner/hello_world/pipeline.py | 12 ++++ .../runner/hello_world/register_capability.py | 52 ++++++++++++++ examples/runner/hello_world/test.sh | 39 +++++++++++ src/livepeer_gateway/runner/__init__.py | 7 ++ src/livepeer_gateway/runner/pipeline.py | 16 +++++ src/livepeer_gateway/runner/serve.py | 65 ++++++++++++++++++ 9 files changed, 318 insertions(+) create mode 100644 examples/runner/hello_world/Dockerfile create mode 100644 examples/runner/hello_world/README.md create mode 100644 examples/runner/hello_world/docker-compose.yml create mode 100644 examples/runner/hello_world/pipeline.py create mode 100644 examples/runner/hello_world/register_capability.py create mode 100755 examples/runner/hello_world/test.sh create mode 100644 src/livepeer_gateway/runner/__init__.py create mode 100644 src/livepeer_gateway/runner/pipeline.py create mode 100644 src/livepeer_gateway/runner/serve.py diff --git a/examples/runner/hello_world/Dockerfile b/examples/runner/hello_world/Dockerfile new file mode 100644 index 0000000..c5803b8 --- /dev/null +++ b/examples/runner/hello_world/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install the package from source — pulls aiohttp, grpcio, protobuf, av per +# pyproject.toml. Build context is the repo root. +COPY pyproject.toml README ./ +COPY src /app/src +RUN pip install --no-cache-dir /app + +COPY examples/runner/hello_world/pipeline.py /app/pipeline.py + +EXPOSE 5000 + +CMD ["python", "/app/pipeline.py"] diff --git a/examples/runner/hello_world/README.md b/examples/runner/hello_world/README.md new file mode 100644 index 0000000..e50e993 --- /dev/null +++ b/examples/runner/hello_world/README.md @@ -0,0 +1,45 @@ +# Hello world (BYOC) + +Smallest end-to-end test of the Pipeline SDK against a real +[go-livepeer](https://github.com/livepeer/go-livepeer) BYOC stack. A `Pipeline` +subclass returns `{"message": "hello, X"}` over HTTP. Registered as a BYOC +capability, called through the gateway, response flows back end-to-end. + +## Run + +```bash +docker compose up -d +./test.sh +docker compose down +``` + +`test.sh` prints `PASS` on success. + +## What's running + +```mermaid +sequenceDiagram + autonumber + participant curl + participant gateway + participant orchestrator + participant hello_world as hello_world
(SDK container) + + curl->>gateway: POST /process/request/predict + gateway->>orchestrator: forward (Livepeer-signed) + orchestrator->>hello_world: POST /predict {"name":"..."} + hello_world-->>orchestrator: {"message":"hello, ..."} + orchestrator-->>gateway: response + gateway-->>curl: response +``` + +Four compose services: + +| Service | What it is | +| --- | --- | +| `gateway`, `orchestrator` | `livepeer/go-livepeer:master` from Docker Hub | +| `hello_world` | The pipeline container — a [BYOC](https://github.com/livepeer/go-livepeer/blob/main/doc/byoc.md) capability built with `livepeer_gateway.runner`. Attached via HTTP register, not the `-aiWorker` mechanism. | +| `register_capability` | One-shot helper that POSTs to `orchestrator:8935/capability/register` | + +First `docker compose up` pulls `livepeer/go-livepeer:master` (~few hundred MB, +cached after) and builds the `hello_world` image locally. diff --git a/examples/runner/hello_world/docker-compose.yml b/examples/runner/hello_world/docker-compose.yml new file mode 100644 index 0000000..7675a4a --- /dev/null +++ b/examples/runner/hello_world/docker-compose.yml @@ -0,0 +1,67 @@ +services: + # Mirrors go-livepeer/doc/byoc.md: on-chain mode against a public Arbitrum + # RPC, but `pricePerUnit 0` keeps the registration free — no balance ever + # leaves the auto-generated keystore. No real chain interaction occurs. + # + # TODO: once livepeer/go-livepeer#3906 ships in :master, drop -network / + # -ethUrl / -ethPassword and run with bare `-network offchain`. Tracked + # in livepeer/go-livepeer#3905. + + orchestrator: + image: livepeer/go-livepeer:master + container_name: orchestrator + command: > + -network arbitrum-one-mainnet + -orchestrator + -ethUrl https://arb1.arbitrum.io/rpc + -ethPassword secret-password + -pricePerUnit 1 + -serviceAddr=orchestrator:8935 + -orchSecret=orch-secret + -v 6 + + gateway: + image: livepeer/go-livepeer:master + container_name: gateway + command: > + -network arbitrum-one-mainnet + -gateway + -ethUrl https://arb1.arbitrum.io/rpc + -ethPassword secret-password + -orchAddr=orchestrator:8935 + -httpAddr=0.0.0.0:9935 + -httpIngest + -v 6 + ports: + - "9935:9935" + depends_on: + - orchestrator + + hello_world: + build: + context: ../../.. + dockerfile: examples/runner/hello_world/Dockerfile + container_name: hello_world + expose: + - "5000" + depends_on: + - orchestrator + + register_capability: + image: python:3.11-slim + container_name: register_capability + command: sh -c "pip install --quiet requests && python /app/register_capability.py" + volumes: + - ./register_capability.py:/app/register_capability.py:ro + environment: + ORCH_URL: https://orchestrator:8935 + ORCH_SECRET: orch-secret + CAPABILITY_NAME: hello-world + CAPABILITY_URL: http://hello_world:5000 + depends_on: + - hello_world + - orchestrator + +networks: + default: + name: livepeer diff --git a/examples/runner/hello_world/pipeline.py b/examples/runner/hello_world/pipeline.py new file mode 100644 index 0000000..49c44b6 --- /dev/null +++ b/examples/runner/hello_world/pipeline.py @@ -0,0 +1,12 @@ +"""Hello-world BYOC pipeline. Run via ``docker compose up``.""" + +from livepeer_gateway.runner import Pipeline, serve + + +class HelloWorld(Pipeline): + def predict(self, name: str = "world") -> dict: + return {"message": f"hello, {name}"} + + +if __name__ == "__main__": + serve(HelloWorld()) diff --git a/examples/runner/hello_world/register_capability.py b/examples/runner/hello_world/register_capability.py new file mode 100644 index 0000000..9e92bf6 --- /dev/null +++ b/examples/runner/hello_world/register_capability.py @@ -0,0 +1,52 @@ +"""Register the hello-world capability with the orchestrator. + +Wire format follows +https://github.com/livepeer/go-livepeer/blob/main/doc/byoc.md +(handler in ``byoc/job_orchestrator.go``). +""" + +import os +import sys +import time + +import requests +import urllib3 + +# Orchestrator's HTTPS endpoint uses a self-signed cert. +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +ORCH_URL = os.environ.get("ORCH_URL", "https://orchestrator:8935") +ORCH_SECRET = os.environ.get("ORCH_SECRET", "orch-secret") +CAPABILITY_NAME = os.environ.get("CAPABILITY_NAME", "hello-world") +CAPABILITY_URL = os.environ.get("CAPABILITY_URL", "http://hello_world:5000") +MAX_ATTEMPTS = int(os.environ.get("MAX_ATTEMPTS", "30")) + +data = { + "name": CAPABILITY_NAME, + "url": CAPABILITY_URL, + "capacity": 1, + "price_per_unit": 0, + "price_scaling": 1, + "currency": "wei", +} +headers = {"Authorization": ORCH_SECRET} + +for attempt in range(1, MAX_ATTEMPTS + 1): + try: + r = requests.post( + f"{ORCH_URL}/capability/register", + json=data, + headers=headers, + verify=False, + timeout=5, + ) + if r.status_code == 200: + print(f"registered {CAPABILITY_NAME} -> {CAPABILITY_URL}") + sys.exit(0) + print(f"attempt {attempt}: status={r.status_code} body={r.text!r}") + except Exception as exc: + print(f"attempt {attempt}: {exc}") + time.sleep(2) + +print("registration failed after timeout") +sys.exit(1) diff --git a/examples/runner/hello_world/test.sh b/examples/runner/hello_world/test.sh new file mode 100755 index 0000000..8a21e67 --- /dev/null +++ b/examples/runner/hello_world/test.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# E2E: send a request through the gateway, assert the response from the +# hello_world container comes back through the orchestrator. + +set -euo pipefail +cd "$(dirname "$0")" + +GATEWAY_URL="${GATEWAY_URL:-http://localhost:9935}" +NAME="${NAME:-livepeer}" +EXPECTED_MSG="hello, ${NAME}" + +echo "Waiting for capability registration..." +for i in $(seq 1 60); do + if docker logs register_capability 2>&1 | grep -q "registered hello-world"; then + echo " registered." + break + fi + sleep 2 +done + +# TODO: swap curl for a livepeer_gateway batch caller (post PR #6) — drops +# the gateway service from compose. +LIVEPEER_HDR=$(printf '%s' '{"request":"{}","parameters":"{}","capability":"hello-world","timeout_seconds":30}' | base64 -w0) + +echo "Sending request through gateway..." +RESPONSE=$(curl -fsS -X POST "${GATEWAY_URL}/process/request/predict" \ + -H "Livepeer: ${LIVEPEER_HDR}" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"${NAME}\"}") + +echo "Response: ${RESPONSE}" + +if echo "${RESPONSE}" | grep -qE "\"message\"[[:space:]]*:[[:space:]]*\"${EXPECTED_MSG}\""; then + echo "PASS" + exit 0 +fi + +echo "FAIL" +exit 1 diff --git a/src/livepeer_gateway/runner/__init__.py b/src/livepeer_gateway/runner/__init__.py new file mode 100644 index 0000000..2532771 --- /dev/null +++ b/src/livepeer_gateway/runner/__init__.py @@ -0,0 +1,7 @@ +"""Pipeline SDK for creating BYOC-compatible AI capabilities from a simple Python class. +""" + +from .pipeline import Pipeline +from .serve import make_app, serve + +__all__ = ["Pipeline", "make_app", "serve"] diff --git a/src/livepeer_gateway/runner/pipeline.py b/src/livepeer_gateway/runner/pipeline.py new file mode 100644 index 0000000..b5b0fdc --- /dev/null +++ b/src/livepeer_gateway/runner/pipeline.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class Pipeline(ABC): + """Base class for batch inference pipelines.""" + + @abstractmethod + def predict(self, **kwargs: Any) -> Any: + """Run one inference; kwargs are the JSON request body fields. + + Return any JSON-serialisable value, or raise to signal an error. + """ + ... diff --git a/src/livepeer_gateway/runner/serve.py b/src/livepeer_gateway/runner/serve.py new file mode 100644 index 0000000..65446e9 --- /dev/null +++ b/src/livepeer_gateway/runner/serve.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import logging +from typing import Any + +from aiohttp import web + +from .pipeline import Pipeline + +_LOG = logging.getLogger(__name__) + + +async def handle_predict(request: web.Request) -> web.Response: + """Run one inference. + + Body is JSON; passed as kwargs to ``pipeline.predict()``. Returns the + result as JSON. ``TypeError`` from ``predict()`` becomes HTTP 400; + other exceptions become 500. + """ + pipeline: Pipeline = request.app["pipeline"] + + try: + body = await request.json() + except Exception as exc: + return web.json_response( + {"error": f"invalid JSON body: {exc}"}, + status=400, + ) + if not isinstance(body, dict): + return web.json_response( + {"error": "request body must be a JSON object"}, + status=400, + ) + + try: + result: Any = pipeline.predict(**body) + except TypeError as exc: + return web.json_response( + {"error": f"input mismatch: {exc}"}, + status=400, + ) + except Exception: + _LOG.exception("predict() failed") + return web.json_response({"error": "internal error"}, status=500) + + return web.json_response(result) + + +async def handle_health(_: web.Request) -> web.Response: + """Health probe. Returns ``{"status": "ready"}``.""" + return web.json_response({"status": "ready"}) + + +def make_app(pipeline: Pipeline) -> web.Application: + """Build an aiohttp application exposing ``pipeline`` over HTTP.""" + app = web.Application() + app["pipeline"] = pipeline + app.router.add_post("/predict", handle_predict) + app.router.add_get("/health", handle_health) + return app + + +def serve(pipeline: Pipeline, *, host: str = "0.0.0.0", port: int = 5000) -> None: + """Run the pipeline as an HTTP server on host:port.""" + web.run_app(make_app(pipeline), host=host, port=port) From 07cd5c4b05c377d41545469797cb4b1260d60186 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 29 Apr 2026 09:32:22 +0200 Subject: [PATCH 02/21] feat(runner): setup() lifecycle hook + HF sentiment example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipeline.setup() is a non-abstract no-op called once before serve() accepts requests. Subclasses override to load model weights. Adds examples/runner/sentiment/ — a Pipeline subclass that classifies text via Hugging Face transformers. setup() loads the distilbert model from the local HF cache populated at build time by prepare_models.py. Surface: - Pipeline.setup() no-op default - make_app() invokes pipeline.setup() before binding routes - examples/runner/sentiment/ — pipeline + prepare_models + Dockerfile + docker-compose + register + test.sh + README Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/runner/sentiment/Dockerfile | 26 ++++++ examples/runner/sentiment/README.md | 69 ++++++++++++++++ examples/runner/sentiment/docker-compose.yml | 79 +++++++++++++++++++ examples/runner/sentiment/pipeline.py | 25 ++++++ examples/runner/sentiment/prepare_models.py | 12 +++ .../runner/sentiment/register_capability.py | 52 ++++++++++++ examples/runner/sentiment/requirements.txt | 4 + examples/runner/sentiment/test.sh | 39 +++++++++ src/livepeer_gateway/runner/pipeline.py | 7 ++ src/livepeer_gateway/runner/serve.py | 7 +- 10 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 examples/runner/sentiment/Dockerfile create mode 100644 examples/runner/sentiment/README.md create mode 100644 examples/runner/sentiment/docker-compose.yml create mode 100644 examples/runner/sentiment/pipeline.py create mode 100644 examples/runner/sentiment/prepare_models.py create mode 100644 examples/runner/sentiment/register_capability.py create mode 100644 examples/runner/sentiment/requirements.txt create mode 100755 examples/runner/sentiment/test.sh diff --git a/examples/runner/sentiment/Dockerfile b/examples/runner/sentiment/Dockerfile new file mode 100644 index 0000000..6a12b5b --- /dev/null +++ b/examples/runner/sentiment/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.11-slim + +WORKDIR /app + +# SDK install (in-repo source until livepeer-gateway publishes; will collapse +# to a single `pip install livepeer-gateway` line once on PyPI). +COPY pyproject.toml README ./ +COPY src /app/src +RUN pip install --no-cache-dir /app + +# Pipeline-specific deps. The requirements.txt sets --extra-index-url to +# pull the CPU-only torch wheel (~200 MB vs ~5 GB for the CUDA variant). +COPY examples/runner/sentiment/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Bake model weights at build time so setup() loads from local disk in +# milliseconds. +COPY examples/runner/sentiment/prepare_models.py /app/prepare_models.py +RUN python /app/prepare_models.py + +# Pipeline code last so edits don't invalidate the bake layer above. +COPY examples/runner/sentiment/pipeline.py /app/pipeline.py + +EXPOSE 5000 + +CMD ["python", "/app/pipeline.py"] diff --git a/examples/runner/sentiment/README.md b/examples/runner/sentiment/README.md new file mode 100644 index 0000000..6293ef0 --- /dev/null +++ b/examples/runner/sentiment/README.md @@ -0,0 +1,69 @@ +# Sentiment analysis (BYOC) + +A Hugging Face sentiment classifier shipped as a BYOC capability. Demonstrates +the `setup()` lifecycle hook for one-time model loading. Built on +[distilbert-base-uncased-finetuned-sst-2-english](https://huggingface.co/distilbert/distilbert-base-uncased-finetuned-sst-2-english) — small enough to run on CPU. + +A `Pipeline` subclass loads the model once in `setup()`, then classifies text +on each `POST /predict`. Registered as a BYOC capability, called through the +gateway, response flows back end-to-end. + +## Run + +```bash +docker compose up -d --wait +./test.sh +docker compose down +``` + +`test.sh` prints `PASS` on success. + +> **First build is ~5 minutes** — pulls torch CPU (~200 MB), transformers, and +> bakes the ~250 MB model into the image. Cached after that; rebuilds are fast. + +## What's running + +```mermaid +sequenceDiagram + autonumber + participant curl + participant gateway + participant orchestrator + participant sentiment as sentiment
(SDK container) + + curl->>gateway: POST /process/request/predict + gateway->>orchestrator: forward (Livepeer-signed) + orchestrator->>sentiment: POST /predict {"text":"..."} + sentiment-->>orchestrator: {"label":"POSITIVE","score":0.99,...} + orchestrator-->>gateway: response + gateway-->>curl: response +``` + +Four compose services: + +| Service | What it is | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `gateway`, `orchestrator` | `livepeer/go-livepeer:master` from Docker Hub | +| `sentiment` | The pipeline container — a [BYOC](https://github.com/livepeer/go-livepeer/blob/main/doc/byoc.md) capability built with `livepeer_gateway.runner`. Loads the HF model in `setup()`. | +| `register_capability` | One-shot helper that POSTs to `orchestrator:8935/capability/register` once `sentiment` is healthy | + +The sentiment service has a healthcheck that probes `GET /health` until the +model finishes loading. `register_capability` waits on `service_healthy`, so +the orchestrator never sees a "registered but not loaded" container. + +## Try variations + +```bash +TEXT="this is awful" EXPECTED_LABEL=NEGATIVE ./test.sh +``` + +Or manually: + +```bash +LIVEPEER_HDR=$(printf '%s' '{"request":"{}","parameters":"{}","capability":"sentiment","timeout_seconds":30}' | base64 -w0) + +curl -X POST http://localhost:9935/process/request/predict \ + -H "Livepeer: ${LIVEPEER_HDR}" \ + -H "Content-Type: application/json" \ + -d '{"text":"distributed inference is the future"}' +``` diff --git a/examples/runner/sentiment/docker-compose.yml b/examples/runner/sentiment/docker-compose.yml new file mode 100644 index 0000000..33f0bb9 --- /dev/null +++ b/examples/runner/sentiment/docker-compose.yml @@ -0,0 +1,79 @@ +services: + # Mirrors go-livepeer/doc/byoc.md: on-chain mode against a public Arbitrum + # RPC, but `pricePerUnit 0` keeps the registration free — no balance ever + # leaves the auto-generated keystore. No real chain interaction occurs. + # + # TODO: once livepeer/go-livepeer#3906 ships in :master, drop -network / + # -ethUrl / -ethPassword and run with bare `-network offchain`. Tracked + # in livepeer/go-livepeer#3905. + + orchestrator: + image: livepeer/go-livepeer:master + container_name: orchestrator + command: > + -network arbitrum-one-mainnet + -orchestrator + -ethUrl https://arb1.arbitrum.io/rpc + -ethPassword secret-password + -pricePerUnit 1 + -serviceAddr=orchestrator:8935 + -orchSecret=orch-secret + -v 6 + + gateway: + image: livepeer/go-livepeer:master + container_name: gateway + command: > + -network arbitrum-one-mainnet + -gateway + -ethUrl https://arb1.arbitrum.io/rpc + -ethPassword secret-password + -orchAddr=orchestrator:8935 + -httpAddr=0.0.0.0:9935 + -httpIngest + -v 6 + ports: + - "9935:9935" + depends_on: + - orchestrator + + sentiment: + build: + context: ../../.. + dockerfile: examples/runner/sentiment/Dockerfile + container_name: sentiment + expose: + - "5000" + # Healthcheck waits for setup() to finish loading the model before the + # orchestrator can route requests here. Without this, register_capability + # could complete and the test could fire before the model is loaded. + healthcheck: + test: ["CMD", "python", "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:5000/health').read()"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 60s + depends_on: + - orchestrator + + register_capability: + image: python:3.11-slim + container_name: register_capability + command: sh -c "pip install --quiet requests && python /app/register_capability.py" + volumes: + - ./register_capability.py:/app/register_capability.py:ro + environment: + ORCH_URL: https://orchestrator:8935 + ORCH_SECRET: orch-secret + CAPABILITY_NAME: sentiment + CAPABILITY_URL: http://sentiment:5000 + depends_on: + sentiment: + condition: service_healthy + orchestrator: + condition: service_started + +networks: + default: + name: livepeer diff --git a/examples/runner/sentiment/pipeline.py b/examples/runner/sentiment/pipeline.py new file mode 100644 index 0000000..0e45d6f --- /dev/null +++ b/examples/runner/sentiment/pipeline.py @@ -0,0 +1,25 @@ +"""Sentiment-analysis BYOC pipeline. Run via ``docker compose up`` — see README.md.""" + +from livepeer_gateway.runner import Pipeline, serve +from transformers import pipeline as hf_pipeline + + +class SentimentAnalyzer(Pipeline): + def setup(self): + # Loads from local HF cache populated at Docker build time. + self.model = hf_pipeline( + "sentiment-analysis", + model="distilbert-base-uncased-finetuned-sst-2-english", + ) + + def predict(self, text: str = "Livepeer is great") -> dict: + result = self.model(text)[0] + return { + "label": result["label"], + "score": float(result["score"]), + "text": text, + } + + +if __name__ == "__main__": + serve(SentimentAnalyzer()) diff --git a/examples/runner/sentiment/prepare_models.py b/examples/runner/sentiment/prepare_models.py new file mode 100644 index 0000000..dec0b19 --- /dev/null +++ b/examples/runner/sentiment/prepare_models.py @@ -0,0 +1,12 @@ +"""Download model weights into the local HF cache at build time. + +Invoked by the Dockerfile so ``setup()`` loads from local disk in +milliseconds instead of pulling from HF Hub on every container start. +""" + +from transformers import pipeline + +pipeline( + "sentiment-analysis", + model="distilbert-base-uncased-finetuned-sst-2-english", +) diff --git a/examples/runner/sentiment/register_capability.py b/examples/runner/sentiment/register_capability.py new file mode 100644 index 0000000..4f25e8b --- /dev/null +++ b/examples/runner/sentiment/register_capability.py @@ -0,0 +1,52 @@ +"""Register the sentiment capability with the orchestrator. + +Wire format follows +https://github.com/livepeer/go-livepeer/blob/main/doc/byoc.md +(handler in ``byoc/job_orchestrator.go``). +""" + +import os +import sys +import time + +import requests +import urllib3 + +# Orchestrator's HTTPS endpoint uses a self-signed cert. +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +ORCH_URL = os.environ.get("ORCH_URL", "https://orchestrator:8935") +ORCH_SECRET = os.environ.get("ORCH_SECRET", "orch-secret") +CAPABILITY_NAME = os.environ.get("CAPABILITY_NAME", "sentiment") +CAPABILITY_URL = os.environ.get("CAPABILITY_URL", "http://sentiment:5000") +MAX_ATTEMPTS = int(os.environ.get("MAX_ATTEMPTS", "30")) + +data = { + "name": CAPABILITY_NAME, + "url": CAPABILITY_URL, + "capacity": 1, + "price_per_unit": 0, + "price_scaling": 1, + "currency": "wei", +} +headers = {"Authorization": ORCH_SECRET} + +for attempt in range(1, MAX_ATTEMPTS + 1): + try: + r = requests.post( + f"{ORCH_URL}/capability/register", + json=data, + headers=headers, + verify=False, + timeout=5, + ) + if r.status_code == 200: + print(f"registered {CAPABILITY_NAME} -> {CAPABILITY_URL}") + sys.exit(0) + print(f"attempt {attempt}: status={r.status_code} body={r.text!r}") + except Exception as exc: + print(f"attempt {attempt}: {exc}") + time.sleep(2) + +print("registration failed after timeout") +sys.exit(1) diff --git a/examples/runner/sentiment/requirements.txt b/examples/runner/sentiment/requirements.txt new file mode 100644 index 0000000..f13a349 --- /dev/null +++ b/examples/runner/sentiment/requirements.txt @@ -0,0 +1,4 @@ +--extra-index-url https://download.pytorch.org/whl/cpu + +transformers +torch diff --git a/examples/runner/sentiment/test.sh b/examples/runner/sentiment/test.sh new file mode 100755 index 0000000..13cb6b0 --- /dev/null +++ b/examples/runner/sentiment/test.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# E2E: send a request through the gateway, assert the SentimentAnalyzer +# response (label + score) comes back through the orchestrator. + +set -euo pipefail +cd "$(dirname "$0")" + +GATEWAY_URL="${GATEWAY_URL:-http://localhost:9935}" +TEXT="${TEXT:-Livepeer makes decentralized inference effortless}" +EXPECTED_LABEL="${EXPECTED_LABEL:-POSITIVE}" + +echo "Waiting for capability registration..." +for i in $(seq 1 60); do + if docker logs register_capability 2>&1 | grep -q "registered sentiment"; then + echo " registered." + break + fi + sleep 2 +done + +# TODO: swap curl for a livepeer_gateway batch caller (post PR #6) — drops +# the gateway service from compose. +LIVEPEER_HDR=$(printf '%s' '{"request":"{}","parameters":"{}","capability":"sentiment","timeout_seconds":30}' | base64 -w0) + +echo "Sending request through gateway..." +RESPONSE=$(curl -fsS -X POST "${GATEWAY_URL}/process/request/predict" \ + -H "Livepeer: ${LIVEPEER_HDR}" \ + -H "Content-Type: application/json" \ + -d "{\"text\":\"${TEXT}\"}") + +echo "Response: ${RESPONSE}" + +if echo "${RESPONSE}" | grep -qE "\"label\"[[:space:]]*:[[:space:]]*\"${EXPECTED_LABEL}\""; then + echo "PASS" + exit 0 +fi + +echo "FAIL" +exit 1 diff --git a/src/livepeer_gateway/runner/pipeline.py b/src/livepeer_gateway/runner/pipeline.py index b5b0fdc..fbf810f 100644 --- a/src/livepeer_gateway/runner/pipeline.py +++ b/src/livepeer_gateway/runner/pipeline.py @@ -7,6 +7,13 @@ class Pipeline(ABC): """Base class for batch inference pipelines.""" + def setup(self) -> None: + """Hook called once before serve() accepts requests. + + Override to load model weights, warm up GPUs, allocate buffers. + Default: no-op for stateless pipelines. + """ + @abstractmethod def predict(self, **kwargs: Any) -> Any: """Run one inference; kwargs are the JSON request body fields. diff --git a/src/livepeer_gateway/runner/serve.py b/src/livepeer_gateway/runner/serve.py index 65446e9..eba677e 100644 --- a/src/livepeer_gateway/runner/serve.py +++ b/src/livepeer_gateway/runner/serve.py @@ -52,7 +52,12 @@ async def handle_health(_: web.Request) -> web.Response: def make_app(pipeline: Pipeline) -> web.Application: - """Build an aiohttp application exposing ``pipeline`` over HTTP.""" + """Build an aiohttp application exposing ``pipeline`` over HTTP. + + Calls ``pipeline.setup()`` synchronously before binding routes, so the + server only starts accepting requests once the pipeline is initialised. + """ + pipeline.setup() app = web.Application() app["pipeline"] = pipeline app.router.add_post("/predict", handle_predict) From 8fec59f9173b0944e3bd6e9a172ebeafdb6642f1 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 29 Apr 2026 09:32:31 +0200 Subject: [PATCH 03/21] chore: add TODO.md for repo-level follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks operational items not suited to code comments — examples extraction trigger, BYOC offchain compose cleanup pending #3906, SDK feature gaps mapped to planned commits, related upstream PRs. Working surface, drained as items land. Co-Authored-By: Claude Opus 4.7 (1M context) --- TODO.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..befcebb --- /dev/null +++ b/TODO.md @@ -0,0 +1,75 @@ +# TODO + +Repo-level follow-ups that don't belong in code comments or spec docs. +Pending operational decisions and triggers for future work. + +## Examples directory + +- [ ] **Extract `examples/runner/` to a dedicated repo + (`livepeer/livepeer-pipeline-examples`)** when any of these triggers hit: + - 3+ runner examples in this repo + - First multi-GB-model example (image gen, LLM, real-time video) — CI + cost becomes painful + - First community contribution + - Cog precedent: `replicate/cog-examples` lives separately from `cog`. + + Keep `hello_world` in this repo as the CI smoke test even after extraction. + Each example in the new repo pins to a specific `livepeer-gateway` version. + +## BYOC offchain support + +- [ ] **Drop `-network arbitrum-one-mainnet`, `-ethUrl`, `-ethPassword` + from example compose files** once + [livepeer/go-livepeer#3906](https://github.com/livepeer/go-livepeer/pull/3906) + ships in `:master`. After that, examples can run with bare + `-network offchain`. Tracked: TODO comment already in each compose. + +## SDK round-trip + +- [ ] **Replace `test.sh`'s curl + base64 Livepeer header with a Python SDK + batch caller** once a batch caller lands (built on + [livepeer/livepeer-python-gateway#6](https://github.com/livepeer/livepeer-python-gateway/pull/6)'s + signing primitives). At that point the example compose can drop the + `gateway` service entirely — caller talks direct to the orchestrator via + the remote signer (per + [livepeer/go-livepeer#3869](https://github.com/livepeer/go-livepeer/pull/3869)). + Tracked: TODO comment in each example's `test.sh`. + +## SDK feature gaps + +- [ ] **Health state machine** (`LOADING / READY / ERROR / IDLE`) on + `/health` body. Currently `setup()` blocks the server bind, and the + example uses a docker compose healthcheck as a workaround. When the + state machine lands, drop the healthcheck from `sentiment/docker-compose.yml`. + +- [ ] **`Input()` / `Output()` typed descriptors** (C3 in the planned + staircase). Required before schema generation. + +- [ ] **Schema generation + `GET /openapi.json`** (C4). Inspects + `predict()` / `on_frame()` signature, emits OpenAPI JSON. + +- [ ] **`GET /` discovery doc** (C5). Points at schema URL, capability id, + version, supported transports. Cog parallel. + +- [ ] **`StreamPipeline` for trickle transport.** With `on_frame()` / + `on_video_frame()` / `on_audio_frame()`. Reuse existing trickle primitives + in `livepeer_gateway.transport`. + +- [ ] **SSE auto-detection** for generators. When `predict()` yields, + emit `text/event-stream`. + +- [ ] **`livepeer push` CLI** + `livepeer.yaml` manifest. Parses the + manifest, generates the Dockerfile (no more hand-written examples), + builds, registers. Drops the example Dockerfiles. + +- [ ] **Schema as Docker image label** (`org.livepeer.pipeline.schema`). + Lands with `livepeer push`. Removes the runtime `/openapi.json` as the + primary schema delivery; keeps it as a fallback. + +## Tracking related upstream work + +- go-livepeer offchain BYOC: [#3905](https://github.com/livepeer/go-livepeer/issues/3905) / + [#3906](https://github.com/livepeer/go-livepeer/pull/3906) +- BYOC remote signer: [#3869](https://github.com/livepeer/go-livepeer/pull/3869) +- Caller-side BYOC SDK: [#6](https://github.com/livepeer/livepeer-python-gateway/pull/6) +- This SDK's draft PR: [#7](https://github.com/livepeer/livepeer-python-gateway/pull/7) From 4a99b9c2998caee0a8373c87c1b4230dd0dba053 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 29 Apr 2026 10:14:31 +0200 Subject: [PATCH 04/21] feat(runner): migrate HTTP layer from aiohttp to FastAPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the aiohttp serve layer with FastAPI + uvicorn. Pipeline API unchanged — Pipeline.predict() and Pipeline.setup() behave identically. Free additions from FastAPI: - GET /docs (Swagger UI) - GET /redoc - GET /openapi.json (minimal until Input/Output land) Handler dispatch: - /predict and /health are sync def, so pipeline.predict() (CPU/GPU bound) runs in FastAPI's threadpool and never blocks the event loop. - Request body parsed via Body(...) — framework handles JSON parse errors and dict-type validation, returning HTTP 422. Notes: - Error response shape changes from {"error": ...} to {"detail": ...}. Body validation errors return 422 (was 400 in aiohttp). Other status codes unchanged: TypeError on wrong predict() kwargs → 400; pipeline exceptions → 500. - aiohttp stays in deps; livepeer_gateway.transport's trickle client uses aiohttp.ClientSession. FastAPI server + aiohttp client coexist. Refs livepeer/livepeer-python-gateway#8 (C3) --- pyproject.toml | 2 + src/livepeer_gateway/runner/serve.py | 76 ++-- uv.lock | 606 +++++++++++++++++++++++++++ 3 files changed, 632 insertions(+), 52 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index afa0751..dfbc413 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ dependencies = [ "protobuf>=4.25.0", "aiohttp>=3.9.0", "av>=11.0.0", + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", ] [project.scripts] diff --git a/src/livepeer_gateway/runner/serve.py b/src/livepeer_gateway/runner/serve.py index eba677e..04ecd56 100644 --- a/src/livepeer_gateway/runner/serve.py +++ b/src/livepeer_gateway/runner/serve.py @@ -3,68 +3,40 @@ import logging from typing import Any -from aiohttp import web +import uvicorn +from fastapi import Body, FastAPI, HTTPException from .pipeline import Pipeline _LOG = logging.getLogger(__name__) -async def handle_predict(request: web.Request) -> web.Response: - """Run one inference. - - Body is JSON; passed as kwargs to ``pipeline.predict()``. Returns the - result as JSON. ``TypeError`` from ``predict()`` becomes HTTP 400; - other exceptions become 500. - """ - pipeline: Pipeline = request.app["pipeline"] - - try: - body = await request.json() - except Exception as exc: - return web.json_response( - {"error": f"invalid JSON body: {exc}"}, - status=400, - ) - if not isinstance(body, dict): - return web.json_response( - {"error": "request body must be a JSON object"}, - status=400, - ) - - try: - result: Any = pipeline.predict(**body) - except TypeError as exc: - return web.json_response( - {"error": f"input mismatch: {exc}"}, - status=400, - ) - except Exception: - _LOG.exception("predict() failed") - return web.json_response({"error": "internal error"}, status=500) - - return web.json_response(result) - - -async def handle_health(_: web.Request) -> web.Response: - """Health probe. Returns ``{"status": "ready"}``.""" - return web.json_response({"status": "ready"}) - +def make_app(pipeline: Pipeline) -> FastAPI: + """Build a FastAPI app exposing ``pipeline`` over HTTP.""" + pipeline.setup() -def make_app(pipeline: Pipeline) -> web.Application: - """Build an aiohttp application exposing ``pipeline`` over HTTP. + app = FastAPI(title=type(pipeline).__name__) + app.state.pipeline = pipeline + + @app.post("/predict", summary="Run one inference") + def handle_predict(body: dict = Body(...)) -> Any: + try: + return pipeline.predict(**body) + except TypeError as exc: + raise HTTPException(status_code=400, detail=f"input mismatch: {exc}") + except HTTPException: + raise + except Exception: + _LOG.exception("predict() failed") + raise HTTPException(status_code=500, detail="internal error") + + @app.get("/health", summary="Liveness probe") + def handle_health() -> dict: + return {"status": "ready"} - Calls ``pipeline.setup()`` synchronously before binding routes, so the - server only starts accepting requests once the pipeline is initialised. - """ - pipeline.setup() - app = web.Application() - app["pipeline"] = pipeline - app.router.add_post("/predict", handle_predict) - app.router.add_get("/health", handle_health) return app def serve(pipeline: Pipeline, *, host: str = "0.0.0.0", port: int = 5000) -> None: """Run the pipeline as an HTTP server on host:port.""" - web.run_app(make_app(pipeline), host=host, port=port) + uvicorn.run(make_app(pipeline), host=host, port=port) diff --git a/uv.lock b/uv.lock index 4003c14..bb8d731 100644 --- a/uv.lock +++ b/uv.lock @@ -148,6 +148,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -223,6 +255,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/41/7f13361db54d7e02f11552575c0384dadaf0918138f4eaa82ea03a9f9580/av-16.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6f90dc082ff2068ddbe77618400b44d698d25d9c4edac57459e250c16b33d700", size = 31948164, upload-time = "2026-01-11T09:59:19.501Z" }, ] +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.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/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -468,6 +549,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/4d/31236cddb7ffb09ba4a49f4f56d2608fec3bbb21c7a0a975d93bca7cd22e/grpcio_tools-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:2ccd2c8d041351cc29d0fc4a84529b11ee35494a700b535c1f820b642f2a72fc", size = 1190242, upload-time = "2025-10-21T16:26:25.296Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, + { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, + { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -484,8 +617,10 @@ source = { editable = "." } dependencies = [ { name = "aiohttp" }, { name = "av" }, + { name = "fastapi" }, { name = "grpcio" }, { name = "protobuf" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.optional-dependencies] @@ -502,11 +637,13 @@ examples = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.9.0" }, { name = "av", specifier = ">=11.0.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, { name = "grpcio", specifier = ">=1.65.0" }, { name = "grpcio-tools", marker = "extra == 'dev'", specifier = ">=1.65.0" }, { name = "numpy", marker = "extra == 'examples'", specifier = ">=2.2.6" }, { name = "opencv-python-headless", marker = "extra == 'examples'", specifier = ">=4.13.0.90" }, { name = "protobuf", specifier = ">=4.25.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, ] provides-extras = ["dev", "examples"] @@ -943,6 +1080,210 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/15/4f02896cc3df04fc465010a4c6a0cd89810f54617a32a70ef531ed75d61c/protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c", size = 170501, upload-time = "2025-12-06T00:17:52.211Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/98/b50eb9a411e87483b5c65dba4fa430a06bac4234d3403a40e5a9905ebcd0/pydantic_core-2.46.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1", size = 2108971, upload-time = "2026-04-20T14:43:51.945Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f364b9d161718ff2217160a4b5d41ce38de60aed91c3689ebffa1c939d23/pydantic_core-2.46.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f", size = 1949588, upload-time = "2026-04-20T14:44:10.386Z" }, + { url = "https://files.pythonhosted.org/packages/8f/8b/30bd03ee83b2f5e29f5ba8e647ab3c456bf56f2ec72fdbcc0215484a0854/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3", size = 1975986, upload-time = "2026-04-20T14:43:57.106Z" }, + { url = "https://files.pythonhosted.org/packages/3c/54/13ccf954d84ec275d5d023d5786e4aa48840bc9f161f2838dc98e1153518/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a", size = 2055830, upload-time = "2026-04-20T14:44:15.499Z" }, + { url = "https://files.pythonhosted.org/packages/be/0e/65f38125e660fdbd72aa858e7dfae893645cfa0e7b13d333e174a367cd23/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807", size = 2222340, upload-time = "2026-04-20T14:41:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/d1/88/f3ab7739efe0e7e80777dbb84c59eb98518e3f57ea433206194c2e425272/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda", size = 2280727, upload-time = "2026-04-20T14:41:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6d/c228219080817bec4982f9531cadb18da6aaa770fdeb114f49c237ac2c9f/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57", size = 2092158, upload-time = "2026-04-20T14:44:07.305Z" }, + { url = "https://files.pythonhosted.org/packages/0f/b1/525a16711e7c6d61635fac3b0bd54600b5c5d9f60c6fc5aaab26b64a2297/pydantic_core-2.46.3-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045", size = 2116626, upload-time = "2026-04-20T14:42:34.118Z" }, + { url = "https://files.pythonhosted.org/packages/ef/7c/17d30673351439a6951bf54f564cf2443ab00ae264ec9df00e2efd710eb5/pydantic_core-2.46.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943", size = 2160691, upload-time = "2026-04-20T14:41:14.023Z" }, + { url = "https://files.pythonhosted.org/packages/86/66/af8adbcbc0886ead7f1a116606a534d75a307e71e6e08226000d51b880d2/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f", size = 2182543, upload-time = "2026-04-20T14:40:48.886Z" }, + { url = "https://files.pythonhosted.org/packages/b0/37/6de71e0f54c54a4190010f57deb749e1ddf75c568ada3b1320b70067f121/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4", size = 2324513, upload-time = "2026-04-20T14:42:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/51/b1/9fc74ce94f603d5ef59ff258ca9c2c8fb902fb548d340a96f77f4d1c3b7f/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a", size = 2361853, upload-time = "2026-04-20T14:43:24.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/d0/4c652fc592db35f100279ee751d5a145aca1b9a7984b9684ba7c1b5b0535/pydantic_core-2.46.3-cp310-cp310-win32.whl", hash = "sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7", size = 1980465, upload-time = "2026-04-20T14:44:46.239Z" }, + { url = "https://files.pythonhosted.org/packages/27/b8/a920453c38afbe1f355e1ea0b0d94a0a3e0b0879d32d793108755fa171d5/pydantic_core-2.46.3-cp310-cp310-win_amd64.whl", hash = "sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6", size = 2073884, upload-time = "2026-04-20T14:43:01.201Z" }, + { url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740, upload-time = "2026-04-20T14:41:20.932Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293, upload-time = "2026-04-20T14:43:42.115Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222, upload-time = "2026-04-20T14:41:57.841Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852, upload-time = "2026-04-20T14:40:43.077Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134, upload-time = "2026-04-20T14:43:03.349Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785, upload-time = "2026-04-20T14:41:19.285Z" }, + { url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404, upload-time = "2026-04-20T14:43:10.108Z" }, + { url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898, upload-time = "2026-04-20T14:44:51.475Z" }, + { url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856, upload-time = "2026-04-20T14:43:46.64Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168, upload-time = "2026-04-20T14:42:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885, upload-time = "2026-04-20T14:41:05.253Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328, upload-time = "2026-04-20T14:41:43.991Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464, upload-time = "2026-04-20T14:43:12.215Z" }, + { url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837, upload-time = "2026-04-20T14:41:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647, upload-time = "2026-04-20T14:42:27.535Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/66/7f/03dbad45cd3aa9083fbc93c210ae8b005af67e4136a14186950a747c6874/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", size = 2105683, upload-time = "2026-04-20T14:42:19.779Z" }, + { url = "https://files.pythonhosted.org/packages/26/22/4dc186ac8ea6b257e9855031f51b62a9637beac4d68ac06bee02f046f836/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", size = 1940052, upload-time = "2026-04-20T14:43:59.274Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/d376391a5aff1f2e8188960d7873543608130a870961c2b6b5236627c116/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", size = 1988172, upload-time = "2026-04-20T14:41:17.469Z" }, + { url = "https://files.pythonhosted.org/packages/0e/6b/523b9f85c23788755d6ab949329de692a2e3a584bc6beb67fef5e035aa9d/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", size = 2128596, upload-time = "2026-04-20T14:40:41.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, + { url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973, upload-time = "2026-04-20T14:42:06.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191, upload-time = "2026-04-20T14:43:14.319Z" }, + { url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791, upload-time = "2026-04-20T14:43:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197, upload-time = "2026-04-20T14:44:27.932Z" }, + { url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073, upload-time = "2026-04-20T14:43:20.729Z" }, + { url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886, upload-time = "2026-04-20T14:44:04.826Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528, upload-time = "2026-04-20T14:40:47.431Z" }, + { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "setuptools" version = "80.9.0" @@ -952,6 +1293,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, ] +[[package]] +name = "starlette" +version = "1.0.0" +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/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -961,6 +1315,258 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, + { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "yarl" version = "1.22.0" From 961190bb870dbd62f29b67d506d561bf04592d69 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 29 Apr 2026 12:24:10 +0200 Subject: [PATCH 05/21] chore(examples): publish runner port + update docs Switches expose: to ports: so /docs, /redoc, and /openapi.json are browsable on http://localhost:5000 during dev. Example READMEs updated. --- examples/runner/hello_world/README.md | 6 ++++++ examples/runner/hello_world/docker-compose.yml | 4 ++-- examples/runner/sentiment/README.md | 6 ++++++ examples/runner/sentiment/docker-compose.yml | 4 ++-- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/examples/runner/hello_world/README.md b/examples/runner/hello_world/README.md index e50e993..b7caa17 100644 --- a/examples/runner/hello_world/README.md +++ b/examples/runner/hello_world/README.md @@ -15,6 +15,12 @@ docker compose down `test.sh` prints `PASS` on success. +## Browse the API + +- Swagger UI: +- ReDoc: +- OpenAPI JSON: + ## What's running ```mermaid diff --git a/examples/runner/hello_world/docker-compose.yml b/examples/runner/hello_world/docker-compose.yml index 7675a4a..5cd238a 100644 --- a/examples/runner/hello_world/docker-compose.yml +++ b/examples/runner/hello_world/docker-compose.yml @@ -42,8 +42,8 @@ services: context: ../../.. dockerfile: examples/runner/hello_world/Dockerfile container_name: hello_world - expose: - - "5000" + ports: + - "5000:5000" depends_on: - orchestrator diff --git a/examples/runner/sentiment/README.md b/examples/runner/sentiment/README.md index 6293ef0..28b7e00 100644 --- a/examples/runner/sentiment/README.md +++ b/examples/runner/sentiment/README.md @@ -21,6 +21,12 @@ docker compose down > **First build is ~5 minutes** — pulls torch CPU (~200 MB), transformers, and > bakes the ~250 MB model into the image. Cached after that; rebuilds are fast. +## Browse the API + +- Swagger UI: +- ReDoc: +- OpenAPI JSON: + ## What's running ```mermaid diff --git a/examples/runner/sentiment/docker-compose.yml b/examples/runner/sentiment/docker-compose.yml index 33f0bb9..2e62a7f 100644 --- a/examples/runner/sentiment/docker-compose.yml +++ b/examples/runner/sentiment/docker-compose.yml @@ -42,8 +42,8 @@ services: context: ../../.. dockerfile: examples/runner/sentiment/Dockerfile container_name: sentiment - expose: - - "5000" + ports: + - "5000:5000" # Healthcheck waits for setup() to finish loading the model before the # orchestrator can route requests here. Without this, register_capability # could complete and the test could fire before the model is loaded. From dbf711e9ae2265a0e3df4611381a6f231cab0b88 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 29 Apr 2026 12:24:11 +0200 Subject: [PATCH 06/21] chore: ignore IDE / editor artifacts --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 540f0e2..e95e11a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ lp_rpc.proto # environment file env + +# IDE +.vscode/ +*.code-workspace +.idea/ +.codex From 0da5fb79db6c4d892b241f94569e169f3bdfdef1 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 29 Apr 2026 14:48:55 +0200 Subject: [PATCH 07/21] feat(runner): typed inputs/outputs via Pydantic + signature introspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit predict()'s signature drives FastAPI's body type and response model. Two paths: - Explicit BaseModel param: pass body to predict() directly - Bare typed params: auto-derive a Pydantic model via create_model and unpack as kwargs OpenAPI now reflects real types — /docs shows declared fields with descriptions, examples, constraints, and typed responses when the return annotation is a BaseModel. Refs livepeer/livepeer-python-gateway#8 (C4) --- examples/runner/sentiment/pipeline.py | 30 ++++++++--- src/livepeer_gateway/runner/serve.py | 76 ++++++++++++++++++++++----- 2 files changed, 85 insertions(+), 21 deletions(-) diff --git a/examples/runner/sentiment/pipeline.py b/examples/runner/sentiment/pipeline.py index 0e45d6f..a0fedde 100644 --- a/examples/runner/sentiment/pipeline.py +++ b/examples/runner/sentiment/pipeline.py @@ -1,8 +1,22 @@ """Sentiment-analysis BYOC pipeline. Run via ``docker compose up`` — see README.md.""" -from livepeer_gateway.runner import Pipeline, serve +from typing import Literal + +from pydantic import BaseModel, Field from transformers import pipeline as hf_pipeline +from livepeer_gateway.runner import Pipeline, serve + + +class SentimentInput(BaseModel): + text: str = Field(description="Text to classify", examples=["I love this!"]) + + +class SentimentOutput(BaseModel): + label: Literal["POSITIVE", "NEGATIVE"] + score: float = Field(ge=0.0, le=1.0) + text: str + class SentimentAnalyzer(Pipeline): def setup(self): @@ -12,13 +26,13 @@ def setup(self): model="distilbert-base-uncased-finetuned-sst-2-english", ) - def predict(self, text: str = "Livepeer is great") -> dict: - result = self.model(text)[0] - return { - "label": result["label"], - "score": float(result["score"]), - "text": text, - } + def predict(self, params: SentimentInput) -> SentimentOutput: + result = self.model(params.text)[0] + return SentimentOutput( + label=result["label"], + score=float(result["score"]), + text=params.text, + ) if __name__ == "__main__": diff --git a/src/livepeer_gateway/runner/serve.py b/src/livepeer_gateway/runner/serve.py index 04ecd56..dfa9c91 100644 --- a/src/livepeer_gateway/runner/serve.py +++ b/src/livepeer_gateway/runner/serve.py @@ -1,35 +1,85 @@ -from __future__ import annotations - +import inspect import logging from typing import Any import uvicorn -from fastapi import Body, FastAPI, HTTPException +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, create_model from .pipeline import Pipeline _LOG = logging.getLogger(__name__) -def make_app(pipeline: Pipeline) -> FastAPI: - """Build a FastAPI app exposing ``pipeline`` over HTTP.""" - pipeline.setup() +def _is_basemodel(t: Any) -> bool: + return isinstance(t, type) and issubclass(t, BaseModel) + + +def _build_input_model(predict_fn: Any, owner_name: str) -> tuple[type[BaseModel], bool]: + """Inspect predict()'s signature; return (InputModel, is_explicit_basemodel). + + If predict() takes a single ``BaseModel`` parameter, use it directly. + Otherwise build a model from the bare parameters via ``create_model``. + """ + sig = inspect.signature(predict_fn) + params = [param for param in sig.parameters.values() if param.name != "self"] + + if len(params) == 1 and _is_basemodel(params[0].annotation): + return params[0].annotation, True + + fields: dict[str, tuple[Any, Any]] = {} + for param in params: + annotation = param.annotation if param.annotation is not inspect.Parameter.empty else Any + default = param.default if param.default is not inspect.Parameter.empty else ... + fields[param.name] = (annotation, default) + + return create_model(f"{owner_name}Input", **fields), False - app = FastAPI(title=type(pipeline).__name__) - app.state.pipeline = pipeline - @app.post("/predict", summary="Run one inference") - def handle_predict(body: dict = Body(...)) -> Any: +def _build_predict_handler( + pipeline: Pipeline, + InputModel: type[BaseModel], + OutputModel: type[BaseModel] | None, + explicit_basemodel: bool, +): + def handler(body: InputModel): try: - return pipeline.predict(**body) - except TypeError as exc: - raise HTTPException(status_code=400, detail=f"input mismatch: {exc}") + if explicit_basemodel: + return pipeline.predict(body) + return pipeline.predict(**body.model_dump()) except HTTPException: raise except Exception: _LOG.exception("predict() failed") raise HTTPException(status_code=500, detail="internal error") + if OutputModel is not None: + handler.__annotations__["return"] = OutputModel + return handler + + +def make_app(pipeline: Pipeline) -> FastAPI: + """Build a FastAPI app exposing ``pipeline`` over HTTP.""" + pipeline.setup() + + InputModel, explicit_basemodel = _build_input_model( + pipeline.predict, type(pipeline).__name__ + ) + return_annotation = inspect.signature(pipeline.predict).return_annotation + OutputModel = return_annotation if _is_basemodel(return_annotation) else None + + handler = _build_predict_handler(pipeline, InputModel, OutputModel, explicit_basemodel) + + app = FastAPI(title=type(pipeline).__name__) + app.state.pipeline = pipeline + + app.add_api_route( + "/predict", + handler, + methods=["POST"], + summary="Run one inference", + ) + @app.get("/health", summary="Liveness probe") def handle_health() -> dict: return {"status": "ready"} From fdc5fedd6820d058d9c90e6eb2f03a9d09a3173d Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 29 Apr 2026 15:35:15 +0200 Subject: [PATCH 08/21] feat(examples): image-upscale demonstrates binary I/O via Pydantic Base64Bytes Swin2SR x2 super-resolution as a BYOC capability. Input image is a base64-encoded JPEG/PNG; output is a base64-encoded PNG. Pydantic's Base64Bytes auto-decodes the request body to bytes, so the pipeline gets bytes directly and the SDK ships zero binary-handling code. Refs livepeer/livepeer-python-gateway#8 (C5) --- examples/runner/image_upscale/Dockerfile | 26 +++++ examples/runner/image_upscale/README.md | 101 ++++++++++++++++++ .../runner/image_upscale/docker-compose.yml | 79 ++++++++++++++ examples/runner/image_upscale/pipeline.py | 54 ++++++++++ .../runner/image_upscale/prepare_models.py | 11 ++ .../image_upscale/register_capability.py | 52 +++++++++ .../runner/image_upscale/requirements.txt | 6 ++ examples/runner/image_upscale/test.sh | 48 +++++++++ examples/runner/image_upscale/test_image.png | Bin 0 -> 138 bytes 9 files changed, 377 insertions(+) create mode 100644 examples/runner/image_upscale/Dockerfile create mode 100644 examples/runner/image_upscale/README.md create mode 100644 examples/runner/image_upscale/docker-compose.yml create mode 100644 examples/runner/image_upscale/pipeline.py create mode 100644 examples/runner/image_upscale/prepare_models.py create mode 100644 examples/runner/image_upscale/register_capability.py create mode 100644 examples/runner/image_upscale/requirements.txt create mode 100755 examples/runner/image_upscale/test.sh create mode 100644 examples/runner/image_upscale/test_image.png diff --git a/examples/runner/image_upscale/Dockerfile b/examples/runner/image_upscale/Dockerfile new file mode 100644 index 0000000..8481785 --- /dev/null +++ b/examples/runner/image_upscale/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.11-slim + +WORKDIR /app + +# SDK install (in-repo source until livepeer-gateway publishes; will collapse +# to a single `pip install livepeer-gateway` line once on PyPI). +COPY pyproject.toml README ./ +COPY src /app/src +RUN pip install --no-cache-dir /app + +# Pipeline-specific deps. The requirements.txt sets --extra-index-url to +# pull the CPU-only torch wheel (~200 MB vs ~5 GB for the CUDA variant). +COPY examples/runner/image_upscale/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Bake model weights at build time so setup() loads from local disk in +# milliseconds. +COPY examples/runner/image_upscale/prepare_models.py /app/prepare_models.py +RUN python /app/prepare_models.py + +# Pipeline code last so edits don't invalidate the bake layer above. +COPY examples/runner/image_upscale/pipeline.py /app/pipeline.py + +EXPOSE 5000 + +CMD ["python", "/app/pipeline.py"] diff --git a/examples/runner/image_upscale/README.md b/examples/runner/image_upscale/README.md new file mode 100644 index 0000000..47e331d --- /dev/null +++ b/examples/runner/image_upscale/README.md @@ -0,0 +1,101 @@ +# Image upscale (BYOC) + +A ~2x image super-resolution BYOC capability — proves the SDK handles binary +I/O cleanly via Pydantic's `Base64Bytes`. Built on +[Swin2SR](https://huggingface.co/caidas/swin2SR-classical-sr-x2-64), small +enough to run on CPU. + +A `Pipeline` subclass loads the model once in `setup()`, then takes a +base64-encoded image on each `POST /predict` and returns the upscaled PNG. +The processor pads inputs to its window size before upscaling, so output +dimensions are at least 2x input but may be slightly larger. Registered as +a BYOC capability, called through the gateway, response flows back +end-to-end. + +## Run + +```bash +docker compose up -d --wait +./test.sh +docker compose down +``` + +`test.sh` prints `PASS` on success. + +> **First build is ~5 minutes** — pulls torch CPU (~200 MB), transformers, and +> bakes the ~70 MB Swin2SR model into the image. Cached after that. + +## Browse the API + +- Swagger UI: +- ReDoc: +- OpenAPI JSON: + +## What's running + +```mermaid +sequenceDiagram + autonumber + participant curl + participant gateway + participant orchestrator + participant image_upscale as image_upscale
(SDK container) + + curl->>gateway: POST /process/request/predict + gateway->>orchestrator: forward (Livepeer-signed) + orchestrator->>image_upscale: POST /predict {"image":""} + image_upscale-->>orchestrator: {"image":"","width":W,"height":H} + orchestrator-->>gateway: response + gateway-->>curl: response +``` + +Four compose services: + +| Service | What it is | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `gateway`, `orchestrator` | `livepeer/go-livepeer:master` from Docker Hub | +| `image_upscale` | The pipeline container — a [BYOC](https://github.com/livepeer/go-livepeer/blob/main/doc/byoc.md) capability built with `livepeer_gateway.runner`. | +| `register_capability` | One-shot helper that POSTs to `orchestrator:8935/capability/register` once `image_upscale` is healthy | + +The pipeline service has a healthcheck that probes `GET /health` until the +model finishes loading. `register_capability` waits on `service_healthy`, so +the orchestrator never sees a "registered but not loaded" container. + +## Binary I/O contract + +Both `image` fields use Pydantic's `Base64Bytes`: + +- **Input** — `image` is a base64-encoded string in the JSON body. Pydantic + decodes to `bytes` before `predict()` runs. +- **Output** — `image` is `bytes` in the pipeline; Pydantic encodes back to + base64 in the JSON response. + +`width` and `height` are returned alongside for convenience. The pipeline +always emits PNG; document the format in the field description if you need +to surface it to callers. + +## Try with your own image + +```bash +TEST_IMAGE=/path/to/your.png \ +INPUT_WIDTH=$W INPUT_HEIGHT=$H \ +./test.sh +``` + +The test asserts output is at least 2x input dimensions. + +Or manually: + +```bash +INPUT_B64=$(base64 -w0 < your.png) + +LIVEPEER_HDR=$(printf '%s' \ + '{"request":"{}","parameters":"{}","capability":"image-upscale","timeout_seconds":60}' \ + | base64 -w0) + +curl -X POST http://localhost:9935/process/request/predict \ + -H "Livepeer: ${LIVEPEER_HDR}" \ + -H "Content-Type: application/json" \ + -d "{\"image\":\"${INPUT_B64}\"}" \ + | jq -r '.image' | base64 -d > upscaled.png +``` diff --git a/examples/runner/image_upscale/docker-compose.yml b/examples/runner/image_upscale/docker-compose.yml new file mode 100644 index 0000000..719d0d7 --- /dev/null +++ b/examples/runner/image_upscale/docker-compose.yml @@ -0,0 +1,79 @@ +services: + # Mirrors go-livepeer/doc/byoc.md: on-chain mode against a public Arbitrum + # RPC, but `pricePerUnit 0` keeps the registration free — no balance ever + # leaves the auto-generated keystore. No real chain interaction occurs. + # + # TODO: once livepeer/go-livepeer#3906 ships in :master, drop -network / + # -ethUrl / -ethPassword and run with bare `-network offchain`. Tracked + # in livepeer/go-livepeer#3905. + + orchestrator: + image: livepeer/go-livepeer:master + container_name: orchestrator + command: > + -network arbitrum-one-mainnet + -orchestrator + -ethUrl https://arb1.arbitrum.io/rpc + -ethPassword secret-password + -pricePerUnit 1 + -serviceAddr=orchestrator:8935 + -orchSecret=orch-secret + -v 6 + + gateway: + image: livepeer/go-livepeer:master + container_name: gateway + command: > + -network arbitrum-one-mainnet + -gateway + -ethUrl https://arb1.arbitrum.io/rpc + -ethPassword secret-password + -orchAddr=orchestrator:8935 + -httpAddr=0.0.0.0:9935 + -httpIngest + -v 6 + ports: + - "9935:9935" + depends_on: + - orchestrator + + image_upscale: + build: + context: ../../.. + dockerfile: examples/runner/image_upscale/Dockerfile + container_name: image_upscale + ports: + - "5000:5000" + # Healthcheck waits for setup() to finish loading the model before the + # orchestrator can route requests here. Without this, register_capability + # could complete and the test could fire before the model is loaded. + healthcheck: + test: ["CMD", "python", "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:5000/health').read()"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 60s + depends_on: + - orchestrator + + register_capability: + image: python:3.11-slim + container_name: register_capability + command: sh -c "pip install --quiet requests && python /app/register_capability.py" + volumes: + - ./register_capability.py:/app/register_capability.py:ro + environment: + ORCH_URL: https://orchestrator:8935 + ORCH_SECRET: orch-secret + CAPABILITY_NAME: image-upscale + CAPABILITY_URL: http://image_upscale:5000 + depends_on: + image_upscale: + condition: service_healthy + orchestrator: + condition: service_started + +networks: + default: + name: livepeer diff --git a/examples/runner/image_upscale/pipeline.py b/examples/runner/image_upscale/pipeline.py new file mode 100644 index 0000000..e3e3454 --- /dev/null +++ b/examples/runner/image_upscale/pipeline.py @@ -0,0 +1,54 @@ +"""Image upscale BYOC pipeline. Run via ``docker compose up`` — see README.md.""" + +import base64 +import io + +import numpy as np +import torch +from PIL import Image +from pydantic import Base64Bytes, BaseModel, Field +from transformers import Swin2SRForImageSuperResolution, Swin2SRImageProcessor + +from livepeer_gateway.runner import Pipeline, serve + + +class UpscaleInput(BaseModel): + image: Base64Bytes = Field(description="Source image, base64-encoded PNG/JPEG") + + +class UpscaleOutput(BaseModel): + image: str = Field(description="Upscaled image, base64-encoded PNG") + width: int + height: int + + +class ImageUpscaler(Pipeline): + def setup(self): + # Loads from local HF cache populated at Docker build time. + model_id = "caidas/swin2SR-classical-sr-x2-64" + self.processor = Swin2SRImageProcessor.from_pretrained(model_id) + self.model = Swin2SRForImageSuperResolution.from_pretrained(model_id) + + def predict(self, params: UpscaleInput) -> UpscaleOutput: + src = Image.open(io.BytesIO(params.image)).convert("RGB") + + inputs = self.processor(images=src, return_tensors="pt") + with torch.no_grad(): + outputs = self.model(**inputs) + + # CHW float [0, 1] → HWC uint8 + chw = outputs.reconstruction.squeeze().clamp(0, 1).cpu().numpy() + hwc = np.moveaxis(chw, 0, -1) + upscaled = Image.fromarray((hwc * 255.0).round().astype(np.uint8)) + + buf = io.BytesIO() + upscaled.save(buf, format="PNG") + return UpscaleOutput( + image=base64.b64encode(buf.getvalue()).decode(), + width=upscaled.width, + height=upscaled.height, + ) + + +if __name__ == "__main__": + serve(ImageUpscaler()) diff --git a/examples/runner/image_upscale/prepare_models.py b/examples/runner/image_upscale/prepare_models.py new file mode 100644 index 0000000..f0c8ca7 --- /dev/null +++ b/examples/runner/image_upscale/prepare_models.py @@ -0,0 +1,11 @@ +"""Download model weights into the local HF cache at build time. + +Invoked by the Dockerfile so ``setup()`` loads from local disk in +milliseconds instead of pulling from HF Hub on every container start. +""" + +from transformers import Swin2SRForImageSuperResolution, Swin2SRImageProcessor + +model_id = "caidas/swin2SR-classical-sr-x2-64" +Swin2SRImageProcessor.from_pretrained(model_id) +Swin2SRForImageSuperResolution.from_pretrained(model_id) diff --git a/examples/runner/image_upscale/register_capability.py b/examples/runner/image_upscale/register_capability.py new file mode 100644 index 0000000..bf3f320 --- /dev/null +++ b/examples/runner/image_upscale/register_capability.py @@ -0,0 +1,52 @@ +"""Register the image-upscale capability with the orchestrator. + +Wire format follows +https://github.com/livepeer/go-livepeer/blob/main/doc/byoc.md +(handler in ``byoc/job_orchestrator.go``). +""" + +import os +import sys +import time + +import requests +import urllib3 + +# Orchestrator's HTTPS endpoint uses a self-signed cert. +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +ORCH_URL = os.environ.get("ORCH_URL", "https://orchestrator:8935") +ORCH_SECRET = os.environ.get("ORCH_SECRET", "orch-secret") +CAPABILITY_NAME = os.environ.get("CAPABILITY_NAME", "image-upscale") +CAPABILITY_URL = os.environ.get("CAPABILITY_URL", "http://image_upscale:5000") +MAX_ATTEMPTS = int(os.environ.get("MAX_ATTEMPTS", "30")) + +data = { + "name": CAPABILITY_NAME, + "url": CAPABILITY_URL, + "capacity": 1, + "price_per_unit": 0, + "price_scaling": 1, + "currency": "wei", +} +headers = {"Authorization": ORCH_SECRET} + +for attempt in range(1, MAX_ATTEMPTS + 1): + try: + r = requests.post( + f"{ORCH_URL}/capability/register", + json=data, + headers=headers, + verify=False, + timeout=5, + ) + if r.status_code == 200: + print(f"registered {CAPABILITY_NAME} -> {CAPABILITY_URL}") + sys.exit(0) + print(f"attempt {attempt}: status={r.status_code} body={r.text!r}") + except Exception as exc: + print(f"attempt {attempt}: {exc}") + time.sleep(2) + +print("registration failed after timeout") +sys.exit(1) diff --git a/examples/runner/image_upscale/requirements.txt b/examples/runner/image_upscale/requirements.txt new file mode 100644 index 0000000..80fa612 --- /dev/null +++ b/examples/runner/image_upscale/requirements.txt @@ -0,0 +1,6 @@ +--extra-index-url https://download.pytorch.org/whl/cpu + +transformers +torch +Pillow +numpy diff --git a/examples/runner/image_upscale/test.sh b/examples/runner/image_upscale/test.sh new file mode 100755 index 0000000..4d11db3 --- /dev/null +++ b/examples/runner/image_upscale/test.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# E2E: send a request through the gateway, assert the upscaled image +# (2x of the 32x32 fixture = 64x64) comes back through the orchestrator. + +set -euo pipefail +cd "$(dirname "$0")" + +GATEWAY_URL="${GATEWAY_URL:-http://localhost:9935}" +TEST_IMAGE="${TEST_IMAGE:-test_image.png}" +INPUT_WIDTH="${INPUT_WIDTH:-64}" +INPUT_HEIGHT="${INPUT_HEIGHT:-64}" + +echo "Waiting for capability registration..." +for i in $(seq 1 60); do + if docker logs register_capability 2>&1 | grep -q "registered image-upscale"; then + echo " registered." + break + fi + sleep 2 +done + +INPUT_B64=$(base64 -w0 < "${TEST_IMAGE}") + +# TODO: swap curl for a livepeer_gateway batch caller (post PR #6) — drops +# the gateway service from compose. +LIVEPEER_HDR=$(printf '%s' '{"request":"{}","parameters":"{}","capability":"image-upscale","timeout_seconds":60}' | base64 -w0) + +echo "Sending request through gateway..." +RESPONSE=$(curl -fsS -X POST "${GATEWAY_URL}/process/request/predict" \ + -H "Livepeer: ${LIVEPEER_HDR}" \ + -H "Content-Type: application/json" \ + -d "{\"image\":\"${INPUT_B64}\"}") + +# Trim the base64 image from the echoed response — keeps stdout readable. +echo "Response (image truncated): $(echo "${RESPONSE}" | sed 's/\("image":"\)[^"]*/\1/')" + +WIDTH=$(echo "${RESPONSE}" | grep -oE '"width"[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+$') +HEIGHT=$(echo "${RESPONSE}" | grep -oE '"height"[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+$') + +# The Swin2SR processor pads inputs to its window size before upscaling, so +# output is at least 2x input but may be slightly larger. +if [ "${WIDTH}" -ge $((INPUT_WIDTH * 2)) ] && [ "${HEIGHT}" -ge $((INPUT_HEIGHT * 2)) ]; then + echo "PASS (${WIDTH}x${HEIGHT}, >=2x of ${INPUT_WIDTH}x${INPUT_HEIGHT})" + exit 0 +fi + +echo "FAIL: expected >=${INPUT_WIDTH}x${INPUT_HEIGHT} doubled, got ${WIDTH}x${HEIGHT}" +exit 1 diff --git a/examples/runner/image_upscale/test_image.png b/examples/runner/image_upscale/test_image.png new file mode 100644 index 0000000000000000000000000000000000000000..c419e41ee12be309a9d1a2cf4be7300a72d0bf1c GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGJMKu;IPkcwMx&mZJuP~c(MxVhy2 u%(;i!1x_$oUNy77y|SN^gA}l*oN;}C(ihI Date: Wed, 29 Apr 2026 21:09:50 +0200 Subject: [PATCH 09/21] feat(runner): add /health state machine (LOADING/OK/ERROR/IDLE) Pipeline tracks state across setup() and exposes it via /health, matching go-livepeer's HealthCheck wire format (ai/worker/runner.gen.go). Re-raises on setup() failure so the container still exits fail-fast. Refs livepeer/livepeer-python-gateway#8 (C6) --- src/livepeer_gateway/runner/__init__.py | 4 ++-- src/livepeer_gateway/runner/pipeline.py | 12 +++++++++++ src/livepeer_gateway/runner/serve.py | 27 ++++++++++++++++++------- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/livepeer_gateway/runner/__init__.py b/src/livepeer_gateway/runner/__init__.py index 2532771..4b55f50 100644 --- a/src/livepeer_gateway/runner/__init__.py +++ b/src/livepeer_gateway/runner/__init__.py @@ -1,7 +1,7 @@ """Pipeline SDK for creating BYOC-compatible AI capabilities from a simple Python class. """ -from .pipeline import Pipeline +from .pipeline import Pipeline, PipelineState from .serve import make_app, serve -__all__ = ["Pipeline", "make_app", "serve"] +__all__ = ["Pipeline", "PipelineState", "make_app", "serve"] diff --git a/src/livepeer_gateway/runner/pipeline.py b/src/livepeer_gateway/runner/pipeline.py index fbf810f..cc00aea 100644 --- a/src/livepeer_gateway/runner/pipeline.py +++ b/src/livepeer_gateway/runner/pipeline.py @@ -1,12 +1,24 @@ from __future__ import annotations from abc import ABC, abstractmethod +from enum import Enum from typing import Any +class PipelineState(str, Enum): + """Wire-level health state — matches go-livepeer's HealthCheckStatus.""" + + LOADING = "LOADING" + OK = "OK" + ERROR = "ERROR" + IDLE = "IDLE" + + class Pipeline(ABC): """Base class for batch inference pipelines.""" + _state: PipelineState = PipelineState.LOADING + def setup(self) -> None: """Hook called once before serve() accepts requests. diff --git a/src/livepeer_gateway/runner/serve.py b/src/livepeer_gateway/runner/serve.py index dfa9c91..1ebe889 100644 --- a/src/livepeer_gateway/runner/serve.py +++ b/src/livepeer_gateway/runner/serve.py @@ -6,9 +6,15 @@ from fastapi import FastAPI, HTTPException from pydantic import BaseModel, create_model -from .pipeline import Pipeline +from .pipeline import Pipeline, PipelineState -_LOG = logging.getLogger(__name__) +logger = logging.getLogger(__name__) + + +class HealthResponse(BaseModel): + """Response shape for ``GET /health`` — matches go-livepeer's HealthCheck.""" + + status: PipelineState def _is_basemodel(t: Any) -> bool: @@ -50,7 +56,7 @@ def handler(body: InputModel): except HTTPException: raise except Exception: - _LOG.exception("predict() failed") + logger.exception("predict() failed") raise HTTPException(status_code=500, detail="internal error") if OutputModel is not None: @@ -60,7 +66,14 @@ def handler(body: InputModel): def make_app(pipeline: Pipeline) -> FastAPI: """Build a FastAPI app exposing ``pipeline`` over HTTP.""" - pipeline.setup() + pipeline._state = PipelineState.LOADING + try: + pipeline.setup() + pipeline._state = PipelineState.OK + except Exception: + pipeline._state = PipelineState.ERROR + logger.exception("setup() failed") + raise InputModel, explicit_basemodel = _build_input_model( pipeline.predict, type(pipeline).__name__ @@ -80,9 +93,9 @@ def make_app(pipeline: Pipeline) -> FastAPI: summary="Run one inference", ) - @app.get("/health", summary="Liveness probe") - def handle_health() -> dict: - return {"status": "ready"} + @app.get("/health", summary="Liveness probe", response_model=HealthResponse) + def handle_health() -> HealthResponse: + return HealthResponse(status=pipeline._state) return app From d2c43f636be8531f281690b2f7488baed632633a Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 30 Apr 2026 10:11:09 +0200 Subject: [PATCH 10/21] feat(runner,examples): SSE auto-detection + LLM chat example When predict() is a generator, the SDK wraps the response with StreamingResponse(text/event-stream) and frames each yielded value as an OpenAI-style SSE event terminated by [DONE]. Both go-livepeer's BYOC gateway and the Python caller-side gateway watch for [DONE] to end the stream. Co-authored-by: John | Elite Encoder --- examples/runner/llm/Dockerfile | 26 +++++++ examples/runner/llm/README.md | 91 ++++++++++++++++++++++ examples/runner/llm/docker-compose.yml | 78 +++++++++++++++++++ examples/runner/llm/pipeline.py | 62 +++++++++++++++ examples/runner/llm/prepare_models.py | 11 +++ examples/runner/llm/register_capability.py | 52 +++++++++++++ examples/runner/llm/requirements.txt | 4 + examples/runner/llm/test.sh | 42 ++++++++++ src/livepeer_gateway/runner/serve.py | 37 +++++++-- 9 files changed, 398 insertions(+), 5 deletions(-) create mode 100644 examples/runner/llm/Dockerfile create mode 100644 examples/runner/llm/README.md create mode 100644 examples/runner/llm/docker-compose.yml create mode 100644 examples/runner/llm/pipeline.py create mode 100644 examples/runner/llm/prepare_models.py create mode 100644 examples/runner/llm/register_capability.py create mode 100644 examples/runner/llm/requirements.txt create mode 100755 examples/runner/llm/test.sh diff --git a/examples/runner/llm/Dockerfile b/examples/runner/llm/Dockerfile new file mode 100644 index 0000000..4645671 --- /dev/null +++ b/examples/runner/llm/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.11-slim + +WORKDIR /app + +# SDK install (in-repo source until livepeer-gateway publishes; will collapse +# to a single `pip install livepeer-gateway` line once on PyPI). +COPY pyproject.toml README ./ +COPY src /app/src +RUN pip install --no-cache-dir /app + +# Pipeline-specific deps. The requirements.txt sets --extra-index-url to +# pull the CPU-only torch wheel (~200 MB vs ~5 GB for the CUDA variant). +COPY examples/runner/llm/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Bake model weights at build time so setup() loads from local disk in +# milliseconds. +COPY examples/runner/llm/prepare_models.py /app/prepare_models.py +RUN python /app/prepare_models.py + +# Pipeline code last so edits don't invalidate the bake layer above. +COPY examples/runner/llm/pipeline.py /app/pipeline.py + +EXPOSE 5000 + +CMD ["python", "/app/pipeline.py"] diff --git a/examples/runner/llm/README.md b/examples/runner/llm/README.md new file mode 100644 index 0000000..55a86cc --- /dev/null +++ b/examples/runner/llm/README.md @@ -0,0 +1,91 @@ +# LLM chat (BYOC, streaming) + +A streaming chat capability built on +[Qwen2.5-0.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct) — +small enough to run on CPU. Demonstrates the SDK's SSE pattern: `predict()` +returns an iterator, the SDK detects the generator and frames each yielded +value as a Server-Sent Event. + +A `Pipeline` subclass loads the model once in `setup()`, then streams tokens +on each `POST /predict` via HuggingFace's `TextIteratorStreamer`. Registered +as a BYOC capability, called through the gateway, response flows back end-to-end. + +## Run + +```bash +docker compose up -d --wait --build +./test.sh +docker compose down +``` + +`test.sh` prints `PASS` on success. + +## Browse the API + +- Swagger UI: +- ReDoc: +- OpenAPI JSON: + +## What's running + +```mermaid +sequenceDiagram + autonumber + participant curl + participant gateway + participant orchestrator + participant llm as llm
(SDK container) + + curl->>gateway: POST /process/request/predict + gateway->>orchestrator: forward (Livepeer-signed) + orchestrator->>llm: POST /predict {"prompt":"..."} + loop each token + llm-->>orchestrator: data: {"token":"..."} + orchestrator-->>gateway: data: {"token":"..."} + gateway-->>curl: data: {"token":"..."} + end + llm-->>orchestrator: data: [DONE] + orchestrator-->>gateway: data: [DONE] + gateway-->>curl: data: [DONE] +``` + +Four compose services: + +| Service | What it is | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `gateway`, `orchestrator` | `livepeer/go-livepeer:master` from Docker Hub | +| `llm` | The pipeline container — runs the model in-process, streams tokens via `TextIteratorStreamer` | +| `register_capability` | One-shot helper that registers the `llm` capability once the pipeline is healthy | + +## Streaming contract + +`predict()` returns `Iterator[ChatChunk]`. The SDK detects the generator and +wraps the response with `Content-Type: text/event-stream`. Each yielded +`ChatChunk` becomes an SSE event, terminated by `[DONE]`: + +```text +data: {"token": "Hello"} + +data: {"token": " world"} + +data: [DONE] + +``` + +Both go-livepeer and the Python caller-side gateway watch for `[DONE]` to +end the stream. + +## Try it yourself + +```bash +LIVEPEER_HDR=$(printf '%s' \ + '{"request":"{}","parameters":"{}","capability":"llm","timeout_seconds":120}' \ + | base64 -w0) + +curl -N -X POST http://localhost:9935/process/request/predict \ + -H "Livepeer: ${LIVEPEER_HDR}" \ + -H 'Content-Type: application/json' \ + -d '{"prompt":"Tell me a joke"}' +``` + +`-N` disables curl's output buffering so each token arrives as it's generated. diff --git a/examples/runner/llm/docker-compose.yml b/examples/runner/llm/docker-compose.yml new file mode 100644 index 0000000..f182c63 --- /dev/null +++ b/examples/runner/llm/docker-compose.yml @@ -0,0 +1,78 @@ +services: + # Mirrors go-livepeer/doc/byoc.md: on-chain mode against a public Arbitrum + # RPC, but `pricePerUnit 0` keeps the registration free — no balance ever + # leaves the auto-generated keystore. No real chain interaction occurs. + # + # TODO: once livepeer/go-livepeer#3906 ships in :master, drop -network / + # -ethUrl / -ethPassword and run with bare `-network offchain`. Tracked + # in livepeer/go-livepeer#3905. + + orchestrator: + image: livepeer/go-livepeer:master + container_name: orchestrator + command: > + -network arbitrum-one-mainnet + -orchestrator + -ethUrl https://arb1.arbitrum.io/rpc + -ethPassword secret-password + -pricePerUnit 1 + -serviceAddr=orchestrator:8935 + -orchSecret=orch-secret + -v 6 + + gateway: + image: livepeer/go-livepeer:master + container_name: gateway + command: > + -network arbitrum-one-mainnet + -gateway + -ethUrl https://arb1.arbitrum.io/rpc + -ethPassword secret-password + -orchAddr=orchestrator:8935 + -httpAddr=0.0.0.0:9935 + -httpIngest + -v 6 + ports: + - "9935:9935" + depends_on: + - orchestrator + + llm: + build: + context: ../../.. + dockerfile: examples/runner/llm/Dockerfile + container_name: llm + ports: + - "5000:5000" + # Healthcheck waits for setup() to finish loading the model before the + # orchestrator can route requests here. + healthcheck: + test: ["CMD", "python", "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:5000/health').read()"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 60s + depends_on: + - orchestrator + + register_capability: + image: python:3.11-slim + container_name: register_capability + command: sh -c "pip install --quiet requests && python /app/register_capability.py" + volumes: + - ./register_capability.py:/app/register_capability.py:ro + environment: + ORCH_URL: https://orchestrator:8935 + ORCH_SECRET: orch-secret + CAPABILITY_NAME: llm + CAPABILITY_URL: http://llm:5000 + depends_on: + llm: + condition: service_healthy + orchestrator: + condition: service_started + +networks: + default: + name: livepeer diff --git a/examples/runner/llm/pipeline.py b/examples/runner/llm/pipeline.py new file mode 100644 index 0000000..7d05006 --- /dev/null +++ b/examples/runner/llm/pipeline.py @@ -0,0 +1,62 @@ +"""LLM chat BYOC pipeline using HuggingFace transformers. Streams tokens via SSE.""" + +from threading import Thread +from typing import Iterator + +from pydantic import BaseModel, Field +from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer + +from livepeer_gateway.runner import Pipeline, serve + + +class ChatInput(BaseModel): + prompt: str = Field(description="User message") + system: str = Field(default="You are a helpful assistant.", description="System prompt") + max_tokens: int = Field(default=256, ge=1, le=1024, description="Max tokens to generate") + + +class ChatChunk(BaseModel): + token: str + + +class ChatPipeline(Pipeline): + def setup(self): + # Loads from local HF cache populated at Docker build time. + model_id = "Qwen/Qwen2.5-0.5B-Instruct" + self.tokenizer = AutoTokenizer.from_pretrained(model_id) + self.model = AutoModelForCausalLM.from_pretrained(model_id) + + def predict(self, params: ChatInput) -> Iterator[ChatChunk]: + messages = [ + {"role": "system", "content": params.system}, + {"role": "user", "content": params.prompt}, + ] + # Two-step (template → text → tokenize) avoids a BatchEncoding + # wrapper that model.generate() can't unpack. + text = self.tokenizer.apply_chat_template( + messages, tokenize=False, add_generation_prompt=True + ) + inputs = self.tokenizer(text, return_tensors="pt") + + # TextIteratorStreamer feeds tokens into a thread-safe queue as the + # model generates them. We drive .generate() in a background thread + # so this generator can yield chunks as they arrive. + streamer = TextIteratorStreamer( + self.tokenizer, skip_prompt=True, skip_special_tokens=True + ) + Thread( + target=self.model.generate, + kwargs={ + **inputs, + "max_new_tokens": params.max_tokens, + "streamer": streamer, + }, + ).start() + + for chunk in streamer: + if chunk: + yield ChatChunk(token=chunk) + + +if __name__ == "__main__": + serve(ChatPipeline()) diff --git a/examples/runner/llm/prepare_models.py b/examples/runner/llm/prepare_models.py new file mode 100644 index 0000000..28b4292 --- /dev/null +++ b/examples/runner/llm/prepare_models.py @@ -0,0 +1,11 @@ +"""Download model weights into the local HF cache at build time. + +Invoked by the Dockerfile so ``setup()`` loads from local disk in +milliseconds instead of pulling from HF Hub on every container start. +""" + +from transformers import AutoModelForCausalLM, AutoTokenizer + +model_id = "Qwen/Qwen2.5-0.5B-Instruct" +AutoTokenizer.from_pretrained(model_id) +AutoModelForCausalLM.from_pretrained(model_id) diff --git a/examples/runner/llm/register_capability.py b/examples/runner/llm/register_capability.py new file mode 100644 index 0000000..3fb4d22 --- /dev/null +++ b/examples/runner/llm/register_capability.py @@ -0,0 +1,52 @@ +"""Register the llm capability with the orchestrator. + +Wire format follows +https://github.com/livepeer/go-livepeer/blob/main/doc/byoc.md +(handler in ``byoc/job_orchestrator.go``). +""" + +import os +import sys +import time + +import requests +import urllib3 + +# Orchestrator's HTTPS endpoint uses a self-signed cert. +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +ORCH_URL = os.environ.get("ORCH_URL", "https://orchestrator:8935") +ORCH_SECRET = os.environ.get("ORCH_SECRET", "orch-secret") +CAPABILITY_NAME = os.environ.get("CAPABILITY_NAME", "llm") +CAPABILITY_URL = os.environ.get("CAPABILITY_URL", "http://llm:5000") +MAX_ATTEMPTS = int(os.environ.get("MAX_ATTEMPTS", "30")) + +data = { + "name": CAPABILITY_NAME, + "url": CAPABILITY_URL, + "capacity": 1, + "price_per_unit": 0, + "price_scaling": 1, + "currency": "wei", +} +headers = {"Authorization": ORCH_SECRET} + +for attempt in range(1, MAX_ATTEMPTS + 1): + try: + r = requests.post( + f"{ORCH_URL}/capability/register", + json=data, + headers=headers, + verify=False, + timeout=5, + ) + if r.status_code == 200: + print(f"registered {CAPABILITY_NAME} -> {CAPABILITY_URL}") + sys.exit(0) + print(f"attempt {attempt}: status={r.status_code} body={r.text!r}") + except Exception as exc: + print(f"attempt {attempt}: {exc}") + time.sleep(2) + +print("registration failed after timeout") +sys.exit(1) diff --git a/examples/runner/llm/requirements.txt b/examples/runner/llm/requirements.txt new file mode 100644 index 0000000..f13a349 --- /dev/null +++ b/examples/runner/llm/requirements.txt @@ -0,0 +1,4 @@ +--extra-index-url https://download.pytorch.org/whl/cpu + +transformers +torch diff --git a/examples/runner/llm/test.sh b/examples/runner/llm/test.sh new file mode 100755 index 0000000..d7b11c2 --- /dev/null +++ b/examples/runner/llm/test.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# E2E: send a chat request through the gateway, assert the LLM streams +# tokens back via SSE and terminates with [DONE]. + +set -euo pipefail +cd "$(dirname "$0")" + +GATEWAY_URL="${GATEWAY_URL:-http://localhost:9935}" +PROMPT="${PROMPT:-Say hello in three words}" + +echo "Waiting for capability registration..." +for i in $(seq 1 60); do + if docker logs register_capability 2>&1 | grep -q "registered llm"; then + echo " registered." + break + fi + sleep 2 +done + +# TODO: swap curl for a livepeer_gateway batch caller (post PR #6) — drops +# the gateway service from compose. +LIVEPEER_HDR=$(printf '%s' '{"request":"{}","parameters":"{}","capability":"llm","timeout_seconds":120}' | base64 -w0) + +echo "Sending chat request through gateway (streaming)..." +# -N disables curl output buffering so chunks arrive as they're generated. +RESPONSE=$(curl -fsSN -X POST "${GATEWAY_URL}/process/request/predict" \ + -H "Livepeer: ${LIVEPEER_HDR}" \ + -H "Content-Type: application/json" \ + -d "{\"prompt\":\"${PROMPT}\"}") + +echo "Response (first events):" +echo "${RESPONSE}" | head -10 + +# Verify SSE format: at least one token event + the [DONE] terminator. +if echo "${RESPONSE}" | grep -q '^data: {"token":' \ + && echo "${RESPONSE}" | grep -q '^data: \[DONE\]'; then + echo "PASS" + exit 0 +fi + +echo "FAIL: expected token events and [DONE] terminator" +exit 1 diff --git a/src/livepeer_gateway/runner/serve.py b/src/livepeer_gateway/runner/serve.py index 1ebe889..4f05f3b 100644 --- a/src/livepeer_gateway/runner/serve.py +++ b/src/livepeer_gateway/runner/serve.py @@ -1,9 +1,11 @@ import inspect +import json import logging -from typing import Any +from typing import Any, Iterator import uvicorn from fastapi import FastAPI, HTTPException +from fastapi.responses import StreamingResponse from pydantic import BaseModel, create_model from .pipeline import Pipeline, PipelineState @@ -42,24 +44,45 @@ def _build_input_model(predict_fn: Any, owner_name: str) -> tuple[type[BaseModel return create_model(f"{owner_name}Input", **fields), False +def _format_sse(generator: Iterator[Any]) -> Iterator[bytes]: + """Frame yielded values as SSE events with [DONE] terminator. + + Required by go-livepeer and the Python caller-side gateway. + """ + try: + for chunk in generator: + payload = chunk.model_dump_json() if isinstance(chunk, BaseModel) else json.dumps(chunk) + yield f"data: {payload}\n\n".encode() + except Exception: + logger.exception("predict() generator failed") + yield b'data: {"error": "internal error"}\n\n' + yield b"data: [DONE]\n\n" + + def _build_predict_handler( pipeline: Pipeline, InputModel: type[BaseModel], OutputModel: type[BaseModel] | None, explicit_basemodel: bool, + is_generator: bool, ): def handler(body: InputModel): try: if explicit_basemodel: - return pipeline.predict(body) - return pipeline.predict(**body.model_dump()) + result = pipeline.predict(body) + else: + result = pipeline.predict(**body.model_dump()) except HTTPException: raise except Exception: logger.exception("predict() failed") raise HTTPException(status_code=500, detail="internal error") - if OutputModel is not None: + if is_generator: + return StreamingResponse(_format_sse(result), media_type="text/event-stream") + return result + + if OutputModel is not None and not is_generator: handler.__annotations__["return"] = OutputModel return handler @@ -75,13 +98,17 @@ def make_app(pipeline: Pipeline) -> FastAPI: logger.exception("setup() failed") raise + is_generator = inspect.isgeneratorfunction(pipeline.predict) + InputModel, explicit_basemodel = _build_input_model( pipeline.predict, type(pipeline).__name__ ) return_annotation = inspect.signature(pipeline.predict).return_annotation OutputModel = return_annotation if _is_basemodel(return_annotation) else None - handler = _build_predict_handler(pipeline, InputModel, OutputModel, explicit_basemodel) + handler = _build_predict_handler( + pipeline, InputModel, OutputModel, explicit_basemodel, is_generator + ) app = FastAPI(title=type(pipeline).__name__) app.state.pipeline = pipeline From 9c920ff1e0865a40223e05df2d15b4ee8482a0d2 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 30 Apr 2026 10:26:22 +0200 Subject: [PATCH 11/21] chore(examples): pricePerUnit=0 + tighten READMEs pricePerUnit=0 means no orchestrator charges, no ticket settlement, empty wallet stays unused. Replaces the previous pricePerUnit=1 workaround that relied on tickets rarely firing. --- examples/runner/hello_world/docker-compose.yml | 2 +- examples/runner/image_upscale/README.md | 4 ++-- examples/runner/image_upscale/docker-compose.yml | 2 +- examples/runner/llm/README.md | 3 +++ examples/runner/llm/docker-compose.yml | 2 +- examples/runner/sentiment/README.md | 4 ++-- examples/runner/sentiment/docker-compose.yml | 2 +- 7 files changed, 11 insertions(+), 8 deletions(-) diff --git a/examples/runner/hello_world/docker-compose.yml b/examples/runner/hello_world/docker-compose.yml index 5cd238a..6b6e3d7 100644 --- a/examples/runner/hello_world/docker-compose.yml +++ b/examples/runner/hello_world/docker-compose.yml @@ -15,7 +15,7 @@ services: -orchestrator -ethUrl https://arb1.arbitrum.io/rpc -ethPassword secret-password - -pricePerUnit 1 + -pricePerUnit 0 -serviceAddr=orchestrator:8935 -orchSecret=orch-secret -v 6 diff --git a/examples/runner/image_upscale/README.md b/examples/runner/image_upscale/README.md index 47e331d..f0f8e83 100644 --- a/examples/runner/image_upscale/README.md +++ b/examples/runner/image_upscale/README.md @@ -22,8 +22,8 @@ docker compose down `test.sh` prints `PASS` on success. -> **First build is ~5 minutes** — pulls torch CPU (~200 MB), transformers, and -> bakes the ~70 MB Swin2SR model into the image. Cached after that. +`prepare_models.py` bakes the model into the image at build time so +`setup()` loads from local cache in milliseconds. ## Browse the API diff --git a/examples/runner/image_upscale/docker-compose.yml b/examples/runner/image_upscale/docker-compose.yml index 719d0d7..9a4ce48 100644 --- a/examples/runner/image_upscale/docker-compose.yml +++ b/examples/runner/image_upscale/docker-compose.yml @@ -15,7 +15,7 @@ services: -orchestrator -ethUrl https://arb1.arbitrum.io/rpc -ethPassword secret-password - -pricePerUnit 1 + -pricePerUnit 0 -serviceAddr=orchestrator:8935 -orchSecret=orch-secret -v 6 diff --git a/examples/runner/llm/README.md b/examples/runner/llm/README.md index 55a86cc..9cda969 100644 --- a/examples/runner/llm/README.md +++ b/examples/runner/llm/README.md @@ -20,6 +20,9 @@ docker compose down `test.sh` prints `PASS` on success. +`prepare_models.py` bakes the model into the image at build time so +`setup()` loads from local cache in milliseconds. + ## Browse the API - Swagger UI: diff --git a/examples/runner/llm/docker-compose.yml b/examples/runner/llm/docker-compose.yml index f182c63..55cd7c5 100644 --- a/examples/runner/llm/docker-compose.yml +++ b/examples/runner/llm/docker-compose.yml @@ -15,7 +15,7 @@ services: -orchestrator -ethUrl https://arb1.arbitrum.io/rpc -ethPassword secret-password - -pricePerUnit 1 + -pricePerUnit 0 -serviceAddr=orchestrator:8935 -orchSecret=orch-secret -v 6 diff --git a/examples/runner/sentiment/README.md b/examples/runner/sentiment/README.md index 28b7e00..3686a3a 100644 --- a/examples/runner/sentiment/README.md +++ b/examples/runner/sentiment/README.md @@ -18,8 +18,8 @@ docker compose down `test.sh` prints `PASS` on success. -> **First build is ~5 minutes** — pulls torch CPU (~200 MB), transformers, and -> bakes the ~250 MB model into the image. Cached after that; rebuilds are fast. +`prepare_models.py` bakes the model into the image at build time so +`setup()` loads from local cache in milliseconds. ## Browse the API diff --git a/examples/runner/sentiment/docker-compose.yml b/examples/runner/sentiment/docker-compose.yml index 2e62a7f..c474216 100644 --- a/examples/runner/sentiment/docker-compose.yml +++ b/examples/runner/sentiment/docker-compose.yml @@ -15,7 +15,7 @@ services: -orchestrator -ethUrl https://arb1.arbitrum.io/rpc -ethPassword secret-password - -pricePerUnit 1 + -pricePerUnit 0 -serviceAddr=orchestrator:8935 -orchSecret=orch-secret -v 6 From 04cc697b6586d9a88a4b86fa5ce6e95103e68403 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 30 Apr 2026 13:45:28 +0200 Subject: [PATCH 12/21] feat(runner): LivePipeline ABC + /stream/* HTTP skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds LivePipeline base class with setup/on_stream_start/process_video/ process_audio/on_params_update/on_stream_stop hooks (all default-passthrough) plus emit_event/emit_data stubs. Splits make_app dispatch into _make_pipeline_app (Pipeline → /predict) and _make_live_pipeline_app (LivePipeline → /stream/start|stop|params), sharing _run_setup and _add_health_route. Routes accept and validate the orchestrator's wire contract; streaming coordinator (subscribe/publish loops, lifecycle dispatch) lands in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/livepeer_gateway/runner/__init__.py | 3 +- src/livepeer_gateway/runner/live_pipeline.py | 92 ++++++++++++++++++++ src/livepeer_gateway/runner/serve.py | 61 +++++++++++-- 3 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 src/livepeer_gateway/runner/live_pipeline.py diff --git a/src/livepeer_gateway/runner/__init__.py b/src/livepeer_gateway/runner/__init__.py index 4b55f50..20a97db 100644 --- a/src/livepeer_gateway/runner/__init__.py +++ b/src/livepeer_gateway/runner/__init__.py @@ -1,7 +1,8 @@ """Pipeline SDK for creating BYOC-compatible AI capabilities from a simple Python class. """ +from .live_pipeline import LivePipeline from .pipeline import Pipeline, PipelineState from .serve import make_app, serve -__all__ = ["Pipeline", "PipelineState", "make_app", "serve"] +__all__ = ["LivePipeline", "Pipeline", "PipelineState", "make_app", "serve"] diff --git a/src/livepeer_gateway/runner/live_pipeline.py b/src/livepeer_gateway/runner/live_pipeline.py new file mode 100644 index 0000000..a4e88a2 --- /dev/null +++ b/src/livepeer_gateway/runner/live_pipeline.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, ConfigDict, Field + +from .pipeline import PipelineState + +if TYPE_CHECKING: + # Gated to keep PyAV out of the import path for batch Pipeline users. + from ..media_decode import AudioDecodedMediaFrame, VideoDecodedMediaFrame + + +class StreamStartRequest(BaseModel): + """Body of ``POST /stream/start`` — sent by the orchestrator. + + `subscribe_url`, `publish_url`, `data_url` are absent when the orchestrator + has the corresponding `EnableVideoIngress` / `EnableVideoEgress` / + `EnableDataOutput` flag disabled — the runner must tolerate any subset. + """ + + model_config = ConfigDict(extra="allow") + + gateway_request_id: str + control_url: str + events_url: str + subscribe_url: str | None = None + publish_url: str | None = None + data_url: str | None = None + params: dict[str, Any] = Field(default_factory=dict) + + +class StreamParamsRequest(BaseModel): + """Body of ``POST /stream/params`` — passthrough JSON params from the caller.""" + + model_config = ConfigDict(extra="allow") + + +class LivePipeline: + """Base class for real-time A/V pipelines on the BYOC trickle protocol. + + Subclasses override any of the lifecycle / processing hooks below. + A subclass that overrides nothing is a valid passthrough relay. + """ + + _state: PipelineState = PipelineState.LOADING + + def setup(self) -> None: + """Hook called once before serve() accepts requests. + + Sync, container-init time. Override to load model weights, warm up GPUs. + """ + + async def on_stream_start(self, params: dict[str, Any]) -> None: + """Called when a new stream session begins, before the first frame. + + `params` is the initial pipeline params from the caller. + """ + + async def process_video( + self, frame: VideoDecodedMediaFrame + ) -> VideoDecodedMediaFrame: + """Transform one decoded video frame. Default: passthrough.""" + return frame + + async def process_audio( + self, frame: AudioDecodedMediaFrame + ) -> AudioDecodedMediaFrame: + """Transform one decoded audio frame. Default: passthrough.""" + return frame + + async def on_params_update(self, params: dict[str, Any]) -> None: + """Called when the caller posts new params mid-stream.""" + + async def on_stream_stop(self) -> None: + """Called when the stream session ends — for per-session cleanup.""" + + async def emit_event(self, payload: dict[str, Any]) -> None: + """Publish a JSON event on the events trickle channel. + + Bound at session start; calling outside an active session is a no-op. + """ + # TODO: passthrough for now; wire up in next phase. + return None + + async def emit_data(self, payload: dict[str, Any]) -> None: + """Publish a JSON record on the data trickle channel (when enabled). + + Bound at session start; calling outside an active session is a no-op. + """ + # TODO: passthrough for now; wire up in next phase. + return None diff --git a/src/livepeer_gateway/runner/serve.py b/src/livepeer_gateway/runner/serve.py index 4f05f3b..1b90930 100644 --- a/src/livepeer_gateway/runner/serve.py +++ b/src/livepeer_gateway/runner/serve.py @@ -8,6 +8,7 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel, create_model +from .live_pipeline import LivePipeline, StreamParamsRequest, StreamStartRequest from .pipeline import Pipeline, PipelineState logger = logging.getLogger(__name__) @@ -87,8 +88,13 @@ def handler(body: InputModel): return handler -def make_app(pipeline: Pipeline) -> FastAPI: - """Build a FastAPI app exposing ``pipeline`` over HTTP.""" +def _add_health_route(app: FastAPI, pipeline: Pipeline | LivePipeline) -> None: + @app.get("/health", summary="Liveness probe", response_model=HealthResponse) + def handle_health() -> HealthResponse: + return HealthResponse(status=pipeline._state) + + +def _run_setup(pipeline: Pipeline | LivePipeline) -> None: pipeline._state = PipelineState.LOADING try: pipeline.setup() @@ -98,6 +104,37 @@ def make_app(pipeline: Pipeline) -> FastAPI: logger.exception("setup() failed") raise + +def _make_live_pipeline_app(pipeline: LivePipeline) -> FastAPI: + """Build a FastAPI app for a real-time ``LivePipeline``.""" + _run_setup(pipeline) + + app = FastAPI(title=type(pipeline).__name__) + app.state.pipeline = pipeline + + @app.post("/stream/start", summary="Start a stream session") + async def handle_stream_start(body: StreamStartRequest) -> dict[str, Any]: + logger.info("stream/start request_id=%s", body.gateway_request_id) + return {"status": "started", "gateway_request_id": body.gateway_request_id} + + @app.post("/stream/stop", summary="Stop the active stream session") + async def handle_stream_stop() -> dict[str, str]: + logger.info("stream/stop") + return {"status": "stopped"} + + @app.post("/stream/params", summary="Update params on the active stream") + async def handle_stream_params(body: StreamParamsRequest) -> dict[str, str]: + logger.info("stream/params keys=%s", list(body.model_dump().keys())) + return {"status": "ok"} + + _add_health_route(app, pipeline) + return app + + +def _make_pipeline_app(pipeline: Pipeline) -> FastAPI: + """Build a FastAPI app for a request/response ``Pipeline`` (HTTP `/predict`).""" + _run_setup(pipeline) + is_generator = inspect.isgeneratorfunction(pipeline.predict) InputModel, explicit_basemodel = _build_input_model( @@ -120,13 +157,23 @@ def make_app(pipeline: Pipeline) -> FastAPI: summary="Run one inference", ) - @app.get("/health", summary="Liveness probe", response_model=HealthResponse) - def handle_health() -> HealthResponse: - return HealthResponse(status=pipeline._state) - + _add_health_route(app, pipeline) return app -def serve(pipeline: Pipeline, *, host: str = "0.0.0.0", port: int = 5000) -> None: +def make_app(pipeline: Pipeline | LivePipeline) -> FastAPI: + """Build a FastAPI app exposing ``pipeline`` over HTTP. + + Dispatches on `LivePipeline` vs `Pipeline` to register `/stream/*` + or `/predict` respectively. + """ + if isinstance(pipeline, LivePipeline): + return _make_live_pipeline_app(pipeline) + return _make_pipeline_app(pipeline) + + +def serve( + pipeline: Pipeline | LivePipeline, *, host: str = "0.0.0.0", port: int = 5000 +) -> None: """Run the pipeline as an HTTP server on host:port.""" uvicorn.run(make_app(pipeline), host=host, port=port) From 831ee4455a956f5c1d1f67182abe2bd1c06c079a Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 30 Apr 2026 15:38:58 +0200 Subject: [PATCH 13/21] feat(runner): trickle bytes-through on /stream/start|stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds _run_passthrough coroutine that bridges subscribe → publish trickle channels segment-by-segment using the existing TrickleSubscriber and TricklePublisher. /stream/start spawns it as a background task on the LivePipeline; /stream/stop cancels and waits up to 5s for graceful cleanup before returning. Single-session for now (409 on double-start); data-only / event-only streams (no subscribe_url + publish_url) return 400 — both extensions land in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/livepeer_gateway/runner/live_pipeline.py | 36 ++++++++++++++++ src/livepeer_gateway/runner/serve.py | 44 +++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/livepeer_gateway/runner/live_pipeline.py b/src/livepeer_gateway/runner/live_pipeline.py index a4e88a2..123c8b3 100644 --- a/src/livepeer_gateway/runner/live_pipeline.py +++ b/src/livepeer_gateway/runner/live_pipeline.py @@ -1,9 +1,12 @@ from __future__ import annotations +import asyncio from typing import TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict, Field +from ..trickle_publisher import TricklePublisher +from ..trickle_subscriber import TrickleSubscriber from .pipeline import PipelineState if TYPE_CHECKING: @@ -44,6 +47,8 @@ class LivePipeline: """ _state: PipelineState = PipelineState.LOADING + # Single-session for now; multi-session is post-C8 (capacity demand-driven). + _session_task: asyncio.Task[None] | None = None def setup(self) -> None: """Hook called once before serve() accepts requests. @@ -90,3 +95,34 @@ async def emit_data(self, payload: dict[str, Any]) -> None: """ # TODO: passthrough for now; wire up in next phase. return None + + +async def _run_passthrough(subscribe_url: str, publish_url: str) -> None: + """Forward bytes from a subscribe URL to a publish URL, unmodified. + + Each inbound trickle segment becomes one outbound segment (1:1) — never + merged or split, so downstream consumers see the same segment count and + ordering as the upstream sender. Returns when the subscribe channel ends + (orchestrator deletes it → 404 from the trickle server), or when the + task is cancelled / either side raises. + """ + sub = TrickleSubscriber(subscribe_url) + try: + # MIME must match go-livepeer's publish channel (stream_orchestrator.go). + async with TricklePublisher(publish_url, mime_type="video/MP2T") as pub: + while True: + segment = await sub.next() + if segment is None: # EOS — channel ended + return + try: + reader = segment.make_reader() + async with await pub.next() as writer: + while True: + chunk = await reader.read() + if not chunk: + break + await writer.write(chunk) + finally: + await segment.close() + finally: + await sub.close() diff --git a/src/livepeer_gateway/runner/serve.py b/src/livepeer_gateway/runner/serve.py index 1b90930..8bd470d 100644 --- a/src/livepeer_gateway/runner/serve.py +++ b/src/livepeer_gateway/runner/serve.py @@ -1,3 +1,4 @@ +import asyncio import inspect import json import logging @@ -8,9 +9,19 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel, create_model -from .live_pipeline import LivePipeline, StreamParamsRequest, StreamStartRequest +from .live_pipeline import ( + LivePipeline, + StreamParamsRequest, + StreamStartRequest, + _run_passthrough, +) from .pipeline import Pipeline, PipelineState +# Bound the wait for an in-flight session task to terminate after cancel. +# 5s covers the trickle close paths (segment close + session close + queue +# drain). If we exceed it, the task is left to finish in the background. +_STOP_TIMEOUT_S = 5.0 + logger = logging.getLogger(__name__) @@ -115,11 +126,42 @@ def _make_live_pipeline_app(pipeline: LivePipeline) -> FastAPI: @app.post("/stream/start", summary="Start a stream session") async def handle_stream_start(body: StreamStartRequest) -> dict[str, Any]: logger.info("stream/start request_id=%s", body.gateway_request_id) + + if pipeline._session_task and not pipeline._session_task.done(): + # Single-session for now; orchestrator shouldn't double-start. + raise HTTPException(status_code=409, detail="session already active") + + if not (body.subscribe_url and body.publish_url): + # Bytes-through requires both directions; data-only / event-only + # streams land in later phases. + raise HTTPException( + status_code=400, + detail="subscribe_url and publish_url required for video streams", + ) + + pipeline._session_task = asyncio.create_task( + _run_passthrough(body.subscribe_url, body.publish_url), + name=f"live-session-{body.gateway_request_id}", + ) return {"status": "started", "gateway_request_id": body.gateway_request_id} @app.post("/stream/stop", summary="Stop the active stream session") async def handle_stream_stop() -> dict[str, str]: logger.info("stream/stop") + task = pipeline._session_task + pipeline._session_task = None + if task is None or task.done(): + return {"status": "stopped"} + + task.cancel() + try: + await asyncio.wait_for(task, timeout=_STOP_TIMEOUT_S) + except asyncio.TimeoutError: + logger.warning("session task did not terminate within %.1fs", _STOP_TIMEOUT_S) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("session task ended with error") return {"status": "stopped"} @app.post("/stream/params", summary="Update params on the active stream") From 9688f675973ee25a5713c00d0c114274af870145 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Mon, 4 May 2026 09:02:14 +0200 Subject: [PATCH 14/21] feat(runner): frame loop dispatch + runner.frames namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /stream/start dispatches between _run_passthrough (no overrides) and _run_frame_loop (decode → user → encode via existing MediaOutput / MediaPublish). Adds runner.frames re-exporting VideoFrame / AudioFrame as the user-facing namespace. Wires on_stream_start (fired before the frame loop) and on_params_update (per /stream/params). Aligns runner log messages with the wider gateway _LOG style. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/livepeer_gateway/runner/frames.py | 12 ++++ src/livepeer_gateway/runner/live_pipeline.py | 67 ++++++++++++++++++-- src/livepeer_gateway/runner/serve.py | 39 ++++++++---- 3 files changed, 99 insertions(+), 19 deletions(-) create mode 100644 src/livepeer_gateway/runner/frames.py diff --git a/src/livepeer_gateway/runner/frames.py b/src/livepeer_gateway/runner/frames.py new file mode 100644 index 0000000..7835ad3 --- /dev/null +++ b/src/livepeer_gateway/runner/frames.py @@ -0,0 +1,12 @@ +"""User-facing frame types for ``LivePipeline``. + +Importing this submodule pulls in PyAV (via ``media_decode``). Pipeline +authors who override ``process_video`` / ``process_audio`` opt in to that +cost by importing here; ``from livepeer_gateway.runner import LivePipeline`` +alone stays PyAV-free for batch ``Pipeline`` users. +""" + +from ..media_decode import AudioDecodedMediaFrame as AudioFrame +from ..media_decode import VideoDecodedMediaFrame as VideoFrame + +__all__ = ["AudioFrame", "VideoFrame"] diff --git a/src/livepeer_gateway/runner/live_pipeline.py b/src/livepeer_gateway/runner/live_pipeline.py index 123c8b3..4158ed8 100644 --- a/src/livepeer_gateway/runner/live_pipeline.py +++ b/src/livepeer_gateway/runner/live_pipeline.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import logging from typing import TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict, Field @@ -11,7 +12,10 @@ if TYPE_CHECKING: # Gated to keep PyAV out of the import path for batch Pipeline users. - from ..media_decode import AudioDecodedMediaFrame, VideoDecodedMediaFrame + from .frames import AudioFrame, VideoFrame + + +_LOG = logging.getLogger(__name__) class StreamStartRequest(BaseModel): @@ -49,6 +53,7 @@ class LivePipeline: _state: PipelineState = PipelineState.LOADING # Single-session for now; multi-session is post-C8 (capacity demand-driven). _session_task: asyncio.Task[None] | None = None + _session_params: dict[str, Any] | None = None def setup(self) -> None: """Hook called once before serve() accepts requests. @@ -62,15 +67,11 @@ async def on_stream_start(self, params: dict[str, Any]) -> None: `params` is the initial pipeline params from the caller. """ - async def process_video( - self, frame: VideoDecodedMediaFrame - ) -> VideoDecodedMediaFrame: + async def process_video(self, frame: VideoFrame) -> VideoFrame: """Transform one decoded video frame. Default: passthrough.""" return frame - async def process_audio( - self, frame: AudioDecodedMediaFrame - ) -> AudioDecodedMediaFrame: + async def process_audio(self, frame: AudioFrame) -> AudioFrame: """Transform one decoded audio frame. Default: passthrough.""" return frame @@ -97,6 +98,19 @@ async def emit_data(self, payload: dict[str, Any]) -> None: return None +def _has_user_processing(pipeline: LivePipeline) -> bool: + """True if the pipeline overrides ``process_video`` or ``process_audio``. + + Used by ``/stream/start`` to pick the cheap bytes path when nothing's + being transformed; otherwise the full decode → user → encode loop runs. + """ + cls = type(pipeline) + return ( + cls.process_video is not LivePipeline.process_video + or cls.process_audio is not LivePipeline.process_audio + ) + + async def _run_passthrough(subscribe_url: str, publish_url: str) -> None: """Forward bytes from a subscribe URL to a publish URL, unmodified. @@ -126,3 +140,42 @@ async def _run_passthrough(subscribe_url: str, publish_url: str) -> None: await segment.close() finally: await sub.close() + + +async def _run_frame_loop( + pipeline: LivePipeline, subscribe_url: str, publish_url: str +) -> None: + """Decode → user transform → encode loop. + + Per-frame errors drop the frame and continue; subscribe/publish errors + end the session. + """ + # Lazy import: defers PyAV until a pipeline actually needs the frame loop. + from ..media_decode import VideoDecodedMediaFrame + from ..media_output import MediaOutput + from ..media_publish import MediaPublish + + try: + await pipeline.on_stream_start(pipeline._session_params or {}) + except Exception: + _LOG.exception("LivePipeline on_stream_start failed") + return + + async with MediaOutput(subscribe_url) as media_output: + media_publish = MediaPublish(publish_url) + try: + async for decoded in media_output.frames(): + is_video = isinstance(decoded, VideoDecodedMediaFrame) + try: + if is_video: + result = await pipeline.process_video(decoded) + else: + result = await pipeline.process_audio(decoded) + except Exception: + method = "process_video" if is_video else "process_audio" + _LOG.exception("LivePipeline %s failed", method) + continue + if result is not None: + await media_publish.write_frame(result.frame) + finally: + await media_publish.close() diff --git a/src/livepeer_gateway/runner/serve.py b/src/livepeer_gateway/runner/serve.py index 8bd470d..7369ce0 100644 --- a/src/livepeer_gateway/runner/serve.py +++ b/src/livepeer_gateway/runner/serve.py @@ -13,6 +13,8 @@ LivePipeline, StreamParamsRequest, StreamStartRequest, + _has_user_processing, + _run_frame_loop, _run_passthrough, ) from .pipeline import Pipeline, PipelineState @@ -22,7 +24,7 @@ # drain). If we exceed it, the task is left to finish in the background. _STOP_TIMEOUT_S = 5.0 -logger = logging.getLogger(__name__) +_LOG = logging.getLogger(__name__) class HealthResponse(BaseModel): @@ -66,7 +68,7 @@ def _format_sse(generator: Iterator[Any]) -> Iterator[bytes]: payload = chunk.model_dump_json() if isinstance(chunk, BaseModel) else json.dumps(chunk) yield f"data: {payload}\n\n".encode() except Exception: - logger.exception("predict() generator failed") + _LOG.exception("Pipeline predict() generator failed") yield b'data: {"error": "internal error"}\n\n' yield b"data: [DONE]\n\n" @@ -87,7 +89,7 @@ def handler(body: InputModel): except HTTPException: raise except Exception: - logger.exception("predict() failed") + _LOG.exception("Pipeline predict() failed") raise HTTPException(status_code=500, detail="internal error") if is_generator: @@ -112,7 +114,7 @@ def _run_setup(pipeline: Pipeline | LivePipeline) -> None: pipeline._state = PipelineState.OK except Exception: pipeline._state = PipelineState.ERROR - logger.exception("setup() failed") + _LOG.exception("Pipeline setup() failed") raise @@ -125,31 +127,38 @@ def _make_live_pipeline_app(pipeline: LivePipeline) -> FastAPI: @app.post("/stream/start", summary="Start a stream session") async def handle_stream_start(body: StreamStartRequest) -> dict[str, Any]: - logger.info("stream/start request_id=%s", body.gateway_request_id) + _LOG.info("LivePipeline stream/start request_id=%s", body.gateway_request_id) if pipeline._session_task and not pipeline._session_task.done(): # Single-session for now; orchestrator shouldn't double-start. raise HTTPException(status_code=409, detail="session already active") if not (body.subscribe_url and body.publish_url): - # Bytes-through requires both directions; data-only / event-only + # Both directions required for video streams; data-only / event-only # streams land in later phases. raise HTTPException( status_code=400, detail="subscribe_url and publish_url required for video streams", ) + # Stored for _run_frame_loop to pass into on_stream_start. + pipeline._session_params = body.params + + if _has_user_processing(pipeline): + coro = _run_frame_loop(pipeline, body.subscribe_url, body.publish_url) + else: + coro = _run_passthrough(body.subscribe_url, body.publish_url) pipeline._session_task = asyncio.create_task( - _run_passthrough(body.subscribe_url, body.publish_url), - name=f"live-session-{body.gateway_request_id}", + coro, name=f"live-session-{body.gateway_request_id}" ) return {"status": "started", "gateway_request_id": body.gateway_request_id} @app.post("/stream/stop", summary="Stop the active stream session") async def handle_stream_stop() -> dict[str, str]: - logger.info("stream/stop") + _LOG.info("LivePipeline stream/stop") task = pipeline._session_task pipeline._session_task = None + pipeline._session_params = None if task is None or task.done(): return {"status": "stopped"} @@ -157,16 +166,22 @@ async def handle_stream_stop() -> dict[str, str]: try: await asyncio.wait_for(task, timeout=_STOP_TIMEOUT_S) except asyncio.TimeoutError: - logger.warning("session task did not terminate within %.1fs", _STOP_TIMEOUT_S) + _LOG.warning("LivePipeline session task did not terminate within %.1fs", _STOP_TIMEOUT_S) except asyncio.CancelledError: pass except Exception: - logger.exception("session task ended with error") + _LOG.exception("LivePipeline session task ended with error") return {"status": "stopped"} @app.post("/stream/params", summary="Update params on the active stream") async def handle_stream_params(body: StreamParamsRequest) -> dict[str, str]: - logger.info("stream/params keys=%s", list(body.model_dump().keys())) + params = body.model_dump() + _LOG.info("LivePipeline stream/params keys=%s", list(params.keys())) + try: + await pipeline.on_params_update(params) + except Exception: + _LOG.exception("LivePipeline on_params_update failed") + raise HTTPException(status_code=500, detail="on_params_update failed") return {"status": "ok"} _add_health_route(app, pipeline) From bb692fbe5877583b82a1355287fc641969e45756 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Mon, 4 May 2026 13:22:03 +0200 Subject: [PATCH 15/21] chore(examples): rename unused loop var i to _ in test.sh Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/runner/hello_world/test.sh | 2 +- examples/runner/image_upscale/test.sh | 2 +- examples/runner/llm/test.sh | 2 +- examples/runner/sentiment/test.sh | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/runner/hello_world/test.sh b/examples/runner/hello_world/test.sh index 8a21e67..f1d2c11 100755 --- a/examples/runner/hello_world/test.sh +++ b/examples/runner/hello_world/test.sh @@ -10,7 +10,7 @@ NAME="${NAME:-livepeer}" EXPECTED_MSG="hello, ${NAME}" echo "Waiting for capability registration..." -for i in $(seq 1 60); do +for _ in $(seq 1 60); do if docker logs register_capability 2>&1 | grep -q "registered hello-world"; then echo " registered." break diff --git a/examples/runner/image_upscale/test.sh b/examples/runner/image_upscale/test.sh index 4d11db3..2e3ad78 100755 --- a/examples/runner/image_upscale/test.sh +++ b/examples/runner/image_upscale/test.sh @@ -11,7 +11,7 @@ INPUT_WIDTH="${INPUT_WIDTH:-64}" INPUT_HEIGHT="${INPUT_HEIGHT:-64}" echo "Waiting for capability registration..." -for i in $(seq 1 60); do +for _ in $(seq 1 60); do if docker logs register_capability 2>&1 | grep -q "registered image-upscale"; then echo " registered." break diff --git a/examples/runner/llm/test.sh b/examples/runner/llm/test.sh index d7b11c2..448ddc0 100755 --- a/examples/runner/llm/test.sh +++ b/examples/runner/llm/test.sh @@ -9,7 +9,7 @@ GATEWAY_URL="${GATEWAY_URL:-http://localhost:9935}" PROMPT="${PROMPT:-Say hello in three words}" echo "Waiting for capability registration..." -for i in $(seq 1 60); do +for _ in $(seq 1 60); do if docker logs register_capability 2>&1 | grep -q "registered llm"; then echo " registered." break diff --git a/examples/runner/sentiment/test.sh b/examples/runner/sentiment/test.sh index 13cb6b0..a5b5417 100755 --- a/examples/runner/sentiment/test.sh +++ b/examples/runner/sentiment/test.sh @@ -10,7 +10,7 @@ TEXT="${TEXT:-Livepeer makes decentralized inference effortless}" EXPECTED_LABEL="${EXPECTED_LABEL:-POSITIVE}" echo "Waiting for capability registration..." -for i in $(seq 1 60); do +for _ in $(seq 1 60); do if docker logs register_capability 2>&1 | grep -q "registered sentiment"; then echo " registered." break From 5bbb085f653d3338b99d57fac175ee72d81e8157 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Mon, 4 May 2026 13:33:07 +0200 Subject: [PATCH 16/21] fix(runner): accept null params on /stream/start The orchestrator sends `params: null` in the /stream/start body when the caller provided no params, not an absent field. Field(default_factory=dict) rejected the request with a 422 validation error. Switching to `dict[str, Any] | None = None` accepts the actual wire shape; the frame loop already handles None via `pipeline._session_params or {}`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/livepeer_gateway/runner/live_pipeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/livepeer_gateway/runner/live_pipeline.py b/src/livepeer_gateway/runner/live_pipeline.py index 4158ed8..99e0fde 100644 --- a/src/livepeer_gateway/runner/live_pipeline.py +++ b/src/livepeer_gateway/runner/live_pipeline.py @@ -4,7 +4,7 @@ import logging from typing import TYPE_CHECKING, Any -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict from ..trickle_publisher import TricklePublisher from ..trickle_subscriber import TrickleSubscriber @@ -34,7 +34,7 @@ class StreamStartRequest(BaseModel): subscribe_url: str | None = None publish_url: str | None = None data_url: str | None = None - params: dict[str, Any] = Field(default_factory=dict) + params: dict[str, Any] | None = None # null when caller sent no params class StreamParamsRequest(BaseModel): From 20b3f27a4ea2e37db30f8181b9a61f641192d249 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Mon, 4 May 2026 13:33:43 +0200 Subject: [PATCH 17/21] feat(examples): live_grayscale demonstrates real-time A/V pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full BYOC E2E example for the new LivePipeline real-time path: GrayscaleFilter zeros the U/V chroma planes per video frame (audio passes through). Compose stack mirrors the other examples — go-livepeer master orchestrator + gateway, register_capability one-shot, runner container built from the project root. test.sh asserts the session lifecycle (start → stop) end-to-end through the full BYOC stack. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/runner/live_grayscale/Dockerfile | 16 +++ examples/runner/live_grayscale/README.md | 118 ++++++++++++++++++ .../runner/live_grayscale/docker-compose.yml | 79 ++++++++++++ examples/runner/live_grayscale/pipeline.py | 29 +++++ .../live_grayscale/register_capability.py | 52 ++++++++ examples/runner/live_grayscale/test.sh | 84 +++++++++++++ 6 files changed, 378 insertions(+) create mode 100644 examples/runner/live_grayscale/Dockerfile create mode 100644 examples/runner/live_grayscale/README.md create mode 100644 examples/runner/live_grayscale/docker-compose.yml create mode 100644 examples/runner/live_grayscale/pipeline.py create mode 100644 examples/runner/live_grayscale/register_capability.py create mode 100755 examples/runner/live_grayscale/test.sh diff --git a/examples/runner/live_grayscale/Dockerfile b/examples/runner/live_grayscale/Dockerfile new file mode 100644 index 0000000..2a02ebb --- /dev/null +++ b/examples/runner/live_grayscale/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +# SDK install (in-repo source until livepeer-gateway publishes; will collapse +# to a single `pip install livepeer-gateway` line once on PyPI). +COPY pyproject.toml README ./ +COPY src /app/src +RUN pip install --no-cache-dir /app + +# Pipeline code last so edits don't invalidate the SDK install layer. +COPY examples/runner/live_grayscale/pipeline.py /app/pipeline.py + +EXPOSE 5000 + +CMD ["python", "/app/pipeline.py"] diff --git a/examples/runner/live_grayscale/README.md b/examples/runner/live_grayscale/README.md new file mode 100644 index 0000000..2bd589e --- /dev/null +++ b/examples/runner/live_grayscale/README.md @@ -0,0 +1,118 @@ +# Live grayscale (BYOC, real-time) + +A minimal real-time video pipeline — proves the SDK's `LivePipeline` +abstraction end-to-end against go-livepeer's BYOC trickle protocol. +Each video frame's chroma planes are zeroed (U=V=128), producing a +grayscale output. Audio passes through unchanged. + +The whole transform is one method: + +```python +class GrayscaleFilter(LivePipeline): + async def process_video(self, frame: VideoFrame) -> VideoFrame: + av_frame = frame.frame + if "yuv" in av_frame.format.name.lower(): + for plane_idx in (1, 2): + plane = av_frame.planes[plane_idx] + plane.update(bytes([128]) * (plane.line_size * plane.height)) + return frame +``` + +No model. No GPU. No external dependencies beyond the SDK. The point +is to validate the architecture — frame decode → user transform → +encode — against a real go-livepeer orchestrator + gateway. See the +issue tracker for a planned follow-up GPU example with a heavier +inference pipeline. + +## Run + +```bash +docker compose up -d --wait --build +./test.sh +docker compose down +``` + +`test.sh` prints `PASS` on success. + +## What's running + +```mermaid +sequenceDiagram + autonumber + participant curl + participant gateway + participant orchestrator + participant live_grayscale as live_grayscale
(SDK container) + + curl->>gateway: POST /process/stream/start + gateway->>orchestrator: forward (signed) + orchestrator->>live_grayscale: POST /stream/start (subscribe_url, publish_url, …) + live_grayscale-->>orchestrator: 200 + orchestrator-->>gateway: stream URLs + gateway-->>curl: { whip_url, whep_url, stream_id, … } + + Note over live_grayscale: GrayscaleFilter.on_stream_start(params) + Note over live_grayscale: loop: subscribe → process_video → publish + + curl->>gateway: POST /process/stream/{id}/stop + gateway->>orchestrator: forward + orchestrator->>live_grayscale: POST /stream/stop + live_grayscale-->>orchestrator: 200 +``` + +Four compose services: + +| Service | What it is | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `gateway`, `orchestrator` | `livepeer/go-livepeer:master` from Docker Hub | +| `live_grayscale` | The pipeline container — a [BYOC](https://github.com/livepeer/go-livepeer/blob/main/doc/byoc.md) capability built with `livepeer_gateway.runner.LivePipeline`. | +| `register_capability` | One-shot helper that POSTs to `orchestrator:8935/capability/register` once `live_grayscale` is healthy | + +The pipeline service has a healthcheck that probes `GET /health` until +`setup()` finishes (state machine reaches `OK`). `register_capability` +waits on `service_healthy`, so the orchestrator never sees a "registered +but not loaded" container. + +## Wire contract (the parts that matter) + +`POST /process/stream/start`'s `Livepeer:` header carries the job +envelope. Two fields drive what trickle channels the orchestrator +creates: + +```json +{ + "capability": "live-video-to-video", + "parameters": "{\"enable_video_ingress\":true,\"enable_video_egress\":true}", + ... +} +``` + +| Flag (in `parameters`) | Effect on the runner's `/stream/start` body | +| ---------------------------- | ------------------------------------------- | +| `enable_video_ingress: true` | Adds `subscribe_url` | +| `enable_video_egress: true` | Adds `publish_url` | +| `enable_data_output: true` | Adds `data_url` (not used here) | + +Verified against `byoc/stream_orchestrator.go:93-131` in go-livepeer. + +## What `test.sh` covers (and what it doesn't) + +✅ **Tested today:** + +- Capability registration round-trip (`live-video-to-video`) +- Job envelope signing on the gateway side +- Stream-start handshake all the way to the runner's `/stream/start` +- Lifecycle hooks fire (`on_stream_start` runs in the runner's logs) +- Clean teardown via `/process/stream/{id}/stop` + +❌ **Not yet tested in CI:** + +- Pushing real MP2T to the WHIP/RTMP ingress +- Pulling the egress and asserting UV chroma planes are flat +- Frame-by-frame transform throughput + +The media verification is a follow-up — a `TODO:` in `test.sh` tracks +it. Spike-risk: needs an ffmpeg-driven test source, a headless WHEP +pull, and a chroma-plane assertion. Landing the lifecycle smoke test +first proves the wire and lets media verification iterate independently. + diff --git a/examples/runner/live_grayscale/docker-compose.yml b/examples/runner/live_grayscale/docker-compose.yml new file mode 100644 index 0000000..c752fa3 --- /dev/null +++ b/examples/runner/live_grayscale/docker-compose.yml @@ -0,0 +1,79 @@ +services: + # Mirrors go-livepeer/doc/byoc.md: on-chain mode against a public Arbitrum + # RPC, but `pricePerUnit 0` keeps the registration free — no balance ever + # leaves the auto-generated keystore. No real chain interaction occurs. + # + # TODO: once livepeer/go-livepeer#3906 ships in :master, drop -network / + # -ethUrl / -ethPassword and run with bare `-network offchain`. Tracked + # in livepeer/go-livepeer#3905. + + orchestrator: + image: livepeer/go-livepeer:master + container_name: orchestrator + command: > + -network arbitrum-one-mainnet + -orchestrator + -ethUrl https://arb1.arbitrum.io/rpc + -ethPassword secret-password + -pricePerUnit 0 + -serviceAddr=orchestrator:8935 + -orchSecret=orch-secret + -v 6 + + gateway: + image: livepeer/go-livepeer:master + container_name: gateway + command: > + -network arbitrum-one-mainnet + -gateway + -ethUrl https://arb1.arbitrum.io/rpc + -ethPassword secret-password + -orchAddr=orchestrator:8935 + -httpAddr=0.0.0.0:9935 + -httpIngest + -v 6 + ports: + - "9935:9935" + depends_on: + - orchestrator + + live_grayscale: + build: + context: ../../.. + dockerfile: examples/runner/live_grayscale/Dockerfile + container_name: live_grayscale + ports: + - "5000:5000" + # Healthcheck waits for the LivePipeline state machine to reach OK after + # setup() completes. Without this, register_capability could complete and + # the test could fire before /stream/start would succeed. + healthcheck: + test: ["CMD", "python", "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:5000/health').read()"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 30s + depends_on: + - orchestrator + + register_capability: + image: python:3.11-slim + container_name: register_capability + command: sh -c "pip install --quiet requests && python /app/register_capability.py" + volumes: + - ./register_capability.py:/app/register_capability.py:ro + environment: + ORCH_URL: https://orchestrator:8935 + ORCH_SECRET: orch-secret + CAPABILITY_NAME: live-video-to-video + CAPABILITY_URL: http://live_grayscale:5000 + depends_on: + live_grayscale: + condition: service_healthy + orchestrator: + condition: service_started + +networks: + default: + name: livepeer diff --git a/examples/runner/live_grayscale/pipeline.py b/examples/runner/live_grayscale/pipeline.py new file mode 100644 index 0000000..6820343 --- /dev/null +++ b/examples/runner/live_grayscale/pipeline.py @@ -0,0 +1,29 @@ +"""Real-time grayscale filter — minimal LivePipeline E2E example. + +Zeroes the U/V chroma planes of each video frame. Audio passes through +unchanged. Run via ``docker compose up`` — see README.md. +""" + +from livepeer_gateway.runner import LivePipeline, serve +from livepeer_gateway.runner.frames import VideoFrame + + +# 128 = neutral chroma in 8-bit YUV (Y=luma, U/V=chroma offsets from 128). +_NEUTRAL_CHROMA = 128 + + +class GrayscaleFilter(LivePipeline): + """Strip chroma; output looks black-and-white.""" + + async def process_video(self, frame: VideoFrame) -> VideoFrame: + av_frame = frame.frame + if "yuv" in av_frame.format.name.lower(): + # In planar YUV the U and V planes are indices 1 and 2. + for plane_idx in (1, 2): + plane = av_frame.planes[plane_idx] + plane.update(bytes([_NEUTRAL_CHROMA]) * (plane.line_size * plane.height)) + return frame + + +if __name__ == "__main__": + serve(GrayscaleFilter()) diff --git a/examples/runner/live_grayscale/register_capability.py b/examples/runner/live_grayscale/register_capability.py new file mode 100644 index 0000000..4f25e8b --- /dev/null +++ b/examples/runner/live_grayscale/register_capability.py @@ -0,0 +1,52 @@ +"""Register the sentiment capability with the orchestrator. + +Wire format follows +https://github.com/livepeer/go-livepeer/blob/main/doc/byoc.md +(handler in ``byoc/job_orchestrator.go``). +""" + +import os +import sys +import time + +import requests +import urllib3 + +# Orchestrator's HTTPS endpoint uses a self-signed cert. +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +ORCH_URL = os.environ.get("ORCH_URL", "https://orchestrator:8935") +ORCH_SECRET = os.environ.get("ORCH_SECRET", "orch-secret") +CAPABILITY_NAME = os.environ.get("CAPABILITY_NAME", "sentiment") +CAPABILITY_URL = os.environ.get("CAPABILITY_URL", "http://sentiment:5000") +MAX_ATTEMPTS = int(os.environ.get("MAX_ATTEMPTS", "30")) + +data = { + "name": CAPABILITY_NAME, + "url": CAPABILITY_URL, + "capacity": 1, + "price_per_unit": 0, + "price_scaling": 1, + "currency": "wei", +} +headers = {"Authorization": ORCH_SECRET} + +for attempt in range(1, MAX_ATTEMPTS + 1): + try: + r = requests.post( + f"{ORCH_URL}/capability/register", + json=data, + headers=headers, + verify=False, + timeout=5, + ) + if r.status_code == 200: + print(f"registered {CAPABILITY_NAME} -> {CAPABILITY_URL}") + sys.exit(0) + print(f"attempt {attempt}: status={r.status_code} body={r.text!r}") + except Exception as exc: + print(f"attempt {attempt}: {exc}") + time.sleep(2) + +print("registration failed after timeout") +sys.exit(1) diff --git a/examples/runner/live_grayscale/test.sh b/examples/runner/live_grayscale/test.sh new file mode 100755 index 0000000..785ad46 --- /dev/null +++ b/examples/runner/live_grayscale/test.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# E2E lifecycle smoke test for the LivePipeline real-time runner. +# +# Asserts that a stream session establishes through the full BYOC +# stack: caller (curl) → gateway → orchestrator → runner /stream/start, +# and tears down cleanly via /process/stream/{id}/stop. +# +# Does NOT push media. Pushing ffmpeg-generated MP2T and asserting +# grayscale output is a follow-up (see TODO at bottom). + +set -euo pipefail +cd "$(dirname "$0")" + +GATEWAY_URL="${GATEWAY_URL:-http://localhost:9935}" + +echo "Waiting for capability registration..." +for _ in $(seq 1 60); do + if docker logs register_capability 2>&1 | grep -q "registered live-video-to-video"; then + echo " registered." + break + fi + sleep 2 +done + +# `parameters` is a stringified JSON; enable_video_{ingress,egress} +# drive trickle channel creation (go-livepeer byoc/types.go). +LIVEPEER_HDR=$(printf '%s' \ + '{"request":"{}","parameters":"{\"enable_video_ingress\":true,\"enable_video_egress\":true}","capability":"live-video-to-video","timeout_seconds":60}' \ + | base64 -w0) + +echo "Starting stream session..." +RESPONSE=$(curl -fsS -X POST "${GATEWAY_URL}/process/stream/start" \ + -H "Livepeer: ${LIVEPEER_HDR}" \ + -H "Content-Type: application/json" \ + -d '{}') + +echo "Response: ${RESPONSE}" + +STREAM_ID=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['stream_id'])") +WHIP_URL=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('whip_url',''))") +WHEP_URL=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('whep_url',''))") + +echo " stream_id=${STREAM_ID}" +echo " whip=${WHIP_URL}" +echo " whep=${WHEP_URL}" + +if [ -z "${STREAM_ID}" ] || [ -z "${WHIP_URL}" ] || [ -z "${WHEP_URL}" ]; then + echo "FAIL: missing stream URLs in response" + exit 1 +fi + +# Give the runner a moment to receive the orchestrator's /stream/start. +sleep 1 + +echo "Asserting runner received /stream/start..." +# uvicorn access log format: 'POST /stream/start HTTP/1.1" 200 OK'. +# We assert 200 specifically so a 422 / 4xx still fails the test. +if ! docker logs live_grayscale 2>&1 | grep -qE 'POST /stream/start HTTP/1\.1" 200'; then + echo "FAIL: runner didn't accept /stream/start (200 not seen)" + docker logs live_grayscale 2>&1 | tail -20 + exit 1 +fi + +echo "Stopping stream..." +curl -fsS -X POST "${GATEWAY_URL}/process/stream/${STREAM_ID}/stop" \ + -H "Livepeer: ${LIVEPEER_HDR}" \ + -d '{}' + +sleep 1 + +echo "Asserting runner received /stream/stop..." +if ! docker logs live_grayscale 2>&1 | grep -qE 'POST /stream/stop HTTP/1\.1" 200'; then + echo "FAIL: runner didn't accept /stream/stop (200 not seen)" + docker logs live_grayscale 2>&1 | tail -20 + exit 1 +fi + +echo "PASS" +exit 0 + +# TODO: extend with ffmpeg-driven media verification — push MP2T to WHIP, +# pull from WHEP, assert UV chroma planes are flat (= grayscale). Spike- +# risk; landing the lifecycle smoke test first proves the wire and lets +# us iterate on media verification independently. From 8beabc1c4ebdeffdcde2c6aefc2c6d7ff984d78d Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Mon, 4 May 2026 15:08:06 +0200 Subject: [PATCH 18/21] test(live_grayscale): real-data smoke via fixture + trickle-aware server Feeds a pre-created MP2T segment through the runner directly (bypasses gateway, which can't carry media over plain HTTP). _smoke_server.py sends Lp-Trickle-Seq=0 so TrickleSubscriber's start_seq=-2 advances correctly; runner reaches the host via extra_hosts. Asserts bytes flowed + no frame-processor errors. Chroma assertion deferred to demo.py. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/runner/live_grayscale/README.md | 21 ------- examples/runner/live_grayscale/_fixtures/0 | Bin 0 -> 52076 bytes .../runner/live_grayscale/_fixtures/README.md | 16 +++++ .../runner/live_grayscale/_smoke_server.py | 28 +++++++++ .../runner/live_grayscale/docker-compose.yml | 4 ++ examples/runner/live_grayscale/test.sh | 58 +++++++++++++----- 6 files changed, 92 insertions(+), 35 deletions(-) create mode 100644 examples/runner/live_grayscale/_fixtures/0 create mode 100644 examples/runner/live_grayscale/_fixtures/README.md create mode 100644 examples/runner/live_grayscale/_smoke_server.py diff --git a/examples/runner/live_grayscale/README.md b/examples/runner/live_grayscale/README.md index 2bd589e..968f288 100644 --- a/examples/runner/live_grayscale/README.md +++ b/examples/runner/live_grayscale/README.md @@ -95,24 +95,3 @@ creates: Verified against `byoc/stream_orchestrator.go:93-131` in go-livepeer. -## What `test.sh` covers (and what it doesn't) - -✅ **Tested today:** - -- Capability registration round-trip (`live-video-to-video`) -- Job envelope signing on the gateway side -- Stream-start handshake all the way to the runner's `/stream/start` -- Lifecycle hooks fire (`on_stream_start` runs in the runner's logs) -- Clean teardown via `/process/stream/{id}/stop` - -❌ **Not yet tested in CI:** - -- Pushing real MP2T to the WHIP/RTMP ingress -- Pulling the egress and asserting UV chroma planes are flat -- Frame-by-frame transform throughput - -The media verification is a follow-up — a `TODO:` in `test.sh` tracks -it. Spike-risk: needs an ffmpeg-driven test source, a headless WHEP -pull, and a chroma-plane assertion. Landing the lifecycle smoke test -first proves the wire and lets media verification iterate independently. - diff --git a/examples/runner/live_grayscale/_fixtures/0 b/examples/runner/live_grayscale/_fixtures/0 new file mode 100644 index 0000000000000000000000000000000000000000..b01acbee7ba8f189c59be6816f0d6c1a6506bcfe GIT binary patch literal 52076 zcmdRW2V7Ixx9&~|5PCulJyeknq9B5VCa9<&QlxiLks>Il2mwMz5gTGbii1eU1|q## zus3j|+YlWHC__{vZyj`IocDY0zWcxB_wK|T{)u$r$}*22?Gof7s4Is@_sV>Q4RTOO{fX8Y~S6)DMC;Y_(wroptusHd+Q$b7xD~ zGa%3(-mnY{-xCrT6h+n7)m@^cud8PS8-t^w!i{xwqNAg=cW(^{3=8oM)waPQa@fu- zI@mX92S@D)f&Ie5qqc^HMjBK7ef@m>O?9a|1A|QUr~!d~Az}X8O?8doAJsS1H)Ky_ zAohy7BhWNpt1mTjmmfUSrG`iDf!*P+_s#%QJ#Ags1^!TXY>f#F@WzgXeZqUEZ|Igl zQ@v$W|KOcrJAA$2qk7b+oq-`CTO;9_QH)W5e-!NMAF%`e1_b&B>AN)) zjs-jJ^u_iG+UdI^FcO|c2X5UG92E}Fwg>Kkw@vkz{&)(ui$r+a;h!gEbly^aUmG2<4wRfh`q3D?lZi9US z?MH zBPYz4R#3opu?Sg3auOXSF%eO33L%;SvNeM9Mkq1X=A4Cbl#(bS+$g4_Lnc0mD5Drb zfp^UiN|Glb0tbzdhVTLiQBN)6_d#hl*C=iNHnF{*jf8$#VP85ndHwFY$0z4|FaNe9 z)`W_T@dDvgMB+rWM~6`4d`79?gcWnEj1e;W!mLVN_8~rLt#67cqP@kdZdJC+-mUHM zx+Q7Mb5#BFo(CdV9y1=A(b!x)b>ulmBRbj)rM<_ z!Sam(nsnb(q+PR*QgulmWYn5rpF`NC1oL?a(!` z4CZm)Iu=tZBjWf0^5bV3?VD5BQ)x=WdmF%ZNqgzS-m~{drAeSpqat{@sP!ZNSJd9#=x~}T?nX-G$KR(Z^ZE-BGkCSK8Q4OgL2SLLY5xMDL zyHt$J8kMizrj5B=@(K0R4fld5f)Cqh9eLk2c-$Y~i(SEn7*86G%i?IVg6&38hT704 zj(#`)9mQwA?P4>JKA?%57)e}_1%{A4qVln>rZ^N$3qWQOh?b1ZPk5xK&d;dJCW4?tCMRdIy-dBl- zBMToyO-R+=GQ4W@q8p1camL7$Az3PSfkWuj%w8cROcx;;WmG5hR{6o@rKrDw$jrg% z(aKo^3`tbQI^r;wK+0@C#RRQEcC=CTW^S)*jYbRmbkK1~*Xa%oCk(jbMnZ>hM5mkK zM2jk0z@RQFSoR7c8II10s}r2B%D;Bjsds~?>8{7UeDz2Hjv%c6x^tYnngO=UusUBE z=nQkxg^|QYlZ2t;xaxQoz7>_??&yjK3ufn2FH?3a?>xuf81{4o z0!O;y?Kg6_-_Y=Sb-KgEbG^n|Q|`r@Q3K@>ZNO!sA-$9jVF% zxEkjso|tIaRz9SRw9oAkljErN7$njV6;aoe`786E6qvlu275b}Djiewh0~|m2Xab$ z@#noTzlSEl+BJ-&R1UQ?%OXE4X(-V75FQoT9->6v5Tv0wJoiu|k|E=)=;Qg_jqz5cuH>K31cNUTpPHwTxccOE;k&f1RJ=A5y^iP&M_F}AY@*E2qJ!_t2^QF zY5n1dwCo_luMqyfo#{d~52s?hxK_UupK@h$| z5NwxM5YjYam^4C=6cy7nLNxnLH4SvQJRGfD-0~}9kO}AHeKwdfhKNGgAqx=IBtU_R zNIb}gjyFOw_<3%H13In4z@M-A+96H4+F=Q5>n$mUHbVVelkqfMpOx5e<|#s?iEl*T zRK#sAYE#Q~A~)kpM(Qgt$Q6r9@{!-KySZ#S`N6@csuhTE7~#+kHW*r?dfcFhi-@D% zu$YUX|al_x*WBKe!)( z*otT}`1XK+0IL8Wdu4?}1s;(E*rtXEiC$FGC4G5ktbL(DyztA(=X z06*rOarL(g#l;7T5a65xE-V+P#~j#BhNpaRIfJM_zV42TXQbH$2#s52OrvHV_j;N{ zVwZtP2QR@VVx|Wh*Zla9zZr%fm)Y;^wk74;SM^%cQ(VR(S2I)8rAO^8sOz{HlH{&5v${@juSQ3R+I50%DSDD2yj zkjo`*C(rt0|3h#>yf6xpM%WfQO4K{?0g2cmp~+VBgVz>$MzULR4+6*^R;Of>#=qHG z!hpavg0S)R_0JKe3%9FPZ4=NH_!ws|7>?0JO}N%4 zz;=-&`U2#XbdhO_fLfm(qx-$frr@nBiO$+l8mQ@>JOEuSY8xl2o3 zJ5p=EZvfjx7dBqhND=Zn*z~q#*axY~;R%QzUnRpZXh}r`PR<_ITVOW?*N{&W$$0!h zTTfdRAwh)y?kCsi!bEyvlgDdoVZ6y}8ior{gKHJMR*eNo z++ju1*ogw9EJXA{2`7MhkCoe=#4iz^TRLK3$3)@Q1hFqK)youi{7SUsN3sM5xlSoR zFO6}H&xi-{U68W|+r@0??|Th6eMv4?xF{x;*FP!z!RCd2uz5P7K0tV$TDfzhdNgDe z3W$)lK{(lTqbxv`JRO;51GB{y;34sF0btjq%`75=&-*`YMYeL--m5!(h7olR!ayty zkpvKyP_Y6blZ}a6d)rOG(?MPMN}5YLA2ObMhb7Oy|y(RJwPN}25p#& z&e5eb4G@Hb;0wnL9*!vOu8RqsfZOm+s{LUCr$)U8|2c2PH|GS0e^H!fw$t%k#?P3k z#Qp3;5j|kL#6iU811_j$k>RgwCk09I>-X=c;t0nWh#S|IwDodATY>V%!&CchWjJU! zznncl&NyBra$5#+=NnLaL%$9T3^I*e!*eKtIv4m-Hs(nzRS4%l=jA0~k2gwmcBJ@R z+I_{-32vKsEO?0KS2DEvJ)R`oz#q-j2iwVQ2!~%9h;ZnT08`d*yVFx_S6VAtOQ#(| zq-`YRV~nzr3X}}t^6iEoAi5Fq4A@Tjgb2b#IYFC~pP+-pHMcGJO$I7mun#-o?adWQ z+x-YVeb+8<9OPUqkqi4M)ngpL(BWvh@mP*_`FL3~i3guXM1mYi+%Ir_oLiTNcsxl( zbZ-64H?JV0{^tpTiAkdNZ5cwg^0;5EvuaDUMxUVJ{vaRAf?`phmKk~8gZr|&VmOJ!5{)tPZ=b%A{sBCt>Td;*e)4Z6M@Kw;Qwju z0uGH>%S!qAk2Bs*PP~$>7Pa3GT>ER_IcG_GT*w_nY^Cl?&0C_hJE1dH#^#2|%XDUh zJU4l&lQkL}vC`EyoRN8D|{T3_t*q;X!-?NGU;TbNYx z+qU|Kva(FDU8=Mpf$l+3yGO8i#NBZ@bGMo)HCFJE*6|K?1|F6x>!iacSM!ALh4%N8vj{Ohy=7tphRn&jNUMXtsHymvi&+SH{{@7oAZelkuC?3{vj2DvAruGi z@S=e0b)dwBt8^S*rvOp&`3VxQ5sl4+WJt#qS-I}Ii>S;y(&N1tW0Irx2iQx58v$-740+K0;!-EZ+NJb#tFm1=J{ zZ*6%(i?U-L5!>3TGV?t(Y+p)?cB$D+XX0^pIJ(KKrC__^ zUUFO4jHn&%ICNL1Bj>QINx;!~rs1~S*M!Fc-PyH+AwfV42=BksbqJY0IP%GzI% z_90uK&4H&m7ITthIMaAyFmMB2?Nl2j;(S%(CX}TmtqI9(au6V5=l-&IlXt&zC zh0Evul@*TXRd;~5N9At!L8ScF$^9p!isB@|;1pM@hn1KZ_wA?9El zTCQ6@`3VP3F3($Zpk(Yri^k55b355{X^V^PY$6;|^f;2>Lf0UDqRP?3s9@t%mPm!#3*PY=#^frqfY94N*C*YYb>(EQb}iRyC z()9LDWV7R2>?gA4xs7b=BwcmpSM=)_2pvW%c|Th8b5|J#8xbvDry@&f&G;L0p9_rF zyRx%cW-sT3Sn!D5Q)q~$j9O_ZgbQ)uj;3|BN|}kj0azev&m(*;!i268jb}- zLy~xUKV2hzmA&aTy?97`s^hQjT+Y z{h(3h8~q8VQMjH*5sC8vbyoJ_kz?ls(*skjUuW0j1ad^AKj}v!g^P;PEf!$AFlB;< zL~x0(hwUCN8s~eJ`<162IR;pDf8#&HR1H2d!})0Kx0*kmF#6Zs=-+>%pNjN>;QC>L zAfhAldO%sXr5D9c ztUdn$ZQ{%~7%Z?#;w0%d(6=BCT9IP=IDM0v;nrVw3>y)<@8Vjmv;mL2NH>Ot?2V8( z{!|{J-j5($6a`G9TFHnD+LlIg77Mhr zLQLebwP$AP-;^R1W+^eZH@HgH_MW|)aH-z!b6e@kQKq&C*e-f0MJJzOfO#C9N%lkp=ZciK%Kt@UZ^G|3LRt z%dlNAPL`id_oL~)ZxUI;Uh8qM;g|CyA*FT}Qn<9@C?ja+QHBnCnCrIYwp+g^IA>p0 z7HR6EB4wG2i3s2N_Dm}uKE4-qa(JILLd@iWFNZAJ-#un-fr~1jTX>Z394%k9KJjvc zMTJvF%@0XY7;Imvi~hx_yXp@}^f_6t#ZqAiACzup?S^SG^d8;$q*-p6n09oJaCK;xOZL>a z-zJlgqU~D^!fwrH*ZS~Gp--y~1Z%=GTY>}QPmt{kcHZDO(lffh^|Z^g-|JzXPaLY4 zVHis~C(Q3nK1A^H`~05yNopE@4ZJkxSX@M;_h8R(hl3NT6$+!&ONfxgaCVgC)ptHu zs{X93oSm%jbQeZGy_u=Q2PYq9w9Ap^$b(#gT=hts^f_=2uL zyD050F($irsB{(2wuUMgcf)i4>bU=lm+8M|_gx+~Bb3bMNj9<5b2gs%wD1IsJLRWw z=ixU0dECtl^jp7=`#C8N&F|yBl`F9(H7(hwz7dfH($o)|n>?0UEnpAib(hn|!i=e! z(b0TuZSBRG8GfUqqc25u#YX$;MufU&W1TgCl!;~BNtG|rVp+C}`HzHZh}Bni`A8z0 zu9igJEXS97tAG%dl`a3h%NzC(@tRYRPH3gSJQQ~Q`t_!f=62pCXwzoNokL9gV^<9C zl4&y{PLuPb%5X+lH0)V?D0<(-h$jOlmYEoy=hTRHp)^9}_vVd=i1qm6 z=ACx9jUYn9QTj)#Z!P+D+u&MD!P#G2{bUA5Pc5pxajiG(S*;Z_rJ;#k?RU$sB67y; z%h2%IeS@R>7)yz5noEycAD_wJJZXH7oam;cxo-BHQp=GFCEkH4SEt$YUi-rUdCV+Q z_nhlzi8k&;$(M`CPuOj%3Mne7o56PD=>Jcc38<($-0U)$F2T&w;B8)`KiG8SV-9(b z!Ko37vvNeluA6$(+v3Q{+Zw8GHoB9Ci0xMdBt!40#%6Y1p0j!Xpu&cR$mpNj3qk$| z_d=}TCLxqn@V8zF9=Y0|eb#5}k62kENI!LVpaVXE#mlWp1rRS4)|VrK;voxc$e9+ zy?U5X)mhvx`a-Q)wavV z|5An6y@^k&qv%QZJviT75sRbY$s zY}h8-i^Hv;})$x0=Apd{~XE;UqZ>rel_J2>o1z#w`J@yv9XGt2RnD=XiTca9+eDJR3tx?WfR*Qdg`}p zUK%>ABmS6~CHy=g?7D8`fMUCDrMUZ>!VBlT)@_*S87XhkqzueGIIwLc#CN%$MQ-%Kx8JZ);a&Vom9n*sNCFCv*romIjS;@ySxG- z-#B*c+9V67w#P!V_@>DCESu=mufR7Pj_5Kn3BL{TegN7en&`vLdYJyAIu4nU6k}BU zg3?Yy$hY9Bb9>gW;IYy$v#MwmY}YQD6Qx|ue-Sp- zzf_T(mbibr+hnIpi-Xv#$PHoX5_g7~i+{SKVz2X!9Y5TxN2Sp4n4pYWGT}O^D z^^|MY%J63RZjQ0uw8vA?apJ^5)+(`sd(HuPyg!O}c_|gG81;3ncb`}{EwboUdaX;z zx^Mg5o^ih4lI#PRf%yI>7vou%^D|}&-2T8!8RuV^NeI?}tDH8dt=jH`7#c*)lC-cj zhnqJCKp+38YZJ4RRMDBlfF-j|lRx4da`l}5)*PlS$ZlOn*u=O>2eApo>k)E_#RoDM^}Xf4a3%w67g1hWVk1(e*z%?B zDa&#uK3`$Cjs&}Ol~_tvt>K`?V&Bbc_dI-%>;7UTT+R}qTT^w*5sUa<+qHMadN)=HJuG?$m!?q89eOlDOq>FGRE&(gq}-$bh~baL$b|)T8|2s z`=}LQA#Du2wwKsmvw3G{b>o}UY3X_g-yMln5_}r|PV#W+H}6b|$EvJVvbjf=3IsO2 zANw+QaswRs`10{>v6;6n%wGZ94KDqQc>k~AwEwxI%-phf20kPF59Tw%|6o3|6yr1b z27l)>fw-UJP3yM(Yh9LZD=*tMzEuMLSsC^G{(0nDfxa_&h*NL{M^JH+ua(Q)>%cqz&=*e zJhRIl<3}8eE4Y5?ocPl4JRyw&O0$EY)#z@LU($@YI5|+IgveoT6c7W-E@cg4g7H zR`i{tp1l$hoD$g0Vb@EXmujF{cSnX_Y8uclJxd6R!_#-*?xQAUpmlzpa)FEGn4#x2 zq1Nw^F)+~mpw*d_xOZ^2m)~4v`)nJxQ>ByBsss;<04H{ey55EZ<3NeZ%1YC0%O8s@ z86hreK4ToC*e*6pTm{*Zig@v)F!$@T^$?3+M(6|+nhZD+Zt^?i&ql0@6DuJhac*4}ZiAGhY*`Zv{Z1m65-vwMtj%_5VVLN7 z@VgusJ<(irq67-R(vqSRT|`Eza(0?sUA)i?oT@}q>xGmYy8yfUEDMPVwaJK#OO!4) z*?Wq#!R=Wy>&$kys=6;N&)DwYcFk0{7s{`^axn6<+0$mx!RbAwlZ%eL7@xBuPfW}y zfbA4Q!?P>1&%%;tngZsl)W5bQBpjQ&o8wxFN=zsv@IIUGn|S-|GPg^E z_FAbto&YXa9lLa;+*>Z`Y%=mAYhiZSpEIrXS;|nObvYe3qH)*-omyPx3>VwCvx%0c`a52pF%#;H$#^ELd|UY2 z_ICN|=E|9{>iui?@8i4H0=AprP-Wbevql-eFTY)MHm!QT<;#o7eIvX_*q9bE{pE^< zZiTdtqW<7IW~Ei**x1^l>NzFC)hX>QH<-H^KF8fSy`zUQLE>5lW1H2NI%3D>C5l$N z%$UhLzl^l&uI0;A3YZ8}YZ1p>vpZ1$`&iY87|e=2lvR{&pGz; z@JpZ_rQ5Y$X#{^(oZe~(We53q&`zJnHm|(asm>F3+6}$^T{`>o-pe*_X>`Dh?9Z4v zGV&8w>BC_?>%q6zLM#*B0t*t$_R3uo`M}{wglO3zRkjf2O*CGZY^8X z+NB5e?EUGFbB&~KUJ-rOz=$P^LGjYs$DjN70kQRF;j&xd_=`_9-b1ZHKJ z-oGjLdH1`4Z*^bV*VWL88R0mK2UB)$%4X+KtdDfKg5ufWvslec_|_g~fAZHbZ|PcP zM6oS02|VF((S*x5)}B&xQ1{Xz%_AosU%Uyn%WowY*gv|ltjn80ZNC1r%t>fRFN1nL z+v!>TjRT|Wd6uimY!EWq({WEO{9`H;yC;b%5{`&0NwYh8|7C!XBbUvN!4vu`u*U;W zw~Y&NBt3B#7E$~>?&(c5=T6^Q>E2IRmu=3!DB^LqMoY)f?8ve~Bh<3ofOgR@s9B7P116u<9l?5Uxr^%hm58XK5cY2Ms< z!s72=_eMdlAm?+t2=xHI?XuHnDJB3{lj%r{n!R%JQ+N#AW=K6i2*vuh~2_tx&M zhQ#hCd0G0t+}9^qM{~HXWC6+d<6k|33fzC4$dhxm`Zg+O2493c3#;&6slK&wFNZuh zh0u*rhuM=iXcu=5s#;kHrcF@ClA1}0vMnA;yscLbp5BJ_1M4~cx5W3AR*`A))qH`c zqalM87Hi#Vwm$yJQpe6Fuu)0yp``!8fN)EvCHhaxPk-gUH-Qfjvp5i|x9O8rorEnRqHBdNF(Hm_ItgbZ5RHiGu3Dx#A?V8_>dv6i|JSx$ zx~J^CB_tLk$cTuDorQ&7>Yb_~am&KvV|Rtl)yJm=OS}snbU#>HJPv_+XxNl&y zD#NaiP*f6c^$1)#KJ752_VuN_V?R*Bf*zNSRc?C-X`&tZT2NT?-Y{7=dwBDPR~8j( z>kp>q0a;b$y4Zhe)ZG%d*ZN)BBxp%WnwJJI-Ml!MvuCNtwY{VNu$lD9DqAVz1>>f(GJfX=CXtUA z)ZR?LAbU)(y^hV~T*0!SwMC|=H_PJuhD1SZ1B|O6r}7CrfudLqyU>e&HhJw1Vf|w+;t*@Oy$_2RFYIV1#F3R!&+_%`Y`K z0Ck&c=W3{t95XQw=J5wORE%8zwBCMwFzF27DgZYhhQ(0J*bz*M6&K zPf^sXf}U90^b7AYu{z$+L+j&`b6#2tQ84{6Uu>q6@Zjs`Zo?EhR z3T8dr_LSN^QtiH;HXffJ)#A>+Z|uHuUW^WWM)V)dXGH(Oe8vRhGi@G!=QC4AhWRkyT^9UUrOVYRxARU(IYXi6w<_b$LnA!V&7|ZSBT$Js z=oWnYCrDS@k&+&r(?o9ZX)Hqt`Yf|oP$brDcnYlu=`S?~45u1ByoF;5GyG0w=I`;% z&7ibdP;)bCdCtOANsZ|(XdZ>nhs+9dXMqs7`? z&9xIO3#=PqbK&QH-zB1N9T`R!ceP6iv`oe~JsfIcS$dD`8=Sp3F$uQIOl2^gAwEHQnbKcyEzcUv4~aPgwRv z75R8$yPeH^b#jwX=>TimQ~6-c*LS_04M5BwKXsp6p~umAOS}_HUO|UnS=K_;1QAMX zDC~kG!`D^wbgkOguACl_5bV+#ZS1zpWWv_BIg(-ePQ$@*lu?8-xp{88`8_aamXlDm?s+=aXrY{JjQ?4Sn*T$is8gP!t2)c z&P&}t>+i`Pv4z|OX#X=e@$ry<#(BY?IB)m~=U8rvNgFI;B3df7ZJQ$Pq`=9LJk>5; znBk6CXT4ONqR~|whLJFD;N&TTYC9GHu18iV_oHkD2q!zvg^6WMeHSAz`d*dJ)#wgE zeN9d3A1ySBJYu3Z%R$X;0{!9Xb|)tX(&0XM?d+kTIW&sVc3v2FGM%1#gma4-e9y(c z3MdA&GFm4YGFd1mZ zhCIAx3H*GA17;Q<)WN+e|S8zCBprBeXLMXpSQ@o>FpcNEg5m7-sZg* z-is5CP`z@)WWxAs-2Fxv_@Q(KBGX{__7YW&+$tYgo5#Gu*@MV)_*OY!uz&SwZ&u~N zEb#R9;+LBrzB4~Az8+@Ew9+Q>46%9Eu=7U2Ts&K<;nR9OufD4 zm!r(OJNI&ysVpg3H>Y9$@c1;Wl_!5%du?FDOy)TB&zNJi_FtHxCj5+<);}@R_!nlv zu-Lc`G_FQRsPz&L`CQ3SUdMG$-|C3s2}()PN$9mYQ9pdcuP^WHT!@irgiV^9+Knn# zxZjh*?w?Eq-8K;O4#k6pbZS5y%04v=K2WYLM94dgBnuHm5u>~utY@jp8;gc+Ppp)& zh{f!(iGkU2`#m#Gt|r);I8nLSV9^*xPdurEXl@vtEqhvlkUJzuN`~2cJPm~tXtz+iSWc}NoISW_%ZLD>KSL+JiQPFk+eGMB2iwI;R?dDMj8-CB9efCq ziM#)Rzrv41d+G{ku1Bj(?DB%k5b&#`j&&sf>~Q>^@vUzMlUFVubFManz{ z&r5C{4%v_~wdlCt2F?2)8`&#vr{EjyZbKK7j%ZAUb7YU?eNAje$$1%LLB?=t7wdT$ zQ!F-GyXt$C1chVOvJ|Azc_%%u2zTa*DRCtQLHgLW`jJ z>?Rf|JapOAV_QwM21+-=2V{<4_Xmv>xZbF8iHOp=k!Za^*dNwv)e6BNk<$)e7xi6L4_WqUa!L&(8 z`npvH51D>u5&9ON`@U{DKT_UYe3s2pygb_h{Bg}0u1m$hD)|;^jRshc8lPpDKxmxL z*M@W}oC_2ZYRj}9k^LW9>xR+~4Gn?q#s~gK#mGjR)u1(Q83TjK$92Z?mhFFxsW^8n zVjDlx?JOId*zEk84>m);Xy!_AFw@4^IZ@%ZZf2% zLfo0j8<}0Jcw>Pug7oV^%5n6=W=q?+xtrCNR>*z!X`4LGA8emkB%<~4t0vY;FHIjt zn_%2!2!um+q?hsFBv6WJK)Yz$0K+yB1$VD=OnyNt4Jpq+Q`iqZUUd+?!7&ss1;z=SKm) zwdTc2JG9pZ+xI-wpAN1AbQd03as)pVS*SlH3*A>@msT|I^7Cx7TNcK?@CY=HzxQ_X z1wH&3Gt@sZQ}h#Nu-Is^-}y@Cswq3K@3PqWn}C~jN0j@rxiRg1=bc8!FhZ73;Swd& z+8tPXik?kIS74GT+%d4YfXs`{5lqL`DD6sMwy zBFuS0Q(V3pzx}q8CGgje-UZ_>&n3i5$Wp((C2iX*gIeT0*L07R8y%}41dFZ-bTmv# zn2o+M#XW$n<{;|JfsE6^HSB$^&nK<*GeU|p8&z}3sXSZT;-uhWx3ryWa z#4J>Q$H-9q-CbsN#6Jy(b(+46>MvR`RWre=aKJ)6rNZGMo%cH>!J1|M3iJWWQqY1R ze&d&g%KaNS=yoy>o>CrqgJsD)Z@-UK(8X0e_e6XDm3n5iS{wROs{st($%i)%^8NlXU$ zjlzj!HI_dtow3}cl`DU>{N=u8aKqVju2OI9l?wWfW>_oCC9D-DbGIHWwPKa;{_O?( zsn*`U-E9d;G%FJLaK?ie2e<@v%BNSZgl!3)bWX5lBE@-B=Y`yfC%dP2g+! zRN2@k>!NLYK4E=&OYHwc@%`T@j9Q*3nb=`<9>nA}_X(J{xwvzPyq$%mfC}}MYxwJa z;o1x>G!$(51uS$h4l3x-h7f<8ip}e#SY6Flz8Dl|rT(Hb${pU{7$kOCbM_3+cTS^t zR^B*%JZhm28#L^{bb@2GKV!w`Ppp*ugcU5lo6B8MSBC%}A&&?!?*aT5<~>SQ9|~|5 z)ZPrsvJP}fCmF%AtRP3w3W`O6)T_=DJM9!G+ zA_1l)YJ9ucEtdnYN9j}M85}yATwXBlVy+93o=XiVgRvUpCgb+&f&_hW z>3mXti&-=evZ1JQUd2R5ImvXmV+nf;gO# z@bLJn>g>qJ)xu-ta~gsdp<#L!U)@CBj;W=vkmT9wRql_ovhRyji}`tCfR*ampsH=P zg8V3Kf$?&EJPvx&8V;>j4x+WPH};%j4`*sMK_^y)_rukHQp{&{rYe*k6qmnN5G7Z>Na4NA`8nvG^_%1xZIyJ*oxTiTp6DFug3eo z;R^riTW2F=H$d4i6wwkPp20YnF3X;m>i*W(5y9qd1wQlhT137|g`e?G{}b<}f8l-G z4-TAp1bS>mlQ>J$@oM@J7Xwa(N9@+u?%wg+eTqhR$Mw-#&>*oen8zc)TkCT0rYFWo z)UeR_hXmFYWdXqVUy1QhY!za;+W~xix!7L6pjg)Y8YkJ`8SnPrK1>I zD^hBGuB*{0JsQw9%TZCcMjwbV*lw)&|Hh1c=IXS(`$e7rJ{Oqo7I!mNeB`$xeOJr; z-plULCV0i;+<}<9K)o=?Eff`o%7vGwDQ7blO{U5BSyaI!0`TFbXXK7b`sokIlNYP% z^`AWZ-UIw4iT_%^$5>`(luazAd|{1@pQg$bL%trdcpeX}xPBw--3B zEu51r&p^{TEC$ zyY;3WD1k0_7mvy0)QG%!q^YYuz#*m|OX9W!>!Ydiw+&ofYYuSy)@?;*IrB zMeK4~Sfnzlpb+BTM5}RB&7&Rv? z7CQmU)}HQzMYu=4IX=;ITOdz57qtGP8RS)r6rvWYWQ^b-aP>jpt$OuFi0+S!&K+-;frDuSat8Yxs?s(SZG7LC~Ax zlj23*w){zWmc~kX!&vLG{VPX*jW63ZQl1{^ZRZZG%^X`-_7{Ku81XY^hX2G&)lZng zVq=_PeYGgCbog}ytkqmzSU|k8NlzAlL@9=NXTNY?ATB$2I2PvZe)-Xnhjl#6EmV8b ztm1gi6S1wsY3@=)zvFVgQ%w1$fh z2a}mr(t5L|&{D@(IA`X&{u`WCtCE}N#2r}_IQnZGG@0jrJ*&%;P@mIJ37seBM{QE? zyw0Bcyri;K2V_eTMQ6<}=t@L>za;-}sCG@5oO+3u{*X=);C3Dc^q@1$Q_E zi}T=1dJyFo8ey^UEp1`xrFG&yz+DXlN9aRRTPQHF+8KKHYQ5r$u_EJSZ%*sBCmP`ki2?=|-~v@8b3K1vezR zxK>Qh}@offWG>)^yfv6C@Ac(9MnXpGnkGONt+!;KU16Y_`t_XW6D7i>;myJCR&D+~ zx(n)Jrz!ZQg8gxdTF?OtlOFKP1z`uiH2ZPKp3GNy2IPaV(QoQLI0$ccdROjueT1z4 z32Zm?`#<>Ve{oX31(FS>z^7?^+|%!g^SOJs+Ri2(99T;K@ssr@!FRzg13Da4L8W>1 zP!`POmt47~Ick;R7pz^^_`#Lp50^XL7HCXL?X&FL#h#$0{=HrmP;~eiE7U)+^1t`& zn{RMht+ib}HY~zRP49nI_vP_a=KbH4bcmc|DJfZ#CD~e4mV+cMWRH+&(}Yr#H6&Xa znMftkCQG73*-9vBQd7}Pvb88?Y$Ye*Jn!#y0>Gi}x3>WT*!{mI#}j}z}{D6c`Br8H|yrvhd($UEM6%tTc%EguZ(ponA!_mz2G$n^JAdCv$FHs&fJ;<)jj`c+ zzyyDB;Q$?+GrNT(piR$~h)+e4t*Y1T9ku^=2(2^W-gRqMootO5~v zboihT191y66F{FoF_$RVMAtSNDloOD)PQbm?+RJdCthVmxs1SxkvIj^*AQkSdjLZ&N#ev2NZglsU{lUxl9Jf#NAY<=`~c3iG1*p5+HO z3ef^-ONttC%8@X9@DB(>SkPQci5(`LQn)B*qD^RKO_=hZ^1bYAU*_3-|E{RnVe>0q z_wHMoJkTbnxA?b!5iK+H??3EJFzpAP33SLzFir2^vqihQ})|nC1opUowyTifU6einW(gmj}+e!?_zc+6BzlAP(p1Id4 zY<8cf2a9==CEX4WO?rg9r7{B2nA02EU-*E^=Z+G>-HXYc>heI~Ma5C?QfD+$nKEkB zjZSk?gEcyF$lpVRN@trpy9RT*#Z+YpZep@>Di_)OA&96=9L#D2EaqrPfH=>&(q;uq@NmT2v4YK|x>D*SodD!_ltVUL9;gF^&;+f3h{vnUrc_B9yn z)JZCz-jS1aC24N>JCAoe!-rWX>hkX{ep(bZzt(9tW{`Oui?~rvn3%w?2c9?BW<#u{zLTJL665)Chq-!Z7eA{yS*T(u6;unTG zm2pVc_GxQHWS+-fL=XU>Pc5Y;UhV&7Z6)JNs{-?R=nYt^Ol|xDKOT^@1mO4~O^xoY zKg!5|TyERBJ57xk&Vg&H_E%-h{;i(wJuQ*7xy7cToe;f)DmsE+o_Cy$xCZ?nn=QC` zU<4;%j5+fOZcQCqE5W0+^1bdI^JAsoS2c!M)Vo`vfihah?ijH_%fXiw{>KsR1emr$;gBpIC59PgEufHxBF1IvO1NcN8DFoN60aZI_U|R zq0^7z1XQ!1heDXn+}P6;mzobXpD=FlrxV6+C**7fSWx!{m-pp ztx$>*n7n1U%de!>z)mC1V+V6fHyi9rNflQXEMQ*N7CQ8(Ztr4AwTY%UK?U+rzvJ2{ zJm8yHIKB-H+UItJDP-@Y_lV7?=`Nj|7<#_>y|4BL;bD{ket&?ntRDY$_QOS!gPJV( znDAWppPcv7PyQ@UZ7gcQ^cb=ckv}0+VqCk{>Thf2e>pVr{Yx?RGuN}v_ zW9ILEEBhPdQBZ}Piu3>&768`cwq%Ajx2{AdrQ;aO^9cr{f=071Po5dAneR2^`pe{V z{E~IZ$>S?~M*iWkgr7*3dLES@qDk^2q8m1e&f{NYZ$P;o^k>;LkTSwQg4&mS$MFl6vT*H*0kN%g z|Jqq(#+!cw5=d`H-rS(fB$jqDMuE%n%^zg`Qu2%Sz8dYl%=1v|^}k*eh_0o13|f{t zsU@~XDj(rVEGgx~QKbp_Nhvm#;O;m-iYY?aD^_i4bR{9=x+1**c*^417fAg-YDN_t za@oNxg(~Ht^{i;ar=KcNi`(55ylPX{;!GiE#UX-eR?4UoZGl`xogUX+XfpQ>#Yr;2 zaI`zJn}dGVGqs^@4fLv@GjADFob_#O)auSe%N_N;s>5*h^CfX z`RTXwOyYsD{pQwoo@A$vN78!eL5M{v<~;Vhu%(C=JZ{=; zgiCYDU_A`^k{;J`CS1IpTpi;>H1h7?FJuT!;abF>FoSCn*Nh3KEk8Gr%oSv;WA6+D zLD_RcCj3s~j_;-*bo>p=lS`4yOiYb0I$#Eu5KCGfasK0xGTe)GBV<>Dcv0BH?$jo} zX2dN^&){0LwC7dZ5$rzt{Ne5q_AvQoXD;dP%_5T($)BaU%!IY(KuN~My(%=An~Tc= z%WLRd6ErzrVz+YfxROm?!IOhq#H%-!YaGRLh6%SOk3%;3xaNu6yVxOI56Jh9Kkz5@ z^Ubpo!=||KaPjJ0jGWAfN$hUsa^*6cuGUFjC+?#j-04WSX#(6C^%d~h%+$}B@?_8h z*FMe55u!*O(pn2Ng(Z6A?;YJzjx);umDVI&X$K41QW+aoV`jS9){KmEp+@o&<3bf9 zRz=+$ribW!DX0SbOlG`qp5Rr#%u^)UPhqo)ONi`-WX_P@x_$0k4W`T-8Aqz)W+0Dg zZdyCOqDD{Rn52=u$e#WZ#Ihf(qH#EvwG2~Y96?$5M?f>b{jjf8D!xv&_G2NgU7{_% z``{L&Gx@c%MPQ8q`1MWpgUv1acbk6TBg+3La}XKQFIbtSdyN ztN|zMvvb_N_nRk_T6(Wu3yTL*2(Jm3_P^@U#w>?cdS2yF*CeS^KJ3gPCyZ$~+v$Jw z&Rwm*?n=R9wft<0>zfiq&5E-~tG%!qDSk~yN|=bO@sFt4xA4J3Z=*r{7wkYeJ(bw{ zUSvxHv~mwrbeRE76Bb>U$F(o{`Tx=1|Cd9;|NoEs(2?P>xhvvYq&I$Z8&4j|YcpbQ z5bj*P_Ks9|#)SQ6bD)JdC=Hxo#@IRXa?jX%w0Zki9I}*m6o>R6`ATGt^u>{asMT8w z#EwJ?(pE(kBU>@0{M5IkDU~LcsUy`6t%L2O%N9^+YAkiWpbC3V!R?8Xsm>i%*XBt4 zF-2fn3?qQ~hPtuNETp{n z%kfw2u05Vj4=@>&6I`S3|6V60s9fC$Q-S9}_m-?}`%yEjb*P!K9Hn5@#xXSm4LMKp zdLAOML05;2T*K4AU01oD)4eDyxxUV^uKm>6l!#(TtOF&EkzFAn@v*}FpAM~$rMH@< z@jR4I5?;k((z^VlrauF1h&RCl=GHP=!}RWog??517HaIOzNzKGkFH2$T#}wQhj)eG zB<_&lTgUk4Ft=zVbcIRB`<->i>rMv>=@JampAhrZyopcvJ41O{VNu%yh$f1_e~9l( zQIms=V0)W|m87)|O40>sFwX%95XIv z_m|SMB?q4ENfa^vwZgiT?~~(SosECt+8KR)*B9SU)tNfGzpSydg{JVZxC8G6x97XB ze}UYi5B`lF3-eE%roU>i|I1zChShF@L_SWi=~}5-(V-^G*rvJjIpLzFtg&-9&T$I9 zA~~hpnFRu>b>k&ktY4(bqIaQX2vjOuE*8&ky6o0qV5_UD%j9T(V}BXlOYx|NEWx%p zD=~e(fogak{k}rH7p!NHUOE&M+@9)#33Q`JSaeIK9#hLg$D3PMY$L^$0ZaX1=g8?l@EoZ{=E$X6znvprJ|5d| zq({%K@E>UG327ebG=$+MXtqlUbjlQ04Y_g}CZz{kdWwlzC2FB4VZJ$nJXIF6(4W2s z!UxN(qL#`p*bMV)gy@jG02yA!AIf+>QA*`UXEsVR-AX}dATjfAu#N@c1e!K^hb zeBx3@0AJEsc8fh`Ns(~irL5TKAdV~H zal+|Uj9+C5y#Y8TZSlcUxK@x<)N*Mn>zSTaRN0^w9!P{D>&~tk>V3l4w!^e+;IL>5 zpO!!2%Es>Z5bV<*TPu2_wer30ojjAgg$XByOlTr(=U`-Fk-P_O!yYQQ+7BuO2OT8hbiRop~t=8Jj3oOhB`&@N}K47SS`4dS3Y?ze#Fb zg6N{)RS_Z!Y?)^ZXn|6g!@GQco)_)XNI`FWx43!5-bPyqyFT90;c9YSe1AQV)}5Ez zJI6^&5AMD?b11OCTr5HHG(Fo(Y{ra-D`(14DrWyR{Uxo{tDmRg+Qs$j5rVbErCMcf z3x8hh8Xu;9ft-}zP-nW!I~O(~Rmwn*t<8}u|t+=^R4Ff7neNrmiPb(y=X7>-O zSUAo$s#*te*f4#_4g(bZSKqwF8(rU&%GS4?iDfZAVOnv{TfE8DXJNEl0_4l0Mst7( zZwJp29#f#eI@zTii&YpZll)yXmhl>>8lR(r6_QKm8yP?88kR_zxPI|tGvIveaQF31 z+7XGcP+OPc+81a1*XsSdN7MiQZzXO|+a8+?&+|?l3ey{#mntNXJGS1PN9+B2?Y>nh z#6Myrb)4t}RHhqn*+g&jFM|^`pppppu`XpZ(*Od1URM(>So27kXB&7S_#uoVKUSyl z?Ncu%117=r zrr&I0F)w?DF+SHL%oOl1+2zu%lwMd^Nefy2w3mo`4)%jFsO;GvonBb%IBp#OyeFBP zUaH|97z7&(2{TN47LXK7#immwg=m}C)oH)it%!G3M*{E&G@PjZDSleO<}mvw-G_kV z1t}J+u79SpDE=>aX|qxv_NLY*4x2)E1yNW))0(hV(fdAHVP-j!_xWb?XUU0-t6=aq zOUC;4?rF^?)b6&BYFzu$|Bzm+#`_EjG>MYc#+e9*QE6ypD~f4ha> zPCb|{l+MOsmK>f?k` z0t?W@7dtdd-i+(0nXe6f#*C& zD5cMe`NUk~;iV7ZPiv@`KE?k%mUbtSSt-PHM`z$IB4h>GWP;k#;5oUsir+I#7lxQK zSSJ_!dcv20(*CfM>LZ>Q3z2L?1>`-R z&Mp8cct7Q*e2$2j7`IHIS{;<}Tfw%+^&-%_99e*M|2uIHyd8pgHjPWk)(jK}SmpkIRTwyZO ziEwLb=wgH+Cu|Hd@YgDAVTa7^>;!jnM;3_Dq=4sa+8y<>$kGpK3blo?M+fDg)yPPw zEn{tWS?!`KYit zadQFHNX1XzM%B#N+#ewepwgo}--%nJHS@iO+(r$mFN9BX23!DpDx&*{OHUQ(2fW#} zBT}(pm7wW*Kl73>3i*5In=njL&|bufbdH>`g?9NxBa108cO<4qzp*>vxgKN2t#OHh z{LB-XO%D(@y`Y)7oZ-v~oAzsRB9$aWRKa-NMMs%HN@j3v9)AyDo)*5H!=#N690p@; zE1C;FnT}fzkOk3dn7frr(PS>4jB;6OvE+}E&I!vW{N}RR>IhxI_=YWtD(ZD6@jT(J zVTHuK^HI<>hy;%l@HwGsX%hcsi@ogXD|gmo>K!s1Z1fo1Wtut@+93FwZVq{_E$Q0l zFVfgwl0t%|Jz$C|%X(H0nOwHb)%b%AN_zPN_3t{TTr5-lRWpwLc}F|DOjh`Pzd}5w6zB|__FmzO>?;Es ztSTjuem#JFN24yfZAGGy)38{IlviD>H-!kJ&&0IHdhZdu+idQ$i0mdohWZaX6G;BR zGr|F}q@+}{`Cy!wm-afm^fPj+_*A=sk<_vsnwS+DP^5<^I!N|0()jmwoKEfd6p8T_vG zjvXv+rA`+tKy2^Fs>?A#KGgfve^5o6m|=zorzjRP-Kbm!1QT-Bj{xJi>msA36gL@} zrS*qf-@h^IfJ<`TqGcwP`NI%q@GQJNkwn2%gsvbL-ZBYG>qX;@GJDBcR32whe?rVV z679zQXja<>0o;`xLmOsazF6j%ro1GG?`9zGzZt9c5`~7fq}vUTW0DF}*eRsDBL*~k zRz*$nW61_Q+O4yYc9m37javoRzUV)z;~*{)fwf2(-EZaw6@N?+Zcy)Ou3s%6ab##` z_x|LBJqKpH8y#n_mW2qH9kwNzZgA7MsDdP}xBYtd?R*F9RS?+e1pt0+Xke?VEYci? zeWT3@5-$}$!AgblYmd#T6*|x|rdIxicHC&)%KkD=i|6P&4<$U@x04_onU&Yr(0!h* zdM``i@t2QYJ~}x{;7E)AbsK+x87Zh60&|$tNs|Yk1QO9RO!cd;dReYiu5Q?M7}(&f zo-469YJ1KevQYp{-iS2$m$>x9t-(Y9iq~CY6w=_#k>Y{@ z`;q8v8!MvzIAQbdRHgkcOFks^rxM z$|E$E=MzdYA6qlhqy6QdG~~02d?*1qgkc<2@CI=%2!gTLuIZ-@FJD+c9WTvXa9jWV zTH7hZva}AzmBo}{-<`xjhSlPfI#YHbX#I2o7g$4LCPa0q4N(pA|CD7)^T?iX-G-%m zN}XF6?X#bCmJyTB_Y^cL`S};Gh!QRg^57jnP2mke!Fe;dQ`*@ztcB zo+)wGvzqW_9jV0xDu7yx|C#SP%mg!k;F)0N4?Gj-l9|9u{@a=0%I&fDtlsE7`@M!= zUvN$d_(L$P?J_9$-H!>}XbNZgreItGU%7z0l88 z{nN2W=m5iM%IfP1v!P4O?DDH;&CYgrtc|S8&w165Sp>ed$>I~PoiXQ^C$Ab}OxLr~ z_V2D&%Xn>U2!EbjHg2>_Cff^>Bx&yo(1BeRt*Sn?(pZ1h)eAubS6i|^m;JE--x6}~ zO{6h=&bHt+WT%Uto~ks~ z)((HwjceDu_8&X1{u@W!fA>ckD9+}a^Xff~v32e|TIb*E-Z}o&0@0no#DHS^&lFrB zqZPeb3xGyW!1nQB+;sEzHl7~ws+3nhdpnhsZ>`KmF?5G%eH~sr6E@Sir`_jL@_ck| zx0T;8myY^$MMi51=WvSKhP-3u z3Ml@(Ken{cWZTzt5&LpukMy${M|JTqgei`dDZ0?d%UaPT%j{Nzy2^#cBCmV=WkA4; zADLJsb{f2RH%+6eRYeDF5J89QFME?-D-|$ zW$uhav3|iUKf%bnd~bo30H5O^}>yJA3WtJ9CW=>@W@@gQ_I4;^W%qoBzja zrg0?+CaTy+Q8l}&^%LM%;S2N}jFNp`5b=H?J08MT#E6~jnsrSi;QiEiqcYa=X@yq( zjIEch66NwCJNH*s49@e&r}lu_*<_3N{cOS2ck~m;H0%-nie)fK?>nAPs3~=9%><9u z%y;^C0sVzm*~XjPk@=v4U56aI1lYdAsU(cE>6LHX+ks16KFd0L^UiFU@Iut?d9`Iz zm|6xa2taDKi}X<$O-gYRR@6i@Sojqt^W#HM)hf+ke%~FORU(r(t3<@Ft^JiV--RDpFoh<8ckZDz3G=HD($;N)xgVSr*SHJGdXn*$Sf_Fh+?aGXd z+7tP14t4cIe~ezJGwrnc-By7wG6HiBFZ>A9hIn(Ho0VQ8{53&|?lyl}?{{C1d( zeXpmqKV*boyj>S7``fI?HdHcEpsvm)%M@&4U~Ek{h3~CAS8(F*yQJo5>wGxFM~+KV zj}6WbSt2IMg>^O)Y|&@*7$QHgmKmC>9~PgH`hjP$#bg!>Dg1U8>t~GZH)lrs%|Cl5 zRJ*)esDNK&%TWD>d`RNsJ!ntiG$E4#zEk^)Fqo4_sJWE$YQKlIJj3O zxLfsGk8{r1)3EThd*9{>j~-q4btUA!+Sphw_R#W;*XzmV#Jo+o*CgKyO6jT5#{{{u zWR?wYm^-|%2*+y7VRU!d%|;LjUoJF2D$e94Ecx&&*w+@IRzlbmSG zpE-Da;MXJ7q-XBy>n77fgeD4iILt7;T~VaV_h~BAXXZJt1z4a}dTFbj^P$PZ$K4H~p zzb#F)+kz&?lO_Z4w5m8q;B!jiI60W0sOU713DEDaI>|06qo^8$D}1ngrX=;V%aj`P zMl9DW8?^Fyi7Cyj;3eHHvh*WKh$3BVVaHqf&%{nMEOz6=ZsDp+^rxm}EbFe=Yfjom zwu#qS&|U~8_4VS~m*)N7d*r`6DgC#9&e`R?1JTcn-KEKx zd|+&y-x{s+?{@|mZBupcDXH{JAyR;X9(&eJ8aMo7LDPS&xAIBcM*>uo9M97+*Cm_)_e>R|`)>=Fl9t=o=lk=9?1+>bl`c8NxtC6)y2z;_0_^*?F}S z`NBBdl^=JEwfPmV!tjXHOImtKoZxeH`v zWQ?Azgig<(Tr8R-Lc(bl4JTQlhGe$DE#;>?ebQ>Dx*Ov4YOBSZK zV$JL1hAX#UBw*JowemIYjE>X=1-FeGX~(&wH=4{_U#773uVmNA1$TsYHqs9cME3gZ z^6~chU@rJ5`Tkj&?-2&ZvIRcOsGt-5tYjQmPvXEzLJM-t#iqxyr6tGee1FF zN7yQe$4c;x_dAjgjGFmj=NbABJkL;?+Fw?EGtV%VZ5(sY{)-%VXizHmeJ&(|+Ondu zqL2rIn|>;a-rChNoVhrA6Elu4jxU8pQn}3uVPKwWkjt0AE@wELUOOA7WhS+O5rKCT zvzhkl!Q){dl3n1$hSx^IfU*L`u7F(5?Rx!4OD>xODtiY$nPk<4|hUOhuRQ zvG3}l>Y}RAzHP=!DXq!a6BDI1p}fML2FKMdnRZM~M%-B3AFO_#q3vq}O9s8>+Ryl` zM(nwJH9bm>Z};+ynZItcVZ2Ep_CuEE%S^nRv>GgVa9ALdGp zA~DeqwvplsSdgH6;HD`A|;+>`93s#-NhEip2rrY zP(C4WO~n@22U{_72}voyDr@DtPzuyQDNypFBGgVqQ?)gVdKzHrFc2kUf~bZb#su;i z?{Z9i_={S9kD+)c!Y4qqq{PS;JU&?fF z29Mk!JiNHg;;f=Zr4!PE*@Fuu|I#gVCljmMm z^t#{sL9GIhk?ApivXVjAfI@WnhVs{e-O&x6xsBvkA)!!27VF`Bp}AfkIF@GW zoMooiU|yKjr-%2FFwx~)5u2B9@XKlDxmXyv>FHVyBl+s-DTfVvX6=zDWaB0VpGjs^ zE)0e(c- zBUt5cXGV@{Y20MFQ(F=deq~hZfFkI0TXgtKYfed*<7cXojmjt;za+11S|I#4KuZh_MKvFGQPQHTc~ia|wG+m*FR}kGo;~ak zz(Pau<=vT3GN+)Y#HbH5fR*_fvp*pU8QJ(!S(i`j z5{F!}tgt!=J2LfW_U{gp`Ez7?5B<>ZM>5)JvCiD4;jza56#E}`Z8j5lYUNS~5&}su z4oM`;I9Nq|4il|N1#I49(7(epB-#s^d;^i6K@^iL?T$8Y#kLWJ z7;!F>AYI8)<^1fxix2#=u4zk7MnL767pcHfZ~WEO^YBgjg12nHAzZtT=-=I|odeIF z)uRz)sMo~FK96OGIY-Gw$6PGWSJ)c?+bTX# zR)R-s<$DdY(Kbh<{j)R5PL>tjzkhh#4OZC06A%+{wzx3(#6aN;<9EUd{a=32GgI}# z9%OY8Apt$y^XpQpjdQASzKg>7?(qYQucQquh3wG8Vkx=_-p!xm6Nr-hc6A! zUSF5_ykLXpp^rWSNY>q`b(ELb zKtXVxZ`&~OW@%{uUCsQ@&>@U|6!pU)X{wZ>gGkk6r#@|6q+WBj7AdutIMH>Ns++ie z9{>6&=eYOD|6x?v&BmmK-A}}0}hQcahE|>4B$`;FP@I1JooIZ4`nQH~tWNzAm{>B#7 z<9a;H)wxnVA6)>`m-F2|5zg`la-Jak3&bR}5GE)q5!sMI85_LrBA#KV-mI~y>RILF zBU<5m!RL2NPd)Xyj>^9d%2={_BstR3`9`@Om?4M~J~e7$lz3_sV1RH%@)_^9nadGt z9Mtm9NWl70eUc|b?Nzc`a_y^>IbI9yE|bK+3&da76w^8ojvXcLt?r<%hZmjj_q&id zH5z$!Ve@Hs);YJ`5Iznaxx?jokFGWvf@y;gW%&tyf8YDK_IXPG8|nC49ThWA*5bZ=NE{oH*R{(yH;CPCsMMK zE-k#(UO?#draSqALm;xlXD*ooXcWT6Dv9;P0G_0HW%Z9~bToNW;Q}WO} z#&fsPTekjmM(6A1snGXa%TTj2feOmLrj>^*yH^q&1*OKvT6 z_-*UJrQ(zKzd8$AqU!P^!pL*~eKg0N5;Y#ff!=mSBtIzra?fM=rhsBROgX#WczBAB ztCXq;D^DQQzKu)4)GHw|dc4z5ZiZ*CwZ~$pWxg# zuEz?(rIAQ_!(eaXx#sviq9fU~TI8ujriI!mTZbp5{Vku<*jM3URPye z-sUh6P`wD zU}O_uB0WiLUrcN@X8>?FKeG;2>@Z!P88iLO+-;X)GhkZ{@AL`djQDe{@1o7}6_nCL z1-{OXB`tFvNRN{}&Si0@Qe(AMXIU_oxZUTGH=n{cP{J-3+x-gk7D0^29tC`Ul}qO= zOSdBVGt-Cz+(-FfbY~}xS8K+rw_9d*lB{I-P`qa|g|SQl2QZQ}jsbD088s(zU;t`j zbmkY9z?||vI*=a5Q5F6J1(=HF?w*mLG`Q^i@&0T~Zv*Y^q9(_CY)ZNuR=cksw6Qw* zYrVCNjg!Umyyf)S0|`!8{1oxd*wAMOQ7zAVJQV?-R0thJ9rT1t`U$;qk@M_dNOpzk z+NO|t*Vg(sY6gj=u{cV0f6^5-?uxjN=1#epu!X$y%+?xg1MJ-zOEj(69e3jc*HLM! zaNn*8>g=|>$2k8B8n0K2$R{pr2$nqV552OCL@q8$+BqX*e6HVPUfbhe9v)EcJ&*OA zwpr`$g{r&xzp0PQ#3|7mSn)w%6W|AcbNyb-64;diIl2i&3 zAiE^1`Wz5OY-PpCGEz5l{tKZ*rW0)G7+^I?t;TfLiFK?!wE!z(kH57 zpjz$#_Md)}FaR-Tj}%u`$8dHY(25)S@w(5b>Y4f3FaGysFB_K5aCoxX41nLv&M(Eo zf$H2V5l~3rP_m`4*o>l=#sUT6iYwfar~J3wr5Es*?yF>Pb(EWpO=43{(4_djoVuHL zb{@p1yxixoQ{SF*T<%29R9bnVhsssJ1NM~O-SZs@G2*>1t4%qYCWpe&)a&I6(?~=w zpoDlL1GROZa|ze}U(Tt0{5rvc)%!dVia!c!`5g&n>w=gs7YP{~~Iq!f$uAq?<%Z1kEdwcES zb|GCzNGDEA39U^)2mAkRpQB@`!beEYOLL+Jph$QIW{SSFgWJzub0vqVk~|0WYvc2V1aG z@4MxL|Ir;=aDj*X7rgLl$hk&Y0QPUq8ZszxOVz6(umg`tqwiiy3SBiw0K}QfZSa-FDpx%c(Bc8woU3vt_8ih4GUJ+F$)zCE_i^+xObd;K*q z?AsFw@?`})ga0~qAB>VDNBMY8b;v~zDlo@ZTG(V~sp zEqR3&?mDldrkndSS2B0QtSZO8eFWs*;2tJ(>g%WzmIRC}#+kbtZNni) zHi5bDx{J&uUR$Il6c*kvi!jlkCf&6@FLn+uyb&1KwewRy_PtS#E81?qvKRl*`sin< zUL=;9za!0PyjbY?M<6{!oJ+dA`0@6%RVP%FcSJ5Q5K4qz73rvFq=#V0ok{PRDc7T2 zm#?o0OZNJ@(wjMfWzUY|X_xqhmFCrXq7hH9T3HJPJP}Tuk35=$(<=WH{v$Wd+>VCg z+UNayyOLE~xA^;<9XYyU`;KqkE*P?q5|Zmdrr*VH1d30-w)5bM(-iJvJcTGjqNr6> zY{4w@V9JcW<^>mRqz{|FKF_h{&w0~4Sc%48BHk;ptlgCPj+D^dz>~EZudVyg5p-~C z=o|=t6m)=SCrb*|aEa(@jI9~x(VF>A%Pe5&e6jqc zb-u)ey%h?(K6ZUYIb?;Ek*lo`bVp3tGi*Lbvdk;($6p=B#T%S9HCVLUmp~ca+;5$y zweuFs_C$Btl!Zwby=)xpq=!T|`4xppmw)BYB)e#KBM3Nln~LB2eZc<@pF&r!zAh;PVGeHytf?MIjP;oK)}*-Cp|l`P@$yDFMW6T z`xC6$_>)UyrAXMRli<1<|QZy6~8k|wVu@gR~A~OqjYa!_hn@UQPP~$X@&Qng==4Q`QMn| V|Mltbzxq!Nx{boa0<>A{{twItl4Jk? literal 0 HcmV?d00001 diff --git a/examples/runner/live_grayscale/_fixtures/README.md b/examples/runner/live_grayscale/_fixtures/README.md new file mode 100644 index 0000000..65c208c --- /dev/null +++ b/examples/runner/live_grayscale/_fixtures/README.md @@ -0,0 +1,16 @@ +# Test fixtures + +`0` — 51 KB synthetic MPEG-TS segment used by `test.sh`'s real-data smoke +(served by `_smoke_server.py`). 2 seconds, 320x240, H.264 yuv420p, video only. + +Synthetic content from ffmpeg's `testsrc` lavfi generator — content +doesn't matter, only that it's valid MPEG-TS the runner can decode. + +## Regenerate + +```bash +ffmpeg -y \ + -f lavfi -i "testsrc=size=320x240:rate=30:duration=2" \ + -c:v libx264 -preset ultrafast -tune zerolatency -pix_fmt yuv420p \ + -f mpegts _fixtures/0 +``` diff --git a/examples/runner/live_grayscale/_smoke_server.py b/examples/runner/live_grayscale/_smoke_server.py new file mode 100644 index 0000000..03ddff1 --- /dev/null +++ b/examples/runner/live_grayscale/_smoke_server.py @@ -0,0 +1,28 @@ +"""Trickle-aware fixture server for test.sh's real-data smoke. + +Serves _fixtures/0 with `Lp-Trickle-Seq: 0` for /-2/-1/0 (matching +TrickleSubscriber's `start_seq=-2`). 404 elsewhere — natural EOS. +""" + +import http.server +import os + +_FIXTURE = open(os.path.join(os.path.dirname(__file__), "_fixtures", "0"), "rb").read() + + +class _Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: + if self.path in ("/-2", "/-1", "/0"): + self.send_response(200) + self.send_header("Content-Type", "video/MP2T") + self.send_header("Content-Length", str(len(_FIXTURE))) + self.send_header("Lp-Trickle-Seq", "0") + self.end_headers() + self.wfile.write(_FIXTURE) + else: + self.send_response(404) + self.end_headers() + + +if __name__ == "__main__": + http.server.HTTPServer(("0.0.0.0", 8080), _Handler).serve_forever() diff --git a/examples/runner/live_grayscale/docker-compose.yml b/examples/runner/live_grayscale/docker-compose.yml index c752fa3..3a9a4dd 100644 --- a/examples/runner/live_grayscale/docker-compose.yml +++ b/examples/runner/live_grayscale/docker-compose.yml @@ -44,6 +44,10 @@ services: container_name: live_grayscale ports: - "5000:5000" + # Lets the runner reach the host's localhost (where test.sh runs the + # fixtures HTTP server during the real-data smoke). + extra_hosts: + - "host.docker.internal:host-gateway" # Healthcheck waits for the LivePipeline state machine to reach OK after # setup() completes. Without this, register_capability could complete and # the test could fire before /stream/start would succeed. diff --git a/examples/runner/live_grayscale/test.sh b/examples/runner/live_grayscale/test.sh index 785ad46..9c22e92 100755 --- a/examples/runner/live_grayscale/test.sh +++ b/examples/runner/live_grayscale/test.sh @@ -1,12 +1,9 @@ #!/usr/bin/env bash -# E2E lifecycle smoke test for the LivePipeline real-time runner. +# E2E smoke test for the LivePipeline real-time runner. Two parts: +# - Lifecycle through the BYOC stack (gateway → orch → runner). +# - Real-data feed direct to runner with a pre-created MP2T segment. # -# Asserts that a stream session establishes through the full BYOC -# stack: caller (curl) → gateway → orchestrator → runner /stream/start, -# and tears down cleanly via /process/stream/{id}/stop. -# -# Does NOT push media. Pushing ffmpeg-generated MP2T and asserting -# grayscale output is a follow-up (see TODO at bottom). +# Grayscale correctness is verified by the demo.py script. set -euo pipefail cd "$(dirname "$0")" @@ -53,8 +50,7 @@ fi sleep 1 echo "Asserting runner received /stream/start..." -# uvicorn access log format: 'POST /stream/start HTTP/1.1" 200 OK'. -# We assert 200 specifically so a 422 / 4xx still fails the test. +# Match status 200 explicitly — naive grep would pass on 4xx too. if ! docker logs live_grayscale 2>&1 | grep -qE 'POST /stream/start HTTP/1\.1" 200'; then echo "FAIL: runner didn't accept /stream/start (200 not seen)" docker logs live_grayscale 2>&1 | tail -20 @@ -75,10 +71,44 @@ if ! docker logs live_grayscale 2>&1 | grep -qE 'POST /stream/stop HTTP/1\.1" 20 exit 1 fi +# Phase 2: real-data smoke — feed a pre-created MP2T segment direct to the +# runner. Verifies bytes were fetched + no frame-processor errors. + +# Trickle-aware fixture server on :8080 (handles start_seq=-2 + Lp-Trickle-Seq). +# Runner reaches the host via host.docker.internal. +SERVER_LOG=$(mktemp) +python3 -u _smoke_server.py > "${SERVER_LOG}" 2>&1 & +SERVER_PID=$! +trap "kill ${SERVER_PID} 2>/dev/null; rm -f ${SERVER_LOG}" EXIT +sleep 0.5 + +echo "Starting real-data session against host fixtures server..." +curl -fsS -X POST -H "Content-Type: application/json" \ + -d '{"gateway_request_id":"smoke","control_url":"http://nope/c","events_url":"http://nope/e","subscribe_url":"http://host.docker.internal:8080","publish_url":"http://nope/p","params":null}' \ + http://localhost:5000/stream/start +echo + +# Give the runner time to GET /-2 (start_seq=-2), decode, run process_video, +# and hit /1=404 = EOS. +sleep 3 + +echo "Asserting fixtures server received GET /-2..." +if ! grep -qE '"GET /-2 HTTP/1\.1" 200' "${SERVER_LOG}"; then + echo "FAIL: fixtures server didn't serve GET /-2 — runner didn't fetch the segment" + cat "${SERVER_LOG}" + exit 1 +fi + +echo "Asserting no frame-processor errors in runner logs..." +if docker logs live_grayscale 2>&1 | grep -qE "process_video failed|process_audio failed"; then + echo "FAIL: runner reported a frame-processor failure" + docker logs live_grayscale 2>&1 | grep -E "process_video failed|process_audio failed" | tail -5 + exit 1 +fi + +echo "Stopping real-data session..." +curl -fsS -X POST http://localhost:5000/stream/stop +echo + echo "PASS" exit 0 - -# TODO: extend with ffmpeg-driven media verification — push MP2T to WHIP, -# pull from WHEP, assert UV chroma planes are flat (= grayscale). Spike- -# risk; landing the lifecycle smoke test first proves the wire and lets -# us iterate on media verification independently. From d52bd84aa739a3ca7dfc0bd69151667952b9b501 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Mon, 4 May 2026 15:42:08 +0200 Subject: [PATCH 19/21] refactor(live_grayscale): full E2E via mediamtx, drop direct-runner smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the synthetic-fixture/direct-runner smoke from 8beabc1 with a single E2E test that pushes a colored stream through the full BYOC stack (ffmpeg → mediamtx → gateway → orch → runner → orch → mediamtx → ffmpeg) and asserts non-empty bytes come back. Adds demo.sh: same path with webcam input + ffplay output, so users can visually verify the GrayscaleFilter works on their own video. Drops _fixtures/, _smoke_server.py, extra_hosts. Adds mediamtx service + LIVE_AI_PLAYBACK_HOST env on gateway. Comment marks both scripts for post-PR-#6 migration to start_byoc_job (the customer-flow SDK). Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/runner/live_grayscale/README.md | 44 ++++++-- examples/runner/live_grayscale/_fixtures/0 | Bin 52076 -> 0 bytes .../runner/live_grayscale/_fixtures/README.md | 16 --- .../runner/live_grayscale/_smoke_server.py | 28 ----- examples/runner/live_grayscale/demo.sh | 40 +++++++ .../runner/live_grayscale/docker-compose.yml | 16 ++- examples/runner/live_grayscale/test.sh | 104 +++++------------- 7 files changed, 111 insertions(+), 137 deletions(-) delete mode 100644 examples/runner/live_grayscale/_fixtures/0 delete mode 100644 examples/runner/live_grayscale/_fixtures/README.md delete mode 100644 examples/runner/live_grayscale/_smoke_server.py create mode 100755 examples/runner/live_grayscale/demo.sh diff --git a/examples/runner/live_grayscale/README.md b/examples/runner/live_grayscale/README.md index 968f288..e9fd8ef 100644 --- a/examples/runner/live_grayscale/README.md +++ b/examples/runner/live_grayscale/README.md @@ -39,32 +39,35 @@ docker compose down ```mermaid sequenceDiagram autonumber - participant curl + participant ffmpeg + participant mediamtx participant gateway participant orchestrator participant live_grayscale as live_grayscale
(SDK container) - curl->>gateway: POST /process/stream/start + ffmpeg->>gateway: POST /process/stream/start (Livepeer envelope) gateway->>orchestrator: forward (signed) orchestrator->>live_grayscale: POST /stream/start (subscribe_url, publish_url, …) live_grayscale-->>orchestrator: 200 - orchestrator-->>gateway: stream URLs - gateway-->>curl: { whip_url, whep_url, stream_id, … } + gateway-->>ffmpeg: { rtmp_url, rtmp_output_url, stream_id, … } - Note over live_grayscale: GrayscaleFilter.on_stream_start(params) - Note over live_grayscale: loop: subscribe → process_video → publish + ffmpeg->>mediamtx: RTMP push (rtmp_url) + mediamtx->>orchestrator: trickle ingest + orchestrator->>live_grayscale: trickle subscribe → frames + Note over live_grayscale: GrayscaleFilter.process_video zeros U/V + live_grayscale->>orchestrator: trickle publish → frames + orchestrator->>mediamtx: trickle egress + mediamtx-->>ffmpeg: RTMP pull (rtmp_output_url) - curl->>gateway: POST /process/stream/{id}/stop - gateway->>orchestrator: forward - orchestrator->>live_grayscale: POST /stream/stop - live_grayscale-->>orchestrator: 200 + ffmpeg->>gateway: POST /process/stream/{id}/stop ``` -Four compose services: +Five compose services: | Service | What it is | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `gateway`, `orchestrator` | `livepeer/go-livepeer:master` from Docker Hub | +| `mediamtx` | RTMP/WHIP/WHEP frontend the gateway points at via `LIVE_AI_PLAYBACK_HOST`. Caller pushes RTMP here; processed output served back as RTMP. | | `live_grayscale` | The pipeline container — a [BYOC](https://github.com/livepeer/go-livepeer/blob/main/doc/byoc.md) capability built with `livepeer_gateway.runner.LivePipeline`. | | `register_capability` | One-shot helper that POSTs to `orchestrator:8935/capability/register` once `live_grayscale` is healthy | @@ -73,6 +76,25 @@ The pipeline service has a healthcheck that probes `GET /health` until waits on `service_healthy`, so the orchestrator never sees a "registered but not loaded" container. +## Try it yourself with your webcam + +`demo.sh` pushes your local webcam through the same path as test.sh and +opens an `ffplay` window with the grayscale output: + +```bash +docker compose up -d --wait --build +./demo.sh # press 'q' in the player to stop +docker compose down +``` + +Webcam input depends on OS — set `WEBCAM_FLAGS` to override the Linux default: + +| OS | `WEBCAM_FLAGS` | +| ------- | ------------------------------------------- | +| Linux | `-f v4l2 -i /dev/video0` | +| macOS | `-f avfoundation -i 0` | +| Windows | `-f dshow -i video=YourCameraName` | + ## Wire contract (the parts that matter) `POST /process/stream/start`'s `Livepeer:` header carries the job diff --git a/examples/runner/live_grayscale/_fixtures/0 b/examples/runner/live_grayscale/_fixtures/0 deleted file mode 100644 index b01acbee7ba8f189c59be6816f0d6c1a6506bcfe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 52076 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 V|Mltbzxq!Nx{boa0<>A{{twItl4Jk? diff --git a/examples/runner/live_grayscale/_fixtures/README.md b/examples/runner/live_grayscale/_fixtures/README.md deleted file mode 100644 index 65c208c..0000000 --- a/examples/runner/live_grayscale/_fixtures/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Test fixtures - -`0` — 51 KB synthetic MPEG-TS segment used by `test.sh`'s real-data smoke -(served by `_smoke_server.py`). 2 seconds, 320x240, H.264 yuv420p, video only. - -Synthetic content from ffmpeg's `testsrc` lavfi generator — content -doesn't matter, only that it's valid MPEG-TS the runner can decode. - -## Regenerate - -```bash -ffmpeg -y \ - -f lavfi -i "testsrc=size=320x240:rate=30:duration=2" \ - -c:v libx264 -preset ultrafast -tune zerolatency -pix_fmt yuv420p \ - -f mpegts _fixtures/0 -``` diff --git a/examples/runner/live_grayscale/_smoke_server.py b/examples/runner/live_grayscale/_smoke_server.py deleted file mode 100644 index 03ddff1..0000000 --- a/examples/runner/live_grayscale/_smoke_server.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Trickle-aware fixture server for test.sh's real-data smoke. - -Serves _fixtures/0 with `Lp-Trickle-Seq: 0` for /-2/-1/0 (matching -TrickleSubscriber's `start_seq=-2`). 404 elsewhere — natural EOS. -""" - -import http.server -import os - -_FIXTURE = open(os.path.join(os.path.dirname(__file__), "_fixtures", "0"), "rb").read() - - -class _Handler(http.server.BaseHTTPRequestHandler): - def do_GET(self) -> None: - if self.path in ("/-2", "/-1", "/0"): - self.send_response(200) - self.send_header("Content-Type", "video/MP2T") - self.send_header("Content-Length", str(len(_FIXTURE))) - self.send_header("Lp-Trickle-Seq", "0") - self.end_headers() - self.wfile.write(_FIXTURE) - else: - self.send_response(404) - self.end_headers() - - -if __name__ == "__main__": - http.server.HTTPServer(("0.0.0.0", 8080), _Handler).serve_forever() diff --git a/examples/runner/live_grayscale/demo.sh b/examples/runner/live_grayscale/demo.sh new file mode 100755 index 0000000..dc976de --- /dev/null +++ b/examples/runner/live_grayscale/demo.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Push your webcam through the GrayscaleFilter and view the output. +# Run after `docker compose up -d --wait --build`. +# +# Webcam input depends on OS — set WEBCAM_FLAGS to override the default: +# Linux (default): WEBCAM_FLAGS="-f v4l2 -i /dev/video0" +# macOS: WEBCAM_FLAGS="-f avfoundation -i 0" +# Windows: WEBCAM_FLAGS='-f dshow -i video=YourCameraName' +# +# TODO: post-PR-#6, replace the gateway+mediamtx ingestion with +# `start_byoc_job` from `livepeer_gateway.byoc`. + +set -euo pipefail +cd "$(dirname "$0")" + +GATEWAY_URL="${GATEWAY_URL:-http://localhost:9935}" +WEBCAM_FLAGS="${WEBCAM_FLAGS:--f v4l2 -i /dev/video0}" + +LIVEPEER_HDR=$(printf '%s' \ + '{"request":"{}","parameters":"{\"enable_video_ingress\":true,\"enable_video_egress\":true}","capability":"live-video-to-video","timeout_seconds":300}' \ + | base64 -w0) + +RESPONSE=$(curl -fsS -X POST "${GATEWAY_URL}/process/stream/start" \ + -H "Livepeer: ${LIVEPEER_HDR}" -d '{}') + +STREAM_ID=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['stream_id'])") +RTMP_IN=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['rtmp_url'])") +RTMP_OUT=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['rtmp_output_url'].split(',')[0])") + +trap 'curl -fsS -X POST "${GATEWAY_URL}/process/stream/${STREAM_ID}/stop" -H "Livepeer: ${LIVEPEER_HDR}" -d "{}" >/dev/null 2>&1 || true' EXIT + +echo "Pushing webcam → ${RTMP_IN}" +echo "Opening viewer ← ${RTMP_OUT}" +echo "Press 'q' in the player window (or Ctrl-C here) to stop." + +ffmpeg -loglevel error ${WEBCAM_FLAGS} \ + -c:v libx264 -preset ultrafast -tune zerolatency \ + -f flv "${RTMP_IN}" & + +ffplay -loglevel error "${RTMP_OUT}" diff --git a/examples/runner/live_grayscale/docker-compose.yml b/examples/runner/live_grayscale/docker-compose.yml index 3a9a4dd..647aa83 100644 --- a/examples/runner/live_grayscale/docker-compose.yml +++ b/examples/runner/live_grayscale/docker-compose.yml @@ -32,10 +32,22 @@ services: -httpAddr=0.0.0.0:9935 -httpIngest -v 6 + environment: + LIVE_AI_PLAYBACK_HOST: rtmp://mediamtx:1935 ports: - "9935:9935" depends_on: - orchestrator + - mediamtx + + # Media server that fronts the BYOC stream gateway: caller pushes RTMP to + # `rtmp_url` (= mediamtx), gateway forwards to runner; runner's egress + # comes back through orch → mediamtx, served on `rtmp_output_url`. + mediamtx: + image: bluenviron/mediamtx:latest + container_name: mediamtx + ports: + - "1935:1935" # RTMP — both test.sh/demo.sh on host and gateway in compose use this live_grayscale: build: @@ -44,10 +56,6 @@ services: container_name: live_grayscale ports: - "5000:5000" - # Lets the runner reach the host's localhost (where test.sh runs the - # fixtures HTTP server during the real-data smoke). - extra_hosts: - - "host.docker.internal:host-gateway" # Healthcheck waits for the LivePipeline state machine to reach OK after # setup() completes. Without this, register_capability could complete and # the test could fire before /stream/start would succeed. diff --git a/examples/runner/live_grayscale/test.sh b/examples/runner/live_grayscale/test.sh index 9c22e92..44194cf 100755 --- a/examples/runner/live_grayscale/test.sh +++ b/examples/runner/live_grayscale/test.sh @@ -1,14 +1,19 @@ #!/usr/bin/env bash -# E2E smoke test for the LivePipeline real-time runner. Two parts: -# - Lifecycle through the BYOC stack (gateway → orch → runner). -# - Real-data feed direct to runner with a pre-created MP2T segment. +# E2E test for the LivePipeline real-time runner. +# Pushes a synthetic colored stream through the BYOC stack and asserts +# the runner produces non-empty output bytes. # -# Grayscale correctness is verified by the demo.py script. +# Path: ffmpeg push (RTMP) → mediamtx → gateway → orch → runner → orch → mediamtx → ffmpeg pull (RTMP) +# +# TODO: post-PR-#6, replace the gateway+mediamtx ingestion with +# `start_byoc_job` from `livepeer_gateway.byoc` (same chroma assertion, +# customer-flow E2E without the deprecated gateway endpoints). set -euo pipefail cd "$(dirname "$0")" GATEWAY_URL="${GATEWAY_URL:-http://localhost:9935}" +OUTPUT_FILE="${OUTPUT_FILE:-/tmp/live_grayscale_output.ts}" echo "Waiting for capability registration..." for _ in $(seq 1 60); do @@ -27,88 +32,31 @@ LIVEPEER_HDR=$(printf '%s' \ echo "Starting stream session..." RESPONSE=$(curl -fsS -X POST "${GATEWAY_URL}/process/stream/start" \ - -H "Livepeer: ${LIVEPEER_HDR}" \ - -H "Content-Type: application/json" \ - -d '{}') - -echo "Response: ${RESPONSE}" + -H "Livepeer: ${LIVEPEER_HDR}" -d '{}') STREAM_ID=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['stream_id'])") -WHIP_URL=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('whip_url',''))") -WHEP_URL=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('whep_url',''))") +RTMP_IN=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['rtmp_url'])") +RTMP_OUT=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['rtmp_output_url'].split(',')[0])") echo " stream_id=${STREAM_ID}" -echo " whip=${WHIP_URL}" -echo " whep=${WHEP_URL}" - -if [ -z "${STREAM_ID}" ] || [ -z "${WHIP_URL}" ] || [ -z "${WHEP_URL}" ]; then - echo "FAIL: missing stream URLs in response" - exit 1 -fi +echo " rtmp_in =${RTMP_IN}" +echo " rtmp_out=${RTMP_OUT}" -# Give the runner a moment to receive the orchestrator's /stream/start. -sleep 1 +trap 'curl -fsS -X POST "${GATEWAY_URL}/process/stream/${STREAM_ID}/stop" -H "Livepeer: ${LIVEPEER_HDR}" -d "{}" >/dev/null 2>&1 || true; rm -f "${OUTPUT_FILE}"' EXIT -echo "Asserting runner received /stream/start..." -# Match status 200 explicitly — naive grep would pass on 4xx too. -if ! docker logs live_grayscale 2>&1 | grep -qE 'POST /stream/start HTTP/1\.1" 200'; then - echo "FAIL: runner didn't accept /stream/start (200 not seen)" - docker logs live_grayscale 2>&1 | tail -20 - exit 1 -fi - -echo "Stopping stream..." -curl -fsS -X POST "${GATEWAY_URL}/process/stream/${STREAM_ID}/stop" \ - -H "Livepeer: ${LIVEPEER_HDR}" \ - -d '{}' +echo "Pushing synthetic colored stream..." +ffmpeg -loglevel error -re \ + -f lavfi -i "testsrc=size=320x240:rate=30:duration=5" \ + -c:v libx264 -preset ultrafast -tune zerolatency \ + -f flv "${RTMP_IN}" & -sleep 1 +echo "Pulling processed stream..." +ffmpeg -loglevel error -i "${RTMP_OUT}" -t 5 -c:v copy "${OUTPUT_FILE}" -echo "Asserting runner received /stream/stop..." -if ! docker logs live_grayscale 2>&1 | grep -qE 'POST /stream/stop HTTP/1\.1" 200'; then - echo "FAIL: runner didn't accept /stream/stop (200 not seen)" - docker logs live_grayscale 2>&1 | tail -20 +if [ ! -s "${OUTPUT_FILE}" ]; then + echo "FAIL: no bytes received from gateway egress" exit 1 fi -# Phase 2: real-data smoke — feed a pre-created MP2T segment direct to the -# runner. Verifies bytes were fetched + no frame-processor errors. - -# Trickle-aware fixture server on :8080 (handles start_seq=-2 + Lp-Trickle-Seq). -# Runner reaches the host via host.docker.internal. -SERVER_LOG=$(mktemp) -python3 -u _smoke_server.py > "${SERVER_LOG}" 2>&1 & -SERVER_PID=$! -trap "kill ${SERVER_PID} 2>/dev/null; rm -f ${SERVER_LOG}" EXIT -sleep 0.5 - -echo "Starting real-data session against host fixtures server..." -curl -fsS -X POST -H "Content-Type: application/json" \ - -d '{"gateway_request_id":"smoke","control_url":"http://nope/c","events_url":"http://nope/e","subscribe_url":"http://host.docker.internal:8080","publish_url":"http://nope/p","params":null}' \ - http://localhost:5000/stream/start -echo - -# Give the runner time to GET /-2 (start_seq=-2), decode, run process_video, -# and hit /1=404 = EOS. -sleep 3 - -echo "Asserting fixtures server received GET /-2..." -if ! grep -qE '"GET /-2 HTTP/1\.1" 200' "${SERVER_LOG}"; then - echo "FAIL: fixtures server didn't serve GET /-2 — runner didn't fetch the segment" - cat "${SERVER_LOG}" - exit 1 -fi - -echo "Asserting no frame-processor errors in runner logs..." -if docker logs live_grayscale 2>&1 | grep -qE "process_video failed|process_audio failed"; then - echo "FAIL: runner reported a frame-processor failure" - docker logs live_grayscale 2>&1 | grep -E "process_video failed|process_audio failed" | tail -5 - exit 1 -fi - -echo "Stopping real-data session..." -curl -fsS -X POST http://localhost:5000/stream/stop -echo - -echo "PASS" -exit 0 +OUTPUT_SIZE=$(stat -c %s "${OUTPUT_FILE}" 2>/dev/null || stat -f %z "${OUTPUT_FILE}") +echo "PASS (${OUTPUT_SIZE} bytes received)" From 9ef95d9ccee1542072198444362e7f58166a9d24 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Mon, 4 May 2026 18:14:23 +0200 Subject: [PATCH 20/21] refactor(examples): fail-fast on missing capability registration The 60-iter poll loop in each test.sh was redundant given the documented prereq is `docker compose up -d --wait --build`. Replace with a single check + clear error pointing the developer at the missing prereq. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/runner/hello_world/test.sh | 13 ++++++------- examples/runner/image_upscale/test.sh | 13 ++++++------- examples/runner/llm/test.sh | 13 ++++++------- examples/runner/sentiment/test.sh | 13 ++++++------- 4 files changed, 24 insertions(+), 28 deletions(-) diff --git a/examples/runner/hello_world/test.sh b/examples/runner/hello_world/test.sh index f1d2c11..5f24b13 100755 --- a/examples/runner/hello_world/test.sh +++ b/examples/runner/hello_world/test.sh @@ -10,13 +10,12 @@ NAME="${NAME:-livepeer}" EXPECTED_MSG="hello, ${NAME}" echo "Waiting for capability registration..." -for _ in $(seq 1 60); do - if docker logs register_capability 2>&1 | grep -q "registered hello-world"; then - echo " registered." - break - fi - sleep 2 -done +if ! docker logs register_capability 2>&1 | grep -q "registered hello-world"; then + echo "FAIL: register_capability hasn't logged success." + echo "Make sure 'docker compose up -d --wait --build' completed first." + exit 1 +fi +echo " registered." # TODO: swap curl for a livepeer_gateway batch caller (post PR #6) — drops # the gateway service from compose. diff --git a/examples/runner/image_upscale/test.sh b/examples/runner/image_upscale/test.sh index 2e3ad78..d9840b5 100755 --- a/examples/runner/image_upscale/test.sh +++ b/examples/runner/image_upscale/test.sh @@ -11,13 +11,12 @@ INPUT_WIDTH="${INPUT_WIDTH:-64}" INPUT_HEIGHT="${INPUT_HEIGHT:-64}" echo "Waiting for capability registration..." -for _ in $(seq 1 60); do - if docker logs register_capability 2>&1 | grep -q "registered image-upscale"; then - echo " registered." - break - fi - sleep 2 -done +if ! docker logs register_capability 2>&1 | grep -q "registered image-upscale"; then + echo "FAIL: register_capability hasn't logged success." + echo "Make sure 'docker compose up -d --wait --build' completed first." + exit 1 +fi +echo " registered." INPUT_B64=$(base64 -w0 < "${TEST_IMAGE}") diff --git a/examples/runner/llm/test.sh b/examples/runner/llm/test.sh index 448ddc0..0d848e2 100755 --- a/examples/runner/llm/test.sh +++ b/examples/runner/llm/test.sh @@ -9,13 +9,12 @@ GATEWAY_URL="${GATEWAY_URL:-http://localhost:9935}" PROMPT="${PROMPT:-Say hello in three words}" echo "Waiting for capability registration..." -for _ in $(seq 1 60); do - if docker logs register_capability 2>&1 | grep -q "registered llm"; then - echo " registered." - break - fi - sleep 2 -done +if ! docker logs register_capability 2>&1 | grep -q "registered llm"; then + echo "FAIL: register_capability hasn't logged success." + echo "Make sure 'docker compose up -d --wait --build' completed first." + exit 1 +fi +echo " registered." # TODO: swap curl for a livepeer_gateway batch caller (post PR #6) — drops # the gateway service from compose. diff --git a/examples/runner/sentiment/test.sh b/examples/runner/sentiment/test.sh index a5b5417..8254556 100755 --- a/examples/runner/sentiment/test.sh +++ b/examples/runner/sentiment/test.sh @@ -10,13 +10,12 @@ TEXT="${TEXT:-Livepeer makes decentralized inference effortless}" EXPECTED_LABEL="${EXPECTED_LABEL:-POSITIVE}" echo "Waiting for capability registration..." -for _ in $(seq 1 60); do - if docker logs register_capability 2>&1 | grep -q "registered sentiment"; then - echo " registered." - break - fi - sleep 2 -done +if ! docker logs register_capability 2>&1 | grep -q "registered sentiment"; then + echo "FAIL: register_capability hasn't logged success." + echo "Make sure 'docker compose up -d --wait --build' completed first." + exit 1 +fi +echo " registered." # TODO: swap curl for a livepeer_gateway batch caller (post PR #6) — drops # the gateway service from compose. From 732c69d23c57758817367d8d01b396f849400186 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Mon, 4 May 2026 18:41:15 +0200 Subject: [PATCH 21/21] refactor(live_grayscale): chroma assertion, bluenviron mediamtx, ffplay viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test.sh verifies the runner actually grayscaled (U/V chroma ≈128 via ffprobe signalstats), instead of bytes-received only. mediamtx is repackaged from stock bluenviron+alpine+curl with runOnReady wired to the gateway's BYOC ingest webhook — drops the Livepeer fork dependency. PASS opens the captured .mts in ffplay (SKIP_VIEWER=1 to skip). demo.sh removed; live webcam viewer was unreliable under the current runner-loop performance, tracked in issue #8. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../runner/live_grayscale/Dockerfile.mediamtx | 8 ++ examples/runner/live_grayscale/README.md | 47 ++++----- examples/runner/live_grayscale/demo.sh | 40 -------- .../runner/live_grayscale/docker-compose.yml | 13 ++- examples/runner/live_grayscale/mediamtx.yml | 25 +++++ examples/runner/live_grayscale/test.sh | 96 ++++++++++++++----- 6 files changed, 132 insertions(+), 97 deletions(-) create mode 100644 examples/runner/live_grayscale/Dockerfile.mediamtx delete mode 100755 examples/runner/live_grayscale/demo.sh create mode 100644 examples/runner/live_grayscale/mediamtx.yml diff --git a/examples/runner/live_grayscale/Dockerfile.mediamtx b/examples/runner/live_grayscale/Dockerfile.mediamtx new file mode 100644 index 0000000..e194c24 --- /dev/null +++ b/examples/runner/live_grayscale/Dockerfile.mediamtx @@ -0,0 +1,8 @@ +# bluenviron/mediamtx ships as a scratch image (no shell, no curl), but +# `runOnReady` needs curl to call back into the gateway's BYOC ingest webhook. +# Repackage on alpine + curl — same mediamtx binary, ~10 MB image. +FROM alpine:3.20 +RUN apk add --no-cache curl ca-certificates +COPY --from=bluenviron/mediamtx:latest /mediamtx /mediamtx +COPY --from=bluenviron/mediamtx:latest /mediamtx.yml /mediamtx.default.yml +ENTRYPOINT ["/mediamtx"] diff --git a/examples/runner/live_grayscale/README.md b/examples/runner/live_grayscale/README.md index e9fd8ef..3340106 100644 --- a/examples/runner/live_grayscale/README.md +++ b/examples/runner/live_grayscale/README.md @@ -28,11 +28,22 @@ inference pipeline. ```bash docker compose up -d --wait --build -./test.sh +./test.sh # captures 5s, asserts grayscale, opens ffplay docker compose down ``` -`test.sh` prints `PASS` on success. +`test.sh` does three things: + +1. Pushes a synthetic stream through the full BYOC chain +2. Captures the egress to `/tmp/live_grayscale_output.mts` and asserts + the U/V chroma planes are ≈128 (i.e., the runner actually grayscaled + the frames — bytes-received alone wouldn't catch a no-op `process_video`) +3. Opens the captured clip in **ffplay** so you can see the result + (`SKIP_VIEWER=1 ./test.sh` skips this — useful in CI / over SSH) + +Requires `ffmpeg`/`ffplay` on the host (already implicit since the test +pushes and pulls RTMP via ffmpeg). `RETRIES=N` overrides the pull retry +count (default 20) for fast-fail iteration during development. ## What's running @@ -64,37 +75,18 @@ sequenceDiagram Five compose services: -| Service | What it is | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gateway`, `orchestrator` | `livepeer/go-livepeer:master` from Docker Hub | -| `mediamtx` | RTMP/WHIP/WHEP frontend the gateway points at via `LIVE_AI_PLAYBACK_HOST`. Caller pushes RTMP here; processed output served back as RTMP. | -| `live_grayscale` | The pipeline container — a [BYOC](https://github.com/livepeer/go-livepeer/blob/main/doc/byoc.md) capability built with `livepeer_gateway.runner.LivePipeline`. | -| `register_capability` | One-shot helper that POSTs to `orchestrator:8935/capability/register` once `live_grayscale` is healthy | +| Service | What it is | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `gateway`, `orchestrator` | `livepeer/go-livepeer:master` from Docker Hub | +| `mediamtx` | RTMP frontend the gateway points at via `LIVE_AI_PLAYBACK_HOST`. Caller pushes RTMP here; processed output served back as RTMP. `mediamtx.yml` wires `runOnReady` to the gateway's BYOC ingest webhook; `Dockerfile.mediamtx` repackages the scratch image with curl so the webhook can fire. | +| `live_grayscale` | The pipeline container — a [BYOC](https://github.com/livepeer/go-livepeer/blob/main/doc/byoc.md) capability built with `livepeer_gateway.runner.LivePipeline`. | +| `register_capability` | One-shot helper that POSTs to `orchestrator:8935/capability/register` once `live_grayscale` is healthy | The pipeline service has a healthcheck that probes `GET /health` until `setup()` finishes (state machine reaches `OK`). `register_capability` waits on `service_healthy`, so the orchestrator never sees a "registered but not loaded" container. -## Try it yourself with your webcam - -`demo.sh` pushes your local webcam through the same path as test.sh and -opens an `ffplay` window with the grayscale output: - -```bash -docker compose up -d --wait --build -./demo.sh # press 'q' in the player to stop -docker compose down -``` - -Webcam input depends on OS — set `WEBCAM_FLAGS` to override the Linux default: - -| OS | `WEBCAM_FLAGS` | -| ------- | ------------------------------------------- | -| Linux | `-f v4l2 -i /dev/video0` | -| macOS | `-f avfoundation -i 0` | -| Windows | `-f dshow -i video=YourCameraName` | - ## Wire contract (the parts that matter) `POST /process/stream/start`'s `Livepeer:` header carries the job @@ -116,4 +108,3 @@ creates: | `enable_data_output: true` | Adds `data_url` (not used here) | Verified against `byoc/stream_orchestrator.go:93-131` in go-livepeer. - diff --git a/examples/runner/live_grayscale/demo.sh b/examples/runner/live_grayscale/demo.sh deleted file mode 100755 index dc976de..0000000 --- a/examples/runner/live_grayscale/demo.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -# Push your webcam through the GrayscaleFilter and view the output. -# Run after `docker compose up -d --wait --build`. -# -# Webcam input depends on OS — set WEBCAM_FLAGS to override the default: -# Linux (default): WEBCAM_FLAGS="-f v4l2 -i /dev/video0" -# macOS: WEBCAM_FLAGS="-f avfoundation -i 0" -# Windows: WEBCAM_FLAGS='-f dshow -i video=YourCameraName' -# -# TODO: post-PR-#6, replace the gateway+mediamtx ingestion with -# `start_byoc_job` from `livepeer_gateway.byoc`. - -set -euo pipefail -cd "$(dirname "$0")" - -GATEWAY_URL="${GATEWAY_URL:-http://localhost:9935}" -WEBCAM_FLAGS="${WEBCAM_FLAGS:--f v4l2 -i /dev/video0}" - -LIVEPEER_HDR=$(printf '%s' \ - '{"request":"{}","parameters":"{\"enable_video_ingress\":true,\"enable_video_egress\":true}","capability":"live-video-to-video","timeout_seconds":300}' \ - | base64 -w0) - -RESPONSE=$(curl -fsS -X POST "${GATEWAY_URL}/process/stream/start" \ - -H "Livepeer: ${LIVEPEER_HDR}" -d '{}') - -STREAM_ID=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['stream_id'])") -RTMP_IN=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['rtmp_url'])") -RTMP_OUT=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['rtmp_output_url'].split(',')[0])") - -trap 'curl -fsS -X POST "${GATEWAY_URL}/process/stream/${STREAM_ID}/stop" -H "Livepeer: ${LIVEPEER_HDR}" -d "{}" >/dev/null 2>&1 || true' EXIT - -echo "Pushing webcam → ${RTMP_IN}" -echo "Opening viewer ← ${RTMP_OUT}" -echo "Press 'q' in the player window (or Ctrl-C here) to stop." - -ffmpeg -loglevel error ${WEBCAM_FLAGS} \ - -c:v libx264 -preset ultrafast -tune zerolatency \ - -f flv "${RTMP_IN}" & - -ffplay -loglevel error "${RTMP_OUT}" diff --git a/examples/runner/live_grayscale/docker-compose.yml b/examples/runner/live_grayscale/docker-compose.yml index 647aa83..cef7d31 100644 --- a/examples/runner/live_grayscale/docker-compose.yml +++ b/examples/runner/live_grayscale/docker-compose.yml @@ -40,14 +40,17 @@ services: - orchestrator - mediamtx - # Media server that fronts the BYOC stream gateway: caller pushes RTMP to - # `rtmp_url` (= mediamtx), gateway forwards to runner; runner's egress - # comes back through orch → mediamtx, served on `rtmp_output_url`. + # RTMP frontend. Custom build adds curl to the scratch base so mediamtx.yml's + # `runOnReady` can call the gateway — no Livepeer mediamtx fork required. mediamtx: - image: bluenviron/mediamtx:latest + build: + context: . + dockerfile: Dockerfile.mediamtx container_name: mediamtx ports: - - "1935:1935" # RTMP — both test.sh/demo.sh on host and gateway in compose use this + - "1935:1935" # RTMP push/pull + volumes: + - ./mediamtx.yml:/mediamtx.yml:ro live_grayscale: build: diff --git a/examples/runner/live_grayscale/mediamtx.yml b/examples/runner/live_grayscale/mediamtx.yml new file mode 100644 index 0000000..1d8cb02 --- /dev/null +++ b/examples/runner/live_grayscale/mediamtx.yml @@ -0,0 +1,25 @@ +# Bridges stock bluenviron mediamtx → BYOC gateway, no Livepeer fork needed. +# See go-livepeer byoc/stream_gateway.go:StartStreamRTMPIngest. + +# Gateway hits the API from another container for stream-exists checks and +# input kicks; default ACL is localhost-only and would 401. +api: yes +authInternalUsers: + - user: any + pass: + ips: [] + permissions: + - action: publish + - action: read + - action: playback + - action: api + +paths: + all_others: + # No shell in the image, so curl is invoked directly (no `sh -c`). + runOnReady: > + curl -fsS -X POST + http://gateway:9935/process/stream/$MTX_PATH/rtmp + -F source_id=$MTX_SOURCE_ID + -F source_type=$MTX_SOURCE_TYPE + runOnReadyRestart: no diff --git a/examples/runner/live_grayscale/test.sh b/examples/runner/live_grayscale/test.sh index 44194cf..83646a8 100755 --- a/examples/runner/live_grayscale/test.sh +++ b/examples/runner/live_grayscale/test.sh @@ -1,62 +1,110 @@ #!/usr/bin/env bash -# E2E test for the LivePipeline real-time runner. -# Pushes a synthetic colored stream through the BYOC stack and asserts -# the runner produces non-empty output bytes. +# E2E test for the LivePipeline real-time runner. Pushes a synthetic +# colored stream through the full BYOC stack, asserts non-empty output, +# and opens the captured grayscale clip in a video player (skip with +# SKIP_VIEWER=1 in CI). # # Path: ffmpeg push (RTMP) → mediamtx → gateway → orch → runner → orch → mediamtx → ffmpeg pull (RTMP) # # TODO: post-PR-#6, replace the gateway+mediamtx ingestion with -# `start_byoc_job` from `livepeer_gateway.byoc` (same chroma assertion, -# customer-flow E2E without the deprecated gateway endpoints). +# `start_byoc_job` from `livepeer_gateway.byoc` (customer-flow E2E +# without the deprecated gateway endpoints). set -euo pipefail cd "$(dirname "$0")" GATEWAY_URL="${GATEWAY_URL:-http://localhost:9935}" -OUTPUT_FILE="${OUTPUT_FILE:-/tmp/live_grayscale_output.ts}" +OUTPUT_FILE="${OUTPUT_FILE:-/tmp/live_grayscale_output.mts}" echo "Waiting for capability registration..." -for _ in $(seq 1 60); do - if docker logs register_capability 2>&1 | grep -q "registered live-video-to-video"; then - echo " registered." - break - fi - sleep 2 -done +if ! docker logs register_capability 2>&1 | grep -q "registered live-video-to-video"; then + echo "FAIL: register_capability hasn't logged success." + echo "Make sure 'docker compose up -d --wait --build' completed first." + exit 1 +fi +echo " registered." -# `parameters` is a stringified JSON; enable_video_{ingress,egress} -# drive trickle channel creation (go-livepeer byoc/types.go). +# `parameters` is a stringified JSON; enable_video_{ingress,egress} drive +# trickle channel creation (go-livepeer byoc/types.go). LIVEPEER_HDR=$(printf '%s' \ '{"request":"{}","parameters":"{\"enable_video_ingress\":true,\"enable_video_egress\":true}","capability":"live-video-to-video","timeout_seconds":60}' \ | base64 -w0) +# Best-effort session cleanup; registered early to catch Ctrl-C. +# `${STREAM_ID:-}` so an early failure (before stream/start succeeded) doesn't +# trip `set -u` when the trap fires. +trap 'curl -fsS -X POST "${GATEWAY_URL}/process/stream/${STREAM_ID:-}/stop" -H "Livepeer: ${LIVEPEER_HDR}" -d "{}" >/dev/null 2>&1 || true' EXIT + echo "Starting stream session..." RESPONSE=$(curl -fsS -X POST "${GATEWAY_URL}/process/stream/start" \ -H "Livepeer: ${LIVEPEER_HDR}" -d '{}') STREAM_ID=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['stream_id'])") -RTMP_IN=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['rtmp_url'])") +RTMP_IN=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['rtmp_url'])") RTMP_OUT=$(echo "${RESPONSE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['rtmp_output_url'].split(',')[0])") - echo " stream_id=${STREAM_ID}" echo " rtmp_in =${RTMP_IN}" echo " rtmp_out=${RTMP_OUT}" -trap 'curl -fsS -X POST "${GATEWAY_URL}/process/stream/${STREAM_ID}/stop" -H "Livepeer: ${LIVEPEER_HDR}" -d "{}" >/dev/null 2>&1 || true; rm -f "${OUTPUT_FILE}"' EXIT +# Gateway URLs use docker-DNS name `mediamtx`, only resolvable inside compose. +RTMP_IN="${RTMP_IN/mediamtx:/localhost:}" +RTMP_OUT="${RTMP_OUT/mediamtx:/localhost:}" +# Push synthetic testsrc into the gateway. `-g 30` = 1s GOP (else first +# segment lags 8s+); duration outlasts cold-start; `/dev/null & +PUSH_PID=$! -echo "Pulling processed stream..." -ffmpeg -loglevel error -i "${RTMP_OUT}" -t 5 -c:v copy "${OUTPUT_FILE}" +# Retry until mediamtx serves egress; low-latency flags = faster first packet. +echo -n "Pulling processed stream" +PULL_OK=0 +for _ in $(seq 1 "${RETRIES:-20}"); do + if ffmpeg -loglevel error \ + -fflags nobuffer -flags low_delay \ + -probesize 32 -analyzeduration 0 \ + -i "${RTMP_OUT}" -t 5 -c:v copy "${OUTPUT_FILE}" /dev/null; then + echo " ok." + PULL_OK=1 + break + fi + echo -n "." + sleep 2 +done + +# SIGINT (not SIGTERM/SIGKILL) lets ffmpeg flush its RTMP trailer cleanly. +kill -INT ${PUSH_PID} 2>/dev/null || true +wait ${PUSH_PID} 2>/dev/null || true + +if [ ${PULL_OK} -ne 1 ]; then + echo + echo "FAIL: pull never succeeded after retries" + exit 1 +fi if [ ! -s "${OUTPUT_FILE}" ]; then echo "FAIL: no bytes received from gateway egress" exit 1 fi +# Verify chroma was actually zeroed (grayscale → U/V planes ≈ 128). +# Otherwise PASS would also fire if process_video was a no-op passthrough. +IFS=, read -r U V < <(ffprobe -v error -f lavfi -i "movie=${OUTPUT_FILE},signalstats" \ + -show_entries frame_tags=lavfi.signalstats.UAVG,lavfi.signalstats.VAVG \ + -of csv=p=0 -read_intervals "%+#1") +awk -v u="${U:-0}" -v v="${V:-0}" 'BEGIN { exit !(u>123 && u<133 && v>123 && v<133) }' \ + || { echo "FAIL: chroma not zeroed (U=${U} V=${V}, expected ≈128)"; exit 1; } + OUTPUT_SIZE=$(stat -c %s "${OUTPUT_FILE}" 2>/dev/null || stat -f %z "${OUTPUT_FILE}") -echo "PASS (${OUTPUT_SIZE} bytes received)" +echo "PASS (${OUTPUT_SIZE} bytes, U=${U} V=${V} → ${OUTPUT_FILE})" + +# Open in ffplay (skipped in non-TTY or via SKIP_VIEWER=1). +if [ -t 1 ] && [ "${SKIP_VIEWER:-0}" != "1" ]; then + echo "Opening captured clip..." + ffplay -loglevel error -window_title "live_grayscale (captured)" \ + -x 640 -y 480 -autoexit "${OUTPUT_FILE}" +fi