diff --git a/.dockerignore b/.dockerignore index 012c52d6a..a949965cb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -104,3 +104,11 @@ coverage.xml # UV cache (keep .python-version and uv.lock - they're needed in Docker) .uv/ + +# DragonTTS — self-contained sub-service (own Dockerfile/pyproject/venv under +# dragontts/). The clairvoyance Dockerfile does `COPY . .` (NOT `COPY app ./app`), +# so this exclusion is LOAD-BEARING: without it, the entire dragontts/ service +# (its own Dockerfile, pyproject, and app/ subpackage) would be baked into the +# clairvoyance image. Do NOT remove it thinking it's redundant. dragontts ships +# its own image via dragontts-docker-publish.yml (context: dragontts). +dragontts/ diff --git a/.github/workflows/dragontts-docker-publish.yml b/.github/workflows/dragontts-docker-publish.yml new file mode 100644 index 000000000..1adf2a089 --- /dev/null +++ b/.github/workflows/dragontts-docker-publish.yml @@ -0,0 +1,62 @@ +name: Build & Push DragonTTS Image to Artifact Registry + +# Pushes the DragonTTS image (the self-contained app under dragontts/, with +# its own Dockerfile/pyproject/uv.lock) to its own Artifact Registry repo. +# Independent of the clairvoyance image workflows: it only runs when DragonTTS +# files change on `release`. Build + push only — the GKE rollout is done +# manually with kubectl. Reuses clairvoyance's WIF identity (same repo/project). +# +# Prerequisite (GCP side, not CI): the github-deployer@breeze-automatic-prod SA +# must have Artifact Registry Writer on the `dragontts` repo in +# breeze-automatic-prod (it already has it on `clairvoyance`). + +on: + push: + branches: + - release + paths: + - dragontts/** + +permissions: + id-token: write + contents: read + +concurrency: + group: dragontts-docker-publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v1 + with: + workload_identity_provider: projects/840384202515/locations/global/workloadIdentityPools/github-pool/providers/github-provider + service_account: github-deployer@breeze-automatic-prod.iam.gserviceaccount.com + + - name: Configure Docker for Artifact Registry + run: | + gcloud auth configure-docker asia-south1-docker.pkg.dev --quiet + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract short SHA + run: echo "SHORT_SHA=${GITHUB_SHA:0:8}" >> $GITHUB_ENV + + - name: Build and Push DragonTTS Image to Artifact Registry + uses: docker/build-push-action@v5 + with: + context: dragontts + push: true + platforms: linux/amd64 + tags: | + asia-south1-docker.pkg.dev/breeze-automatic-prod/dragontts/dragontts:${{ env.SHORT_SHA }} + asia-south1-docker.pkg.dev/breeze-automatic-prod/dragontts/dragontts:latest + provenance: false + sbom: false diff --git a/dragontts/.dockerignore b/dragontts/.dockerignore new file mode 100644 index 000000000..5badf9b60 --- /dev/null +++ b/dragontts/.dockerignore @@ -0,0 +1,15 @@ +.git +.github +.venv +.pytest_cache +.claude +.env +.env.* +!.env.example +data +tests +scripts +README.md +*.md +__pycache__ +*.pyc diff --git a/dragontts/.env.example b/dragontts/.env.example new file mode 100644 index 000000000..d5dddcc97 --- /dev/null +++ b/dragontts/.env.example @@ -0,0 +1,31 @@ +# --- Provider credentials (names mirror clairvoyance) --- +CARTESIA_API_KEY= +SARVAM_API_KEY= +ELEVENLABS_API_KEY= +ELEVENLABS_BASE_URL=https://api.elevenlabs.io +ELEVENLABS_INDIAN_RESIDENCY_API_KEY= +ELEVENLABS_INDIAN_RESIDENCY_BASE_URL= +GOOGLE_CREDENTIALS_JSON= +GOOGLE_CREDENTIALS_PATH= + +# --- Storage (point both at a persistent volume in k8s) --- +DB_PATH=data/dragontts.db +BLOB_DIR=data/blobs + +# --- Cache policy --- +MAX_CACHE_BYTES=0 # 0 = unlimited +TTL_SECONDS=0 # 0 = no expiry +ENABLE_WRITE_THROUGH=true + +# --- Sentence splitting --- +# Split a transcript into sentences BEFORE synth/serve so each is synthesized + +# cached under its own key (recurring sentences get reused). Parts are then pure- +# concatenated — no edge trim, no inserted gap (nothing is cut; appended exactly +# as synthesized). Empty = OFF (the whole transcript is one cache entry, the +# default). Set to the symbols to split AFTER, e.g. .:?! The synth and stream +# paths are controlled INDEPENDENTLY: +SPLIT_AT_SYMBOLS= # /tts/bytes (one-shot synth) path; empty = OFF +SPLIT_AT_SYMBOLS_STREAM= # /tts/stream path; empty = OFF +# Only split when EVERY resulting part has >= this many words (a lone single-word +# part isn't worth caching alone, so the whole phrase stays one entry). +SPLIT_MIN_WORDS_PER_PART=2 diff --git a/dragontts/.gitignore b/dragontts/.gitignore new file mode 100644 index 000000000..94ed06ff7 --- /dev/null +++ b/dragontts/.gitignore @@ -0,0 +1,41 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +env/ + +# Node +node_modules/ + +# Env / secrets +.env +.env.* +!.env.example +*.pem +*.key + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ + +# Claude (local tooling state — never commit) +.claude/ + +# DragonTTS runtime storage (SQLite db + audio blobs) +data/ +*.db + +# Audio artifacts +*.wav +*.mp3 +*.ogg +/samples/ + +# Local live smoke tests + their audio output (never commit) +/smoke/ diff --git a/dragontts/.python-version b/dragontts/.python-version new file mode 100644 index 000000000..2c0733315 --- /dev/null +++ b/dragontts/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/dragontts/Dockerfile b/dragontts/Dockerfile new file mode 100644 index 000000000..5af2ccef4 --- /dev/null +++ b/dragontts/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim AS base + +# Cap glibc malloc arenas so freed memory returns to the OS instead of fragmenting +# across dozens of pools. The default (8 x host-cores) lets the 4x32 worker +# threads strand freed audio buffers across ~100 arenas -> anon RSS creeps. 4 is a +# small fixed cap; zero perf cost on this 1-CPU, GIL-bound, async-I/O pod. +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + MALLOC_ARENA_MAX=4 + +COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /usr/local/bin/uv + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-install-project + +COPY app ./app + +RUN uv sync --frozen + +RUN useradd --uid 1000 --create-home appuser && chown -R appuser:appuser /app +USER appuser + +EXPOSE 8000 + +CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--loop", "uvloop", "--no-access-log", "--limit-concurrency", "1024"] diff --git a/dragontts/README.md b/dragontts/README.md new file mode 100644 index 000000000..b90d88ab4 --- /dev/null +++ b/dragontts/README.md @@ -0,0 +1,48 @@ +# dragonTTS + +TTS caching proxy (FastAPI): caches text-to-speech audio to cut latency and cost +on repeated synthesis. Multi-provider (Cartesia / ElevenLabs / Sarvam / Gemini), +SQLite + filesystem store, write-through caching with length-scaled TTL. + +## Run locally + +The folder is self-contained — a venv, deps, and the `app` package all live +inside it. Always `cd` here and run from this folder. + +### 1. Configure env +```bash +cp .env.example .env # then fill in provider API keys in .env +``` +`.env` is gitignored — it is the only place real keys go locally (in prod they +are kubectl-injected, never in the image). + +Key knobs (see `.env.example` for the full list): +- `SPLIT_AT_SYMBOLS` — split `/tts/bytes` (one-shot) transcripts into per- + sentence cache entries (e.g. `.:?`). Empty = off. +- `SPLIT_AT_SYMBOLS_STREAM` — same, for `/tts/stream`. +- `ENABLE_WRITE_THROUGH=true` — store every synth into the cache. +- `CACHE_TTL_*` — length-scaled TTL (longer phrases live longer). + +### 2. Install + run (uv) +```bash +uv sync +uv run uvicorn app.main:app --reload +``` +Plain venv (no uv): `python3.11 -m venv .venv && source .venv/bin/activate && +pip install -e . && uvicorn app.main:app --reload` + +Server starts on http://127.0.0.1:8000 → try `GET /health`. + +## Endpoints +- `POST /tts/bytes` — one-shot synthesis (cached, returns μ-law 8 kHz). +- `POST /tts/stream` — chunked streaming synthesis (raw pcm_s16le @ 16 kHz). +- `POST /cache/clear` — clear the cache at runtime (don't delete `data/` while + the server runs). +- `GET /health` — liveness + configured providers. +- `GET /stats`, `GET /stats/daily`, `GET /stats/latency` — cache economics. +- `POST /slack-summary` — manually trigger the daily Slack summary. + +## Notes +- Cache store lives in `data/` (SQLite db + audio blobs) — gitignored; keep it + on a persistent volume in prod. +- Split parts are pure-concatenated (no audio is trimmed/cut). diff --git a/dragontts/app/__init__.py b/dragontts/app/__init__.py new file mode 100644 index 000000000..00fdfe969 --- /dev/null +++ b/dragontts/app/__init__.py @@ -0,0 +1,3 @@ +"""DragonTTS — caching TTS proxy compatible with clairvoyance providers.""" + +__version__ = "0.1.0" diff --git a/dragontts/app/alerts/__init__.py b/dragontts/app/alerts/__init__.py new file mode 100644 index 000000000..87685e674 --- /dev/null +++ b/dragontts/app/alerts/__init__.py @@ -0,0 +1 @@ +"""Slack alerting (incoming-webhook Block Kit sender + daily cache summary).""" diff --git a/dragontts/app/alerts/slack.py b/dragontts/app/alerts/slack.py new file mode 100644 index 000000000..b2e63bbce --- /dev/null +++ b/dragontts/app/alerts/slack.py @@ -0,0 +1,148 @@ +"""Slack alerting — incoming-webhook Block Kit sender. + +Mirrors clairvoyance's ``app/services/slack/alert.py`` contract: an ``Alert`` +singleton (``slack_alert``) that POSTs a Block Kit message to +``SLACK_WEBHOOK_URL`` and treats HTTP 200 + body ``ok`` as success. It **never +raises** — a Slack outage logs + returns ``False``; it can never break the +request path or the background summary task. An empty webhook URL makes +``send()`` a safe no-op (== feature off), matching clairvoyance (no separate +enable flag). + +Uses httpx (already a DragonTTS dependency) rather than clairvoyance's aiohttp; +the on-wire Block Kit payload is equivalent. +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from app.core.config import settings +from app.core.logging import logger + + +def _format_tag(user: str) -> str: + """Wrap a handle as a Slack mention, unless it's already a Slack tag form + (``<@U123>`` user or ```` group — pass those through).""" + u = user.strip() + if not u: + return "" + if u.startswith("<") and u.endswith(">"): + return u + return f"<@{u}>" + + +class Alert: + """Incoming-webhook Block Kit sender. Best-effort: never raises.""" + + def __init__( + self, webhook_url: str | None = None, tag_users: str | None = None + ) -> None: + self.webhook_url = ( + webhook_url if webhook_url is not None else settings.slack_webhook_url + ) + self.tag_users = ( + tag_users if tag_users is not None else settings.slack_tag_users + ) + + def _blocks( + self, + title: str, + fields, + sections, + links, + include_tags: bool, + ) -> list[dict[str, Any]]: + blocks: list[dict[str, Any]] = [ + { + "type": "header", + "text": {"type": "plain_text", "text": title, "emoji": True}, + }, + ] + if fields: + # One section; Slack renders its fields in 2 columns. + blocks.append( + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": f"*{f['name']}:*\n{f['value']}"} + for f in fields + ], + } + ) + # Each section is its own block — a plain mrkdwn string, or a + # {title/name, text} dict rendered as a bold sub-heading + body + # (mirrors clairvoyance's summary sections). + for s in sections or []: + if isinstance(s, str): + text = s + else: + head = s.get("title") or s.get("name", "") + text = f"*{head}:*\n{s.get('text', '')}" if head else s.get("text", "") + blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}}) + for link in links or []: + url = link.get("url", "") + name = link.get("name", link.get("text", "Link")) + blocks.append( + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"🔗 <{url}|{name}>"}, + } + ) + if include_tags and self.tag_users: + tags = " ".join( + t for t in (_format_tag(u) for u in self.tag_users.split(",")) if t + ) + if tags: + blocks.append( + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"cc: {tags}"}, + } + ) + return blocks + + async def send( + self, + title: str, + fields=None, + sections=None, + links=None, + fallback_text: str | None = None, + include_tags: bool = True, + ) -> bool: + """POST a Block Kit message. Returns True on success, False otherwise. + + Never raises — Slack down/timeout/misconfigured all log + return False. + """ + if not self.webhook_url: + logger.warning("Slack webhook URL not configured — alert skipped") + return False + try: + # Build the payload inside the guard too — _blocks()/formatting can + # raise on a malformed field/section, and send() must never raise. + message = { + "blocks": self._blocks(title, fields, sections, links, include_tags), + "text": fallback_text or title, # notification preview text + } + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + self.webhook_url, + json=message, + headers={"Content-Type": "application/json"}, + ) + body = (resp.text or "").strip().lower() + if resp.status_code == 200 and body == "ok": + return True + logger.error( + f"Slack webhook non-ok: status={resp.status_code} body={resp.text[:200]}" + ) + return False + except Exception as e: # noqa: BLE001 — best-effort, never raise + logger.error(f"Error sending Slack alert: {e}") + return False + + +# Module-level singleton — callers do `await slack_alert.send(...)`. +slack_alert = Alert() diff --git a/dragontts/app/alerts/summary.py b/dragontts/app/alerts/summary.py new file mode 100644 index 000000000..cec11c8d6 --- /dev/null +++ b/dragontts/app/alerts/summary.py @@ -0,0 +1,194 @@ +"""Build the daily cache-economics Slack summary (overall + per provider). + +Reuses the existing metrics rollups — no new storage: +- ``metrics_summary`` for the global requests/hits/words-served/synthesized, +- ``provider_metrics_summary`` for the per-provider breakdown (which now carries + per-provider ``words_synthesized``, so per-provider words-from-cache % works), +- ``stats()`` for the cache snapshot (entries / total bytes), +- ``shutil.disk_usage`` for PVC utilization. + +cost_saved(provider) = words_from_cache(provider) * settings.slack_cost_per_word[provider]; +total cost saved = sum across providers (each provider has its own $/word). +""" + +from __future__ import annotations + +import shutil +from datetime import datetime, timedelta, timezone + +from app.core.config import settings +from app.core.logging import logger + + +def _pct(num: int, den: int) -> str: + if den <= 0: + return "—" + return f"{round(num * 100 / den)}%" + + +def _money(usd: float) -> str: + # Display in INR, rounded to the nearest rupee (1 USD = slack_usd_to_inr). + return f"₹{round(usd * settings.slack_usd_to_inr):,}" + + +def _human_bytes(n: float) -> str: + for unit in ("B", "KB", "MB", "GB", "TB"): + if n < 1024 or unit == "TB": + return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}" + n /= 1024 + return f"{n} B" + + +def _today_window() -> tuple[str, str]: + """(from, to) UTC dates covering roughly the day ending now: yesterday + today. + At 11 PM IST (17:30 UTC) this captures the just-finished IST day well.""" + now = datetime.now(timezone.utc) + return (now - timedelta(days=1)).date().isoformat(), now.date().isoformat() + + +async def build_summary(cache) -> dict: + """Gather metrics and return ``{title, fields, sections, fallback_text}`` + shaped for :func:`app.alerts.slack.Alert.send`.""" + from_date, to_date = _today_window() + meta = cache._metadata # CacheService holds the metadata store + m = await meta.metrics_summary(from_date=from_date, to_date=to_date) + providers = await meta.provider_metrics_summary( + from_date=from_date, to_date=to_date + ) + snap = await meta.stats() + + requests = m.get("requests", 0) + hits = m.get("hits", 0) + words_served = m.get("words_served", 0) + words_synthesized = m.get("words_synthesized", 0) + words_from_cache = max(0, words_served - words_synthesized) + + rates = settings.slack_cost_per_word or {} + total_cost = 0.0 + sections: list[dict] = [] + for prov, pm in sorted(providers.items()): + p_req = pm.get("requests", 0) + p_hits = pm.get("hits", 0) + p_wserved = pm.get("words_served", 0) + p_wsyn = pm.get("words_synthesized", 0) + p_wfc = max(0, p_wserved - p_wsyn) + cost = p_wfc * float(rates.get(prov, 0.0)) + total_cost += cost + sections.append( + { + "title": prov, + "text": ( + f"• hit rate: *{_pct(p_hits, p_req)}* ({p_hits:,} / {p_req:,})\n" + f"• words from cache: *{_pct(p_wfc, p_wserved)}* " + f"({p_wfc:,} / {p_wserved:,})\n" + f"• cost saved: *{_money(cost)}*" + ), + } + ) + + fields = [ + { + "name": "Cache hit rate", + "value": f"{_pct(hits, requests)} ({hits:,} / {requests:,} requests)", + }, + { + "name": "Words from cache", + "value": f"{_pct(words_from_cache, words_served)} " + f"({words_from_cache:,} / {words_served:,} words)", + }, + {"name": "Est. cost saved", "value": f"{_money(total_cost)} (all providers)"}, + {"name": "Window", "value": f"{from_date} → {to_date} (UTC)"}, + ] + + # Cache snapshot + PVC usage (last section). + entries = snap.get("entries", 0) + total_bytes = snap.get("total_bytes", 0) + try: + du = shutil.disk_usage(settings.db_path) + used_pct = round(du.used * 100 / du.total) if du.total else 0 + pvc = f"{_human_bytes(du.used)} / {_human_bytes(du.total)} ({used_pct}%)" + except Exception: + pvc = "—" + sections.append( + { + "title": "Cache", + "text": ( + f"• entries: *{entries:,}*\n" + f"• size: *{_human_bytes(total_bytes)}*\n" + f"• PVC: *{pvc}*" + ), + } + ) + + display_date = datetime.now(timezone.utc).strftime("%d-%m-%y") + return { + "title": f"📊 DragonTTS Daily Cache Summary - {display_date}", + "fields": fields, + "sections": sections, + "fallback_text": ( + f"DragonTTS daily: hit {_pct(hits, requests)}, " + f"words from cache {_pct(words_from_cache, words_served)}, " + f"saved {_money(total_cost)}" + ), + } + + +def _past_target_time() -> bool: + """True if the current UTC time is at/after settings.slack_summary_time_utc + (HH:MM) today. Combined with the once-per-day claim this fires the summary + exactly once, shortly after the target time. A malformed/out-of-range value + falls back to 17:30 UTC (11 PM IST) rather than raising on every tick.""" + now = datetime.now(timezone.utc) + try: + parts = settings.slack_summary_time_utc.split(":") + hh, mm = int(parts[0]), int(parts[1]) + if not (0 <= hh <= 23 and 0 <= mm <= 59): + raise ValueError + target = now.replace(hour=hh, minute=mm, second=0, microsecond=0) + except Exception: + target = now.replace(hour=17, minute=30, second=0, microsecond=0) + return now >= target + + +async def send_daily_summary(cache, *, force: bool = False) -> bool: + """Build + send the daily Slack summary. + + - ``force=True`` (the manual trigger endpoint): bypasses both the time gate + and the once-per-day claim — posts immediately. + - ``force=False`` (the background loop): only posts when past the target time + AND this worker wins the once-per-day SQLite claim. On a send failure it + releases the claim so a later tick retries. + + Returns True if a message was sent. A missing webhook URL => no-op (feature + off). Never raises. + """ + if not settings.slack_webhook_url: + return False + from app.alerts.slack import slack_alert # lazy: avoid any import cycle + + today = datetime.now(timezone.utc).date().isoformat() + if not force: + if not _past_target_time(): + return False + if not await cache._metadata.claim_slack_summary(today): + return False # another worker already posted today + + # Build + send inside one guard so ANY failure (a transient DB error in + # build_summary, a send exception, or a shutdown CancelledError) releases the + # once-per-day claim — otherwise a blip right after claiming would silently + # suppress the whole day's summary. `finally` runs even on CancelledError, + # releasing the claim so other workers can still post; the cancel then + # propagates and ends the task on shutdown. + ok = False + try: + payload = await build_summary(cache) + ok = await slack_alert.send(**payload) + except Exception as e: # noqa: BLE001 — log; the loop also swallows + logger.error(f"Slack summary build/send raised: {e}") + finally: + if not force and not ok: + try: + await cache._metadata.release_slack_summary() + except Exception: + pass + return ok diff --git a/dragontts/app/api/v1/cache.py b/dragontts/app/api/v1/cache.py new file mode 100644 index 000000000..5b2ec9ea6 --- /dev/null +++ b/dragontts/app/api/v1/cache.py @@ -0,0 +1,292 @@ +"""Cache admin endpoints — list, fetch, and delete by raw key (for browsing).""" + +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Query, Request, Response +from pydantic import BaseModel + +from app.alerts.summary import send_daily_summary +from app.audio.format import content_type_for +from app.schemas.cache import CacheEntryInfo, PaginatedCache + +router = APIRouter() + + +def _to_info(r) -> CacheEntryInfo: + return CacheEntryInfo( + key=r.key, + text=r.text, + provider=r.provider, + voice_id=r.voice_id, + model=r.model, + language=r.language, + container=r.container, + encoding=r.encoding, + sample_rate=r.sample_rate, + size_bytes=r.size_bytes, + hit_count=r.hit_count, + created_at=r.created_at, + ) + + +MAX_CACHE_LIST_LIMIT = 1000 # hard cap so a list call can never stream the whole DB + + +@router.get("/cache", response_model=PaginatedCache) +async def list_cache( + request: Request, + provider: str | None = None, + voice_id: str | None = None, + limit: int = 100, + offset: int = 0, + q: str | None = Query(default=None, description="filter by text (see match)"), + match: str = Query(default="exact", description="'exact' (default) or 'substring'"), + created_after: str | None = Query( + default=None, description="YYYY-MM-DD, inclusive" + ), + created_before: str | None = Query( + default=None, description="YYYY-MM-DD, exclusive" + ), +): + """Paginated entry listing with optional text/date filters. limit is clamped + to [1, MAX_CACHE_LIST_LIMIT]. q+match filters text; created_after is + inclusive, created_before is exclusive (both YYYY-MM-DD).""" + metadata = request.app.state.metadata + limit = max(1, min(limit, MAX_CACHE_LIST_LIMIT)) + offset = max(0, offset) + # Fetch one extra row to detect a next page without a COUNT(*) scan. + records = await metadata.list( + provider=provider, + voice_id=voice_id, + limit=limit + 1, + offset=offset, + q=q, + match=match, + created_before=created_before, + created_after=created_after, + ) + has_next = len(records) > limit + entries = [_to_info(r) for r in records[:limit]] + return PaginatedCache( + entries=entries, offset=offset, limit=limit, has_next=has_next + ) + + +@router.get("/stats") +async def stats( + request: Request, + from_date: str | None = Query(default=None, alias="from"), + to_date: str | None = Query(default=None, alias="to"), +): + """Cache metrics. Request metrics are date-filterable (?from=&to= YYYY-MM-DD); + the cache snapshot is the current point-in-time state. All from stored + rollups/totals — no full-table scan per call.""" + for label, value in (("from", from_date), ("to", to_date)): + if value is not None: + try: + datetime.strptime(value, "%Y-%m-%d") + except ValueError: + raise HTTPException( + status_code=400, detail=f"invalid {label}; use YYYY-MM-DD" + ) + metadata = request.app.state.metadata + registry = request.app.state.registry + metrics = await metadata.metrics_summary(from_date=from_date, to_date=to_date) + snapshot = await metadata.stats() + providers = await metadata.provider_metrics_summary( + from_date=from_date, to_date=to_date + ) + latency = await metadata.latency_summary(from_date=from_date, to_date=to_date) + return { + "range": {"from": from_date, "to": to_date}, + **metrics, + "providers": providers, + "latency": latency, + "entries": snapshot["entries"], + "total_bytes": snapshot["total_bytes"], + "total_words": snapshot["total_words"], + "by_provider": snapshot["by_provider"], + "providers_configured": registry.configured(), + "session": request.app.state.cache.session_stats, + } + + +@router.get("/stats/daily") +async def stats_daily( + request: Request, + from_date: str | None = Query(default=None, alias="from"), + to_date: str | None = Query(default=None, alias="to"), + provider: str | None = None, +): + """Extensive day-wise analytics: per date, the full metric totals (hit_rate, + words_from_cache_pct derived) plus a per-provider breakdown. ``provider`` + narrows the view to one provider (its totals then reflect that provider + only). ?from=&to= YYYY-MM-DD (UTC dates).""" + for label, value in (("from", from_date), ("to", to_date)): + if value is not None: + try: + datetime.strptime(value, "%Y-%m-%d") + except ValueError: + raise HTTPException( + status_code=400, detail=f"invalid {label}; use YYYY-MM-DD" + ) + cache = request.app.state.cache + return await cache.daily_stats( + from_date=from_date, to_date=to_date, provider=provider + ) + + +@router.get("/stats/latency") +async def stats_latency( + request: Request, + from_date: str | None = Query(default=None, alias="from"), + to_date: str | None = Query(default=None, alias="to"), + provider: str | None = None, +): + """Per-provider x per-day latency (avg/p95/count, microseconds): + ``synth`` (provider synth time, MISS-only), ``total`` (end-to-end), + ``cache_serve`` (HIT serve), ``ttfb`` (stream first byte), plus a derived + ``miss_overhead_us`` = total.avg - synth.avg (DragonTTS work beyond the + provider call). ?from=&to= YYYY-MM-DD (UTC); ?provider= narrows to one.""" + for label, value in (("from", from_date), ("to", to_date)): + if value is not None: + try: + datetime.strptime(value, "%Y-%m-%d") + except ValueError: + raise HTTPException( + status_code=400, detail=f"invalid {label}; use YYYY-MM-DD" + ) + metadata = request.app.state.metadata + providers = await metadata.latency_summary_by_provider( + from_date=from_date, to_date=to_date, provider=provider + ) + # Derive the MISS overhead per day: total.avg - synth.avg. Population estimate + # only -- synth and total are sampled independently per kind, so the same + # request isn't guaranteed to appear in both populations. + for _prov, days in providers.items(): + for _date, kinds in days.items(): + total_avg = kinds.get("total", {}).get("avg_us") + synth_avg = kinds.get("synth", {}).get("avg_us") + kinds["miss_overhead_us"] = ( + round(total_avg - synth_avg, 1) + if total_avg is not None and synth_avg is not None + else None + ) + return { + "range": {"from": from_date, "to": to_date, "provider": provider}, + "providers": providers, + "note": ( + "miss_overhead_us = total.avg - synth.avg (population estimate; synth " + "and total are independently sampled). synth = provider synth time " + "(MISS-only); total = end-to-end; cache_serve = HIT serve; " + "ttfb = stream time-to-first-byte." + ), + } + + +@router.post("/cache/clear") +async def clear_cache( + request: Request, + provider: str | None = None, + voice_id: str | None = None, + dry_run: bool = False, +): + """Delete all entries (optionally filtered by provider/voice_id). Pass + ``dry_run=true`` to preview the count/bytes/providers without deleting.""" + cache = request.app.state.cache + if dry_run: + preview = await cache.clear_preview(provider=provider, voice_id=voice_id) + return {"status": "preview", **preview} + count = await cache.clear(provider=provider, voice_id=voice_id) + return {"status": "cleared", "deleted": count} + + +class DeleteByTextBody(BaseModel): + """Body for POST /cache/delete-by-text.""" + + text: str | None = None + provider: str | None = None + voice_id: str | None = None + match: str = "exact" # 'exact' (default) or 'substring' + dry_run: bool = True # safe by default — set False to actually delete + + +@router.post("/cache/delete-by-text") +async def delete_by_text(body: DeleteByTextBody, request: Request): + """Delete cache entries by text (exact or substring), optionally narrowed by + provider/voice_id. ``dry_run`` defaults True — previews matched entries + + keys without deleting; set ``dry_run: false`` to delete. Requires at least + one filter (text/provider/voice_id) — use /cache/clear to wipe everything.""" + cache = request.app.state.cache + try: + return await cache.delete_by_text( + text=body.text, + provider=body.provider, + voice_id=body.voice_id, + match=body.match, + dry_run=body.dry_run, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/cache/delete-by-age") +async def delete_by_age( + request: Request, + older_than_days: int = Query(..., description="evict entries older than N days"), + dry_run: bool = Query(True, description="preview unless set to false"), +): + """Delete entries older than ``older_than_days`` (by created_at). ``dry_run`` + defaults True — preview matched entries without deleting.""" + cache = request.app.state.cache + return await cache.delete_by_age(older_than_days=older_than_days, dry_run=dry_run) + + +@router.post("/cache/backfill-ttl") +async def backfill_ttl(request: Request): + """One-shot: assign a random 48–72h expiry to pre-existing entries that have + no TTL (created before length-scaled TTL, e.g. under ttl_seconds=0) so they + age out instead of living forever. Idempotent — only touches NULL-TTL rows, + so it's safe to call repeatedly. Run once after deploying this feature.""" + cache = request.app.state.cache + count = await cache.backfill_missing_ttl() + return {"status": "backfilled", "updated": count} + + +@router.post("/slack-summary") +async def slack_summary(request: Request): + """Force-send the daily Slack cache summary now — bypasses the time gate and + the once-per-day claim. Use to test the message or recover a missed day. A + missing webhook URL is a no-op (returns ``sent=false``).""" + cache = request.app.state.cache + sent = await send_daily_summary(cache, force=True) + return {"sent": sent} + + +@router.get("/cache/{key}") +async def get_cache(key: str, request: Request): + metadata = request.app.state.metadata + blobs = request.app.state.blobs + record = await metadata.get(key) + if not record: + raise HTTPException(status_code=404, detail="cache key not found") + audio = await blobs.get(record.storage_path) + return Response( + content=audio, + media_type=content_type_for(record.encoding), + headers={"X-Provider": record.provider, "X-Voice-Id": record.voice_id}, + ) + + +@router.delete("/cache/{key}") +async def delete_cache(key: str, request: Request): + metadata = request.app.state.metadata + blobs = request.app.state.blobs + record = await metadata.get(key) + if not record: + raise HTTPException(status_code=404, detail="cache key not found") + await metadata.delete(key) + await blobs.delete(record.storage_path) + return {"status": "deleted", "key": key} diff --git a/dragontts/app/api/v1/health.py b/dragontts/app/api/v1/health.py new file mode 100644 index 000000000..a318841ce --- /dev/null +++ b/dragontts/app/api/v1/health.py @@ -0,0 +1,17 @@ +"""Health + introspection endpoints.""" + +from __future__ import annotations + +from fastapi import APIRouter, Request + +router = APIRouter() + + +@router.get("/health") +async def health(request: Request): + return {"status": "ok", "providers": request.app.state.registry.configured()} + + +@router.get("/providers") +async def providers(request: Request): + return {"providers": request.app.state.registry.configured()} diff --git a/dragontts/app/api/v1/tts.py b/dragontts/app/api/v1/tts.py new file mode 100644 index 000000000..baa364e72 --- /dev/null +++ b/dragontts/app/api/v1/tts.py @@ -0,0 +1,263 @@ +"""TTS endpoints — Cartesia-mimic read + check/create/delete admin ops. + +All take the same Cartesia-shaped body and operate by the derived cache key: +- POST /tts/bytes read (HIT returns cached, MISS synth+store+return) — drop-in +- POST /tts/stream read as a chunked stream (MISS streams from the provider as + it synthesizes, HIT streams the cached blob) — low TTFB +- POST /tts/check existence check, no synthesis +- POST /tts/create force synth-or-base64 store (overrides) +- POST /tts/create/bulk batch warm a phrase library +- POST /tts/delete remove by derived key + +Upstream provider HTTP/network failures map to 502/503, never 500. +""" + +from __future__ import annotations + +import base64 +from collections.abc import AsyncGenerator + +import httpx +from fastapi import APIRouter, HTTPException, Request, Response +from fastapi.responses import StreamingResponse + +from app.audio.format import content_type_for +from app.cache.key import parse_model_id +from app.cache.resilience import ProviderBusy +from app.core.config import settings +from app.core.logging import logger +from app.providers.base import ProviderError +from app.providers.registry import ProviderNotConfigured +from app.schemas.cache import CheckResponse, CreateResponse, DeleteResponse +from app.schemas.tts import TTSRequest + +router = APIRouter() + + +def _map_upstream_error(provider: str, exc: Exception) -> HTTPException: + if isinstance(exc, (httpx.HTTPStatusError, ProviderError)): + return HTTPException( + status_code=502, + detail=f"upstream {provider} returned an error: {exc}", + ) + return HTTPException(status_code=503, detail=f"upstream {provider} unreachable") + + +@router.post("/tts/bytes") +async def tts_bytes(req: TTSRequest, request: Request): + cache = request.app.state.cache + try: + provider, _ = parse_model_id(req.model_id) + audio, headers = await cache.get_or_synthesize(req) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except ProviderNotConfigured as e: + raise HTTPException(status_code=503, detail=str(e)) + except ProviderBusy as e: + raise HTTPException( + status_code=503, detail=str(e), headers={"Retry-After": "1"} + ) + except ProviderError as e: + raise HTTPException( + status_code=502, detail=f"upstream {provider} returned an error: {e}" + ) + except (httpx.HTTPStatusError, httpx.RequestError) as e: + raise _map_upstream_error(provider, e) + + return Response( + content=audio, + media_type=content_type_for(req.output_format.encoding), + headers=headers, + ) + + +@router.post("/tts/stream") +async def tts_stream(req: TTSRequest, request: Request): + """Stream synthesized/cached audio back in chunks (low TTFB on miss). + + HIT streams the cached blob; MISS streams provider chunks as they are + synthesized and tees the full clip to the cache on completion. + + The first chunk is primed before the response commits so a provider + connection failure maps to a proper 502/503 instead of a truncated 200. + """ + cache = request.app.state.cache + try: + provider, _ = parse_model_id(req.model_id) + headers, gen = await cache.stream(req) + except ValueError as e: + logger.warning( + f"/tts/stream 400 — {e} | model_id={req.model_id!r} " + f"transcript={req.transcript!r}" + ) + raise HTTPException(status_code=400, detail=str(e)) + except ProviderNotConfigured as e: + raise HTTPException(status_code=503, detail=str(e)) + except ProviderBusy as e: + raise HTTPException( + status_code=503, detail=str(e), headers={"Retry-After": "1"} + ) + except ProviderError as e: + raise HTTPException( + status_code=502, detail=f"upstream {provider} returned an error: {e}" + ) + + try: + first = await gen.__anext__() + except StopAsyncIteration: + first = b"" + except ProviderBusy as e: + await gen.aclose() + raise HTTPException( + status_code=503, detail=str(e), headers={"Retry-After": "1"} + ) + except (ProviderError, httpx.HTTPStatusError, httpx.RequestError, OSError) as e: + await gen.aclose() + raise _map_upstream_error(provider, e) + + async def body(first_chunk: bytes) -> AsyncGenerator[bytes, None]: + try: + if first_chunk: + yield first_chunk + async for chunk in gen: + yield chunk + finally: + # Release the provider generator promptly on client disconnect so + # the underlying socket/cancel is cleaned up without waiting on GC. + await gen.aclose() + + return StreamingResponse( + body(first), + media_type=content_type_for(req.output_format.encoding), + headers=headers, + ) + + +@router.post("/tts/check", response_model=CheckResponse) +async def tts_check(req: TTSRequest, request: Request): + cache = request.app.state.cache + try: + parse_model_id(req.model_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + cached, record, provider, model, key = await cache.check(req) + return CheckResponse( + cached=cached, + key=key, + provider=provider, + voice_id=req.voice.id, + model=model, + encoding=record.encoding if record else None, + sample_rate=record.sample_rate if record else None, + size_bytes=record.size_bytes if record else None, + hit_count=record.hit_count if record else None, + created_at=record.created_at if record else None, + ) + + +@router.post("/tts/create", response_model=CreateResponse) +async def tts_create(req: TTSRequest, request: Request): + cache = request.app.state.cache + try: + provider, _ = parse_model_id(req.model_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + audio_override = None + if req.audio_base64: + try: + audio_override = base64.b64decode(req.audio_base64) + except Exception: + raise HTTPException(status_code=400, detail="invalid audio_base64") + + try: + key, status, source, size, _, model, enc, rate = await cache.create( + req, audio_override + ) + except ProviderNotConfigured as e: + raise HTTPException(status_code=503, detail=str(e)) + except ProviderBusy as e: + raise HTTPException( + status_code=503, detail=str(e), headers={"Retry-After": "1"} + ) + except ProviderError as e: + raise HTTPException( + status_code=502, detail=f"upstream {provider} returned an error: {e}" + ) + except (httpx.HTTPStatusError, httpx.RequestError) as e: + raise _map_upstream_error(provider, e) + + return CreateResponse( + key=key, + status=status, + source=source, + provider=provider, + voice_id=req.voice.id, + model=model, + encoding=enc, + sample_rate=rate, + size_bytes=size, + ) + + +@router.post("/tts/delete", response_model=DeleteResponse) +async def tts_delete(req: TTSRequest, request: Request): + cache = request.app.state.cache + try: + parse_model_id(req.model_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + deleted, key = await cache.delete(req) + return DeleteResponse(status="deleted" if deleted else "not_found", key=key) + + +@router.post("/tts/create/bulk") +async def tts_create_bulk(requests: list[TTSRequest], request: Request): + """Batch create/override many entries in one call (warm a phrase library). + + Each item honours the same rules as /tts/create. Per-item errors are + collected (including base64/upstream failures) rather than aborting the batch. + """ + cache = request.app.state.cache + if len(requests) > settings.bulk_create_max: + raise HTTPException( + status_code=413, + detail=f"bulk create capped at {settings.bulk_create_max} items per call", + ) + results = [] + errors = [] + for i, req in enumerate(requests): + try: + parse_model_id(req.model_id) + audio_override = None + if req.audio_base64: + audio_override = base64.b64decode(req.audio_base64) + key, status, source, size, provider, model, _enc, _rate = ( + await cache.create(req, audio_override) + ) + results.append( + { + "index": i, + "key": key, + "status": status, + "source": source, + "provider": provider, + "size_bytes": size, + } + ) + except ValueError as e: + errors.append({"index": i, "error": str(e)}) + except ProviderNotConfigured as e: + errors.append({"index": i, "error": str(e)}) + except ProviderBusy: + errors.append({"index": i, "error": "provider busy (bulkhead full); retry"}) + except ProviderError as e: + errors.append({"index": i, "error": f"upstream error: {e}"}) + except (httpx.HTTPStatusError, httpx.RequestError) as e: + errors.append({"index": i, "error": f"upstream error: {type(e).__name__}"}) + return { + "created": len(results), + "errors": len(errors), + "results": results, + "error_details": errors, + } diff --git a/dragontts/app/audio/format.py b/dragontts/app/audio/format.py new file mode 100644 index 000000000..ed60eac8a --- /dev/null +++ b/dragontts/app/audio/format.py @@ -0,0 +1,71 @@ +"""Audio format conversion — native format -> requested output_format. + +The cache stores audio in the provider's *native* format and converts to the +caller's requested ``output_format`` on serve (the key is format-agnostic, so +one entry serves every format). Handles native input of either raw PCM +(pcm_s16le) or μ-law (ulaw/mulaw, G.711) — the formats clairvoyance's telephony +path uses. An MP3 decode path can be added later without touching the cache +layer. +""" + +from __future__ import annotations + +import audioop + +_ULAWS = {"ulaw", "mulaw", "pcm_mulaw"} +_PCMS = {"pcm_s16le", "pcm", "raw"} + + +def _downsample_pcm( + data: bytes, in_rate: int, out_rate: int, sample_width: int = 2 +) -> bytes: + if in_rate == out_rate: + return data + # Pad to a whole number of frames before rate conversion. + if len(data) % sample_width != 0: + data = data + b"\x00" * (sample_width - (len(data) % sample_width)) + out, _ = audioop.ratecv(data, sample_width, 1, in_rate, out_rate, None) + return out + + +def convert_audio( + native_audio: bytes, + *, + native_encoding: str, + native_rate: int, + out_encoding: str, + out_rate: int, +) -> bytes: + """Convert native-format audio to the requested output format. + + Native input may be PCM s16le or μ-law. The pipeline normalizes to PCM, + resamples to the target rate, then encodes to the target format. + """ + native_enc = native_encoding.lower() + out_enc = out_encoding.lower() + + # 1. Normalize native -> PCM s16le at the native sample rate. + if native_enc in _ULAWS: + pcm = audioop.ulaw2lin(native_audio, 2) + else: # already PCM s16le + pcm = native_audio + + # 2. Resample PCM to the target rate. + pcm = _downsample_pcm(pcm, native_rate, out_rate) + + # 3. Encode to the target format. + if out_enc in _ULAWS: + return audioop.lin2ulaw(pcm, 2) + if out_enc in _PCMS: + return pcm + raise ValueError(f"Unsupported output encoding: {out_encoding!r}") + + +def content_type_for(encoding: str) -> str: + """HTTP content type for a cached audio encoding.""" + enc = encoding.lower() + if enc in _ULAWS: + return "audio/mulaw" + if enc in _PCMS: + return "audio/pcm" + return "application/octet-stream" diff --git a/dragontts/app/audio/resample.py b/dragontts/app/audio/resample.py new file mode 100644 index 000000000..7873f603c --- /dev/null +++ b/dragontts/app/audio/resample.py @@ -0,0 +1,137 @@ +"""Stateful streaming PCM resampler (vectorized numpy). + +Providers whose streaming APIs emit PCM at a rate other than the native cache +rate (16 kHz) need to resample *incrementally* to forward live 16 kHz chunks on +a cache miss (low TTFB) instead of buffering the whole clip: + +- Gemini streams raw PCM at 24 kHz over gRPC. +- Sarvam ``bulbul:v2`` / ``bulbul:v3`` stream at 22050 / 24000 Hz over WS. + +This module feeds each upstream chunk through a linear-interpolation resampler +that carries the fractional read position and a small tail across calls, so +seams align chunk-to-chunk. Pure numpy (no scipy); same linear-interp quality +as the one-shot Gemini synth path. For speech the sub-sample position drift over +very long clips is inaudible. + +When ``out_rate < in_rate`` (downsampling) high-frequency input content above +the output Nyquist can alias slightly, exactly as the one-shot linear path +does; acceptable for speech and matches existing behaviour. +""" + +from __future__ import annotations + +import numpy as np + + +class StreamingResampler: + """int16 LE PCM at ``in_rate`` -> int16 LE PCM at ``out_rate``, streamed. + + Feed chunks via :meth:`push` and finish with :meth:`flush` to drain the tail. + Holds a small leftover buffer + a fractional read position between calls so + chunk boundaries don't introduce a discontinuity. + """ + + def __init__(self, in_rate: int, out_rate: int): + if in_rate <= 0 or out_rate <= 0: + raise ValueError("rates must be positive") + self._in_rate = in_rate + self._out_rate = out_rate + # Input samples advanced per emitted output sample. + self._step = in_rate / out_rate + self._carry = np.empty(0, dtype=np.float32) # leftover input (float) + # Fractional input index (relative to the start of ``_carry``) of the + # next output sample to emit. + self._pos = 0.0 + # A single held byte from an odd-length chunk (trailing half-frame); + # prepended to the next push so we only ever feed whole int16 frames. + self._held_byte: bytes = b"" + + def push(self, data: bytes) -> bytes: + """Resample one chunk of int16 LE PCM; return resampled int16 LE bytes. + + May return ``b""`` when there isn't enough buffered input to emit a + whole output sample yet (the bytes are held for the next push). + """ + if not data: + return b"" + data = self._held_byte + data + extra = len(data) & 1 + if extra: # odd length: keep the trailing byte for the next call + self._held_byte = data[-1:] + data = data[:-1] + else: + self._held_byte = b"" + if not data: + return b"" + x = np.frombuffer(data, dtype=" bytes: + n = buf.shape[0] + # An output sample at fractional input index ``idx`` uses samples + # ``floor(idx)`` and ``floor(idx)+1``; both must be real samples, i.e. + # ``floor(idx) <= n-2``. Compute a generous candidate count, then drop + # any candidate whose ``lo`` has no following sample (the off-by-one at + # the tail that would otherwise invent a phantom sample past the buffer). + avail = n - 1 - self._pos + if avail <= 0: + # Not enough room for even one output — keep it all, wait for more. + self._carry = buf + return b"" + k = int(avail // self._step) + 1 # generous upper bound (>=1) + ks = np.arange(k, dtype=np.float64) + indices = self._pos + ks * self._step + lo = np.floor(indices).astype(np.int64) + mask = lo <= n - 2 # both interpolation samples must exist + if not mask.any(): + self._carry = buf + return b"" + # ``lo`` is non-decreasing so the True entries are a leading prefix, + # but boolean-index to be robust regardless of ordering. + indices = indices[mask] + lo = lo[mask] + hi = lo + 1 # now guaranteed <= n-1 by the mask + frac = (indices - lo).astype(np.float32) + vals = buf[lo] + frac * (buf[hi] - buf[lo]) + out = np.clip(vals, -32768.0, 32767.0).astype(" bytes: + """Drain any trailing output by extending the carry with its last sample. + + Emits at most one or two samples, so the cached clip is at most a + fraction of a millisecond short — negligible. + """ + if self._carry.size == 0: + return b"" + buf = np.concatenate([self._carry, self._carry[-1:]]) + out = self._resample(buf) + self._carry = np.empty(0, dtype=np.float32) + self._pos = 0.0 + return out + + @property + def in_rate(self) -> int: + return self._in_rate + + @property + def out_rate(self) -> int: + return self._out_rate + + +def resample_clip(data: bytes, in_rate: int, out_rate: int) -> bytes: + """One-shot convenience: resample a complete int16 LE PCM clip. + + Equivalent to feeding the whole buffer through a fresh + :class:`StreamingResampler` and flushing. Kept as a named helper so callers + that already have the full clip (the one-shot synth path) read clearly. + """ + rs = StreamingResampler(in_rate, out_rate) + return rs.push(data) + rs.flush() diff --git a/dragontts/app/audio/text.py b/dragontts/app/audio/text.py new file mode 100644 index 000000000..397cb0949 --- /dev/null +++ b/dragontts/app/audio/text.py @@ -0,0 +1,171 @@ +"""TTS text normalization — expand numbers to words so providers read amounts +correctly (ElevenLabs garbles digit+word hybrids like "5 hundred 99"). + +Applied in :meth:`CacheService._resolve` after :func:`normalize_text`, to BOTH +the text sent to the provider and the cache key (so "599" and "5 hundred 99" +collapse to one entry). + +Scope (gated by ``TTS_NORMALIZE_NUMBERS``): + * <= 8 integer digits -> Indian cardinal (lakh/crore): 100000 -> "one lakh", + 10000000 -> "one crore" + * >= 9 integer digits -> digit-by-digit (phone/order ids) + * decimals -> "point" + up to 10 places: 5.99 -> "five point nine nine" + * word-multipliers (hundred/thousand/lakh/crore) left as-is + +Leading-dot handling (``TTS_LEADING_DOT``, ElevenLabs only — Cartesia reads a +stray "." fine): a "." is PREPENDED to the text sent to ElevenLabs (no space, +idempotent) so it reads the first word/number cleanly. This is a SYNTH-only +hint: it is applied by :func:`prepend_leading_dot` at the provider call site, +NEVER to the cache key, so "your order" and ".your order" share one entry. +""" + +from __future__ import annotations + +import re + +from app.core.config import settings + +_ONES = [ + "zero", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + "thirteen", + "fourteen", + "fifteen", + "sixteen", + "seventeen", + "eighteen", + "nineteen", +] +_TENS = [ + "", + "", + "twenty", + "thirty", + "forty", + "fifty", + "sixty", + "seventy", + "eighty", + "ninety", +] + +# A number token: digits + commas, optionally a decimal point WITH digits after. +# A trailing "." (sentence period, no following digits) is NOT part of the number +# (else "99." would parse as a decimal -> "ninety nine point "). +_TOKEN_NUM = re.compile(r"^(\W*)(\d[\d,]*(?:\.\d+)?)(\W*)$") + + +def _under_thousand(x: int) -> str: + parts = [] + if x >= 100: + parts.append(f"{_ONES[x // 100]} hundred") + x %= 100 + if x: + parts.append( + _ONES[x] + if x < 20 + else _TENS[x // 10] + ("" if x % 10 == 0 else " " + _ONES[x % 10]) + ) + return " ".join(parts) or "zero" + + +def num_to_words(n: int) -> str: + """Indian cardinal: lakh (10^5), crore (10^7). 100000 -> 'one lakh'.""" + if n == 0: + return "zero" + parts = [] + cr, n = divmod(n, 10_000_000) + la, n = divmod(n, 100_000) + th, n = divmod(n, 1_000) + if cr: + parts.append(_under_thousand(cr) + " crore") + if la: + parts.append(_under_thousand(la) + " lakh") + if th: + parts.append(_under_thousand(th) + " thousand") + if n: + parts.append(_under_thousand(n)) + return " ".join(parts) + + +def _expand_number(s: str) -> str: + """Expand a digit string (commas / one decimal allowed) to words. Defensive + against empty / punctuation-only input (returns it unchanged).""" + s = s.replace(",", "").strip() + if not s or not any(c.isdigit() for c in s): + return s + if "." in s: # decimal: integer part as words + "point" + each digit (<=10) + whole, _, frac = s.partition(".") + digits = [d for d in frac if d.isdigit()][:10] + int_part = num_to_words(int(whole)) if whole.isdigit() else "zero" + if not digits: # "5." (trailing period) -> just the integer + return int_part + return int_part + " point " + " ".join(_ONES[int(d)] for d in digits) + if len(s) >= 9: # 9+ integer digits -> digit-by-digit (phone/order id) + return " ".join(_ONES[int(d)] for d in s if d.isdigit()) + return num_to_words(int(s)) + + +def normalize_numbers(text: str) -> str: + """Expand standalone digit tokens to English words (Indian grouping). + + Trailing sentence punctuation is kept ("99." -> "ninety nine."). A leading + "." before a digit stays on the token (".5" -> ".five"); the ElevenLabs + leading-dot prepend is handled separately by :func:`prepend_leading_dot` + (synth-only, never the key).""" + out = [] + for tok in text.split(): + m = _TOKEN_NUM.match(tok) + if not m: + out.append(tok) + continue + out.append(m.group(1) + _expand_number(m.group(2)) + m.group(3)) + return " ".join(out) + + +def normalize_for_tts(text: str, provider: str) -> str: + """Number expansion for the cache KEY and the synth-text base (all providers). + + "599" and "5 hundred 99" collapse to one entry. No-op when + ``TTS_NORMALIZE_NUMBERS`` is false. This is KEY text — it must be + provider-hint-free, so the ElevenLabs leading dot is NOT applied here (it is + a synth-only concern, applied by :func:`prepend_leading_dot`). ``provider`` + is accepted for call-site symmetry but currently does not alter the result. + """ + if not settings.tts_normalize_numbers: + return text + return normalize_numbers(text) + + +def prepend_leading_dot(text: str, provider: str) -> str: + """ElevenLabs-only SYNTH hint: ensure ``text`` starts with "." (no space). + + A stray leading dot makes ElevenLabs read the first word/number cleanly (it + garbles digit+word hybrids like "5 hundred 99" without it). Applied ONLY to + the text sent to the provider — never to the cache key — so equivalent inputs + ("your order" and ".your order") share one entry. Other providers are + unaffected. Idempotent (never double-dots) and a no-op for empty text. + + Independent of number expansion: gated by ``TTS_LEADING_DOT`` alone, so the + dot still applies when ``TTS_NORMALIZE_NUMBERS`` is off.""" + if ( + settings.tts_leading_dot + and text + and text.strip() # whitespace-only -> no bare "." + and provider + and provider.strip().lower() == "elevenlabs" + and not text.startswith(".") + ): + return "." + text + return text diff --git a/dragontts/app/cache/key.py b/dragontts/app/cache/key.py new file mode 100644 index 000000000..14c5a7b59 --- /dev/null +++ b/dragontts/app/cache/key.py @@ -0,0 +1,89 @@ +"""Cache-key derivation — normalization + canonical params + hashing. + +The key fully determines the cached bytes. ``model_id`` carries routing as +``:`` and is split on the FIRST colon only, so a model that +itself contains a colon (e.g. sarvam ``bulbul:v3``) parses correctly: +``"sarvam:bulbul:v3"`` -> provider ``sarvam``, model ``bulbul:v3``. +""" + +from __future__ import annotations + +import hashlib +import json +import unicodedata + +from app.core.config import PROVIDER_DEFAULTS + +# Unit separator between key parts — prevents field-merge ambiguity. +_SEP = "\x1f" + +# Invisible / zero-width characters to strip. LLM tokenization emits these as +# artifacts inside transliterated words (e.g. Telugu "వెబ్‌సైట్" carries a ZWJ +# ‌ between వెబ్ and సైట్). They have no TTS value, can make a provider +# mis-synthesize the word (white noise), and would otherwise split one spoken +# phrase into two cache keys (with-ZWJ vs without). Stripped before key + synth. +_ZW = "​‌‍‎‏­" +_ZW_TABLE = str.maketrans("", "", _ZW) + + +def normalize_text(text: str) -> str: + """Strip zero-width chars, NFC-normalize, then collapse internal whitespace.""" + text = text.translate(_ZW_TABLE) + return " ".join(unicodedata.normalize("NFC", text).split()) + + +def parse_model_id(model_id: str) -> tuple[str, str]: + """Split ``:`` on the first colon. Provider names contain + no colon; the model half may (e.g. ``bulbul:v3``).""" + if ":" not in model_id: + raise ValueError(f"model_id must be ':', got {model_id!r}") + provider, model = model_id.split(":", 1) + provider = provider.strip().lower() + model = model.strip() + if not provider or not model: + raise ValueError(f"model_id missing provider or model: {model_id!r}") + return provider, model + + +def canonical_params(provider: str, params: dict | None) -> str: + """Canonical JSON of tuning params, dropping values equal to provider + defaults. Returns "" when all params are default/absent, so identical- + default requests collapse to one entry while differing params get distinct + entries.""" + params = params or {} + defaults = PROVIDER_DEFAULTS.get(provider, {}) + kept = { + k: params[k] + for k in sorted(params) + if params[k] is not None and not (k in defaults and params[k] == defaults[k]) + } + if not kept: + return "" + return json.dumps(kept, sort_keys=True, separators=(",", ":")) + + +def hash_key( + *, + text: str, + provider: str, + voice_id: str, + model: str, + language: str, + params_canonical: str, +) -> str: + """Deterministic SHA-256 over every input that affects the audio. + + The requested output_format is intentionally NOT part of the key: the cache + stores audio in the provider's native format and converts to the requested + format on serve, so one entry serves every format (one-shot μ-law and + streaming PCM share it). + """ + parts = [ + normalize_text(text), + provider, + voice_id, + model, + language or "", + params_canonical, + ] + return hashlib.sha256(_SEP.join(parts).encode("utf-8")).hexdigest() diff --git a/dragontts/app/cache/metrics.py b/dragontts/app/cache/metrics.py new file mode 100644 index 000000000..6f4562896 --- /dev/null +++ b/dragontts/app/cache/metrics.py @@ -0,0 +1,174 @@ +"""Write-behind metrics: defer the stats-only HIT writes off the read path. + +A cache HIT currently awaits a SQLite ``touch_and_record`` (a WAL commit) before +returning audio. This batches those updates — per-key touch sums + summed metric +deltas — and flushes them on an interval, a batch threshold, or shutdown, so a HIT +returns audio without waiting on the DB writer. + +Stats-only: a crash can lose at most one flush-window of *counters* (never wrong +audio — the cache row itself is unchanged, and the correctness-critical writes +``put`` / ``put_with_totals`` / ``delete`` / ``adjust_totals`` stay synchronous on +the metadata store). +""" + +from __future__ import annotations + +import asyncio + +from app.core.logging import logger + + +class WriteBehindMetrics: + """Batch + defer ``touch_and_record`` and ``record_metrics`` behind the read path.""" + + def __init__(self, metadata, flush_interval_s: float, flush_batch: int): + self._meta = metadata + self._flush_interval = flush_interval_s + self._flush_batch = max(1, flush_batch) + self._touches: dict[str, dict[str, int]] = {} + self._counters: dict[str, int] = {} + # Per-provider accumulators: provider-given calls route here (flushed via + # record_metrics(provider=...) which writes global + per-provider in one + # store call, so the global daily row is never double-counted). + self._provider_counters: dict[str, dict[str, int]] = {} + self._touch_provider: dict[str, str] = {} # key -> provider (constant per key) + self._latency: list[tuple] = [] # (kind, latency_us) samples + self._lock = asyncio.Lock() + self._pending = 0 + self._task: asyncio.Task | None = None + self._stopped = False + # Strong refs to threshold-spawned flush tasks so the GC can't collect + # them mid-flight (an unreferenced task may be cancelled before it runs). + self._flush_tasks: set[asyncio.Task] = set() + + def start(self) -> None: + if self._task is None and not self._stopped: + self._task = asyncio.create_task(self._run()) + + def _spawn_flush(self) -> None: + """Schedule a flush now (batch threshold crossed), keeping a strong ref.""" + task = asyncio.create_task(self._flush()) + self._flush_tasks.add(task) + task.add_done_callback(self._flush_tasks.discard) + + async def stop(self) -> None: + self._stopped = True + task, self._task = self._task, None + if task is not None: + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + # Let any threshold-spawned flush finish writing its already-swapped-out + # batch before draining — cancelling it mid-write would drop those + # counters. Bounded by a timeout so a stuck flush can't hang shutdown; + # anything still pending after is caught by the final drain below. + if self._flush_tasks: + _done, pending = await asyncio.wait(list(self._flush_tasks), timeout=5.0) + for t in pending: + t.cancel() + self._flush_tasks.clear() + await self._flush() # final drain so graceful shutdown loses nothing + + async def touch_and_record( + self, key: str, deltas: dict, *, provider: str | None = None + ) -> None: + async with self._lock: + d = self._touches.setdefault(key, {}) + for k, v in deltas.items(): + d[k] = d.get(k, 0) + int(v) + if provider is not None: + self._touch_provider[key] = provider + self._pending += 1 + hot = self._pending >= self._flush_batch + if hot: + self._spawn_flush() # batch threshold -> flush promptly + + async def record_metrics( + self, *, provider: str | None = None, **deltas: int + ) -> None: + async with self._lock: + if provider is not None: + pc = self._provider_counters.setdefault(provider, {}) + for k, v in deltas.items(): + pc[k] = pc.get(k, 0) + int(v) + else: + for k, v in deltas.items(): + self._counters[k] = self._counters.get(k, 0) + int(v) + self._pending += 1 + hot = self._pending >= self._flush_batch + if hot: + self._spawn_flush() + + async def record_latency( + self, kind: str, latency_us: int, provider: str | None = None + ) -> None: + async with self._lock: + if len(self._latency) < 10000: # bound memory if a flush ever stalls + self._latency.append((kind, int(latency_us), provider)) + self._pending += 1 + hot = self._pending >= self._flush_batch + if hot: + self._spawn_flush() + + async def _flush(self) -> None: + # Swap the accumulators out under the lock, then write without holding it + # (so a slow flush doesn't block accumulation). Concurrent flushes are safe + # — the later one swaps empty dicts and no-ops. + async with self._lock: + if not ( + self._touches + or self._counters + or self._provider_counters + or self._latency + ): + self._pending = 0 + return + touches, self._touches = self._touches, {} + touch_provider, self._touch_provider = self._touch_provider, {} + counters, self._counters = self._counters, {} + provider_counters, self._provider_counters = self._provider_counters, {} + latency, self._latency = self._latency, [] + self._pending = 0 + for key, d in touches.items(): + try: + # The store's touch_and_record bumps hit_count by the ``hits`` + # delta (default 1) and sums all deltas into the daily rollup, so + # one batched call == N synchronous calls exactly. The provider + # (constant per key) is forwarded so the per-provider row is written. + await self._meta.touch_and_record( + key, d, provider=touch_provider.get(key) + ) + except Exception as e: + logger.warning(f"write-behind touch flush failed ({key[:8]}…): {e}") + if counters: + try: + await self._meta.record_metrics(**counters) + except Exception as e: + logger.warning(f"write-behind metrics flush failed: {e}") + for prov, d in provider_counters.items(): + try: + # record_metrics(provider=...) writes the global daily row AND the + # per-provider subset in one store call (no double-count). + await self._meta.record_metrics(provider=prov, **d) + except Exception as e: + logger.warning( + f"write-behind provider metrics flush failed ({prov}): {e}" + ) + if latency: + try: + await self._meta.record_latency_batch(latency) + except Exception as e: + logger.warning(f"write-behind latency flush failed: {e}") + + async def _run(self) -> None: + while not self._stopped: + try: + await asyncio.sleep(self._flush_interval) + await self._flush() + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"write-behind flush loop error: {e}") + await asyncio.sleep(self._flush_interval) diff --git a/dragontts/app/cache/resilience.py b/dragontts/app/cache/resilience.py new file mode 100644 index 000000000..e8ed76266 --- /dev/null +++ b/dragontts/app/cache/resilience.py @@ -0,0 +1,136 @@ +"""Per-provider resilience: bulkhead (concurrency cap) + token-bucket rate limit. + +A **bulkhead** caps how many in-flight synth/stream calls one provider may have, so +a slow or hung provider can't exhaust shared resources (the single event loop, the +``to_thread`` worker pool, memory) and starve the *other* providers. The **rate +limiter** keeps us under provider concurrency/character limits (avoids 429s). + +Both are configurable per provider via global defaults plus a JSON override (see +:mod:`app.core.config`). ``get_gate(provider)`` always returns a (cached) gate — a +no-op when both limiters are disabled (0) — so call sites just ``async with gate:``. +""" + +from __future__ import annotations + +import asyncio +import time + +from app.core.config import settings + + +class ProviderBusy(Exception): + """Bulkhead full — provider is at its concurrent-synth cap within the wait + window. Maps to a retryable 503 (with ``Retry-After``).""" + + +class _TokenBucket: + """Async token-bucket: ``rate`` tokens/sec, ``capacity`` burst. ``acquire`` waits.""" + + def __init__(self, rate_per_sec: float, capacity: float | None = None): + self._rate = float(rate_per_sec) + self._capacity = float(capacity if capacity and capacity > 0 else rate_per_sec) + self._tokens = self._capacity + self._updated = time.monotonic() + self._lock = asyncio.Lock() + + def _refill(self) -> None: + now = time.monotonic() + self._tokens = min( + self._capacity, self._tokens + (now - self._updated) * self._rate + ) + self._updated = now + + async def acquire(self, n: float = 1.0) -> None: + # Re-check under the lock, sleep for exactly the refill time needed, retry. + while True: + async with self._lock: + self._refill() + if self._tokens >= n: + self._tokens -= n + return + deficit = n - self._tokens + wait = deficit / self._rate if self._rate > 0 else 0.0 + await asyncio.sleep(wait) + + +class ResilienceGate: + """Bulkhead (semaphore) + rate limiter (token bucket) for one provider. + + No-op when both ``max_concurrent`` and ``rate_per_sec`` are 0/None (the gate is + still held/entered, it just does nothing) so call sites are unconditional. + """ + + def __init__(self, max_concurrent: int, rate_per_sec: float, wait_timeout: float): + self._sem: asyncio.Semaphore | None = ( + asyncio.Semaphore(max_concurrent) + if max_concurrent and max_concurrent > 0 + else None + ) + self._bucket: _TokenBucket | None = ( + _TokenBucket(rate_per_sec) if rate_per_sec and rate_per_sec > 0 else None + ) + self._wait_timeout = wait_timeout + + async def acquire(self) -> None: + # Acquire the bulkhead first. If the rate-limiter then fails or is + # cancelled, release the bulkhead ourselves so a partial acquire can't + # leak a slot. On success, __aexit__ releases exactly once. + if self._sem is not None: + try: + await asyncio.wait_for(self._sem.acquire(), timeout=self._wait_timeout) + except asyncio.TimeoutError as e: + raise ProviderBusy("provider at concurrent-synth cap") from e + if self._bucket is not None: + try: + await self._bucket.acquire() + except BaseException: + if self._sem is not None: + self._sem.release() + raise + + def release(self) -> None: + # Paired 1:1 with a successful acquire() — every __aenter__ that returned + # acquired exactly one bulkhead permit, so __aexit__ releases exactly one. + # Deliberately NO per-call flag: the gate is a cached singleton shared by + # ALL concurrent callers for a provider, so instance state would be + # clobbered by overlapping holders and silently leak the semaphore + # (under-releasing until the provider locks up with ProviderBusy). + if self._sem is not None: + self._sem.release() + + async def __aenter__(self) -> "ResilienceGate": + await self.acquire() + return self + + async def __aexit__(self, *exc) -> None: + self.release() + + +_GATE_CACHE: dict[str, ResilienceGate] = {} + + +def _config(provider: str) -> tuple[int, float, float]: + overrides = settings.provider_resilience_overrides.get(provider, {}) or {} + max_concurrent = int( + overrides.get("max_concurrent", settings.provider_max_concurrent_synths) + ) + rate = float(overrides.get("rate_per_sec", settings.provider_rate_limit_per_sec)) + wait_ms = int( + overrides.get("wait_timeout_ms", settings.provider_bulkhead_wait_timeout_ms) + ) + return max_concurrent, rate, wait_ms / 1000.0 + + +def get_gate(provider: str) -> ResilienceGate: + """Return the cached ResilienceGate for ``provider`` (no-op if disabled).""" + gate = _GATE_CACHE.get(provider) + if gate is None: + mc, rate, wait = _config(provider) + gate = ResilienceGate(mc, rate, wait) + _GATE_CACHE[provider] = gate + return gate + + +def reset_gates() -> None: + """Drop cached gates (tests / config reload).""" + _GATE_CACHE.clear() diff --git a/dragontts/app/cache/service.py b/dragontts/app/cache/service.py new file mode 100644 index 000000000..ad0a53b38 --- /dev/null +++ b/dragontts/app/cache/service.py @@ -0,0 +1,1363 @@ +"""Cache service — lookup / synth / store-native / convert-on-serve + metrics. + +The cache key is format-agnostic (text + provider + voice + model + language + +params). Audio is stored once in the provider's *native* format and converted to +the caller's requested ``output_format`` on serve, so a single entry serves +every format — the one-shot μ-law path and the streaming PCM path share it. + +Read path: cache check → HIT loads native, converts to requested, returns +(+ records a hit) → MISS synthesizes native, write-throughs native, converts, +returns (+ records a miss). Streaming MISS forwards native chunks live (when +the requested format == native) and stores native on clean completion. +Admin: check / create / delete / clear. Metrics flow into a daily rollup; the +cache snapshot is served from incrementally-maintained totals. +""" + +from __future__ import annotations + +import asyncio +import random +import re +import time +from collections.abc import AsyncGenerator +from datetime import datetime, timedelta, timezone +from typing import Callable + +from app.audio.format import convert_audio +from app.audio.text import normalize_for_tts, prepend_leading_dot +from app.cache.key import ( + canonical_params, + hash_key, + normalize_text, + parse_model_id, +) +from app.cache.metrics import WriteBehindMetrics +from app.cache.resilience import get_gate +from app.core.config import settings +from app.core.logging import logger +from app.providers.base import AudioResult, BaseTTSProvider, ProviderError +from app.providers.registry import ProviderNotConfigured +from app.schemas.tts import TTSRequest +from app.storage.base import CacheRecord, escape_like + + +def _wc(text: str | None) -> int: + """Word count of a transcript (whitespace tokens, punctuation kept).""" + return len((text or "").split()) + + +# Sentinel: caller has NOT pre-fetched the existing record (vs. None = checked, +# genuinely absent). Avoids a redundant get() on every store when the caller +# already knows. +_UNCHECKED = object() + +# Chunk size used when streaming a complete blob back to the caller (HIT, or a +# one-shot miss). Large enough to amortize per-iteration overhead, small enough +# to start flowing promptly. +_STREAM_CHUNK = 16 * 1024 + + +async def _chunked( + data: bytes, size: int = _STREAM_CHUNK +) -> AsyncGenerator[bytes, None]: + """Yield ``data`` in fixed-size byte chunks for an HTTP streaming response.""" + for i in range(0, len(data), size): + yield data[i : i + size] + + +def _split_transcript(text: str, symbols: str, min_words: int = 2) -> list[str]: + """Split ``text`` into sentence chunks at whitespace following a ``symbols`` char. + + Used by the SPLIT_AT_SYMBOLS feature so a multi-sentence transcript (e.g. a + greeting clairvoyance sends whole) is synthesized + cached one sentence per + key — recurring sentences then reuse their own entry. Each chunk KEEPS its + trailing punctuation (the per-part cache key matches a request that sends + that sentence alone). The split is at whitespace AFTER a symbol, which keeps + decimals ("3.14"), IPs ("192.168.1.1") and URLs ("example.com/path") intact + — the dot there isn't followed by whitespace. Empty/whitespace-only results + are dropped. + + ``min_words`` is a PER-PART gate: a part with fewer words is too small to be + worth its own cache entry, so consecutive short parts are merged into one — + but a long sentence is NEVER merged with a short neighbour. So a single short + sentence no longer collapses the WHOLE phrase to one entry: "thanks. OK. that + will be all" => ["thanks. OK.", "that will be all"] (the long sentence keeps + its own key) instead of one merged chunk. + + Returns ``[text]`` (one chunk) when ``symbols`` is empty, no split point + exists, or every part ended up merged back into one — so a caller's + ``len(parts) > 1`` check is the on/off gate. + + Note: abbreviations like "Mr. Smith" WILL split (no dictionary) — acceptable + for the scripted-greeting use case; set ``symbols`` to "." only to minimize it. + """ + if not symbols or not text or not text.strip(): + return [text] if (text and text.strip()) else [] + parts = [ + p.strip() + for p in re.split(rf"(?<=[{re.escape(symbols)}])\s+", text) + if p.strip() + ] + if len(parts) > 1: + # Per-part min-words gate: a part with fewer than min_words is too small + # to be worth its own cache entry, so consecutive short parts are merged + # into one (and a trailing short run is kept together). A long sentence + # is never merged with a short neighbour, so one short sentence ("OK.") + # no longer collapses the whole phrase — the recurring long sentences + # keep their own keys. + merged: list[str] = [] + short_run = "" + for p in parts: + if len(p.split()) < min_words: + short_run = f"{short_run} {p}".strip() if short_run else p + else: + if short_run: + merged.append(short_run) + short_run = "" + merged.append(p) + if short_run: + merged.append(short_run) + parts = merged or [text] + return parts + + +# Split parts are pure-concatenated — no edge trim, no inserted gap — so nothing +# is cut and each part's audio is appended exactly as the provider returned it +# (see _get_or_synthesize_split / _stream_split). + + +def _same_format(encoding_a: str, rate_a: int, encoding_b: str, rate_b: int) -> bool: + return encoding_a.lower() == encoding_b.lower() and rate_a == rate_b + + +class CacheService: + def __init__( + self, + metadata, + blobs, + get_provider: Callable[[str], BaseTTSProvider | None], + ): + self._metadata = metadata + self._blobs = blobs + self._get_provider = get_provider + # Write-behind wrapper for stats-only writes (HIT touch + metrics) so a HIT + # returns audio without awaiting a SQLite commit. Falls back to the raw + # store (synchronous) when disabled. Correctness-critical writes + # (put/put_with_totals/delete/adjust_totals) stay on self._metadata. + self._metrics = ( + WriteBehindMetrics( + metadata, + settings.metrics_flush_interval_ms / 1000.0, + settings.metrics_flush_batch_size, + ) + if settings.metrics_write_behind_enabled + else metadata + ) + # Ephemeral session counters (reset on restart); durable metrics are in the DB. + self._hits = 0 + self._misses = 0 + # Per-key in-flight synth futures for single-flight: N concurrent + # identical MISSes share ONE synth + ONE store. Loop-thread-only (no lock). + self._inflight: dict[str, asyncio.Future] = {} + + async def start(self) -> None: + """Start background tasks (write-behind metrics flusher).""" + if hasattr(self._metrics, "start"): + self._metrics.start() + + async def stop(self) -> None: + """Flush + stop background tasks (graceful shutdown loses no metrics).""" + if hasattr(self._metrics, "stop"): + await self._metrics.stop() + + # -- internals ----------------------------------------------------------- + + def _resolve(self, req: TTSRequest): + # Normalize the transcript once so the cache KEY and the text sent to the + # provider SYNTH stay in sync (strips LLM zero-width artifacts like the + # ZWJ in "వెబ్‌సైట్", collapses whitespace, NFC). Without this, the key + # was normalized but the synth text was raw — they could diverge, and ZWJ + # reached the provider (audible artifacts). + req.transcript = normalize_text(req.transcript) + provider, model = parse_model_id(req.model_id) + of = req.output_format + params_canon = canonical_params(provider, req.params) + # Number-normalize the transcript (Indian grouping) so the cache KEY and + # the synth-text base stay in sync -- "599" and "5 hundred 99" share one + # entry. This is KEY text: it is provider-hint-free, so the ElevenLabs + # leading dot is NOT added here (it's a synth-only hint, applied in + # _synthesize / _stream_and_store). A dot-free key matches a request + # that sends the phrase alone, so the entry is reusable. + req.transcript = normalize_for_tts(req.transcript, provider) + key = hash_key( + text=req.transcript, + provider=provider, + voice_id=req.voice.id, + model=model, + language=req.language, + params_canonical=params_canon, + ) + return provider, model, of, params_canon, key + + @staticmethod + def _expired(record: CacheRecord) -> bool: + return bool( + record.ttl_expires_at + and datetime.fromisoformat(record.ttl_expires_at) + <= datetime.now(timezone.utc) + ) + + async def _timed(self, kind: str, t0: float, provider: str) -> None: + """Record a latency sample (sampling-gated) for the avg/p95 rollup, tagged + with the routed provider for the per-provider latency view.""" + rate = settings.metrics_latency_sample_rate + if rate > 0 and random.random() < rate: + await self._metrics.record_latency( + kind, int((time.perf_counter() - t0) * 1_000_000), provider + ) + + async def _timed_chunks(self, gen, t0: float, provider: str): + """Wrap a streamed chunk generator so latency rides the stream lifecycle: + ``ttfb`` at the first yielded byte, ``total`` after the last. stream() + returns the generator BEFORE the request finishes (the caller iterates + it later), so end-to-end latency can't be recorded synchronously like + the one-shot bytes path — it must fire here. ``finally`` covers both + clean completion and a client disconnect (aclose). Each sample is + independently sampling-gated via :meth:`_timed`.""" + first = True + try: + async for chunk in gen: + if first: + await self._timed("ttfb", t0, provider) + first = False + yield chunk + finally: + await self._timed("total", t0, provider) + + async def _synthesize( + self, req: TTSRequest, provider: str, model: str + ) -> AudioResult: + """Synthesize via the routed provider; return its NATIVE-format audio. + + Runs under the provider's resilience gate (bulkhead + rate limit) so a + slow/hung provider is capped and can't starve the others. + """ + instance = self._get_provider(provider) + if instance is None: + raise ProviderNotConfigured(provider) + gate = get_gate(provider) + text = prepend_leading_dot(req.transcript, provider) + async with gate: + t0 = time.perf_counter() + if req.params.get("enable_ssml_parsing"): + # SSML: synthesize via the streaming (WS) path, not the one-shot + # HTTP synth. ElevenLabs renders tags more cleanly over + # the WS path (the HTTP one-shot can split a word across a break), + # so the cached audio is the smoother WS rendering. stream_synth + # falls back to HTTP itself if no warm socket is available. + chunks = [] + async for chunk in instance.stream_synth( + text=text, + voice_id=req.voice.id, + model=model, + language=req.language, + params=req.params, + ): + chunks.append(chunk) + result = AudioResult( + audio=b"".join(chunks), + container="raw", + encoding=instance.native_encoding, + sample_rate=instance.native_sample_rate, + ) + else: + result = await instance.synth( + text=text, + voice_id=req.voice.id, + model=model, + language=req.language, + params=req.params, + ) + await self._timed("synth", t0, provider) + return result + + @staticmethod + def _ttl_expires_at(text: str) -> str | None: + """Length-scaled expiry timestamp (ISO UTC, no microseconds — matches the + purge/backfill format so SQL ``ttl_expires_at < now`` string comparison + is correct). ``clamp(BASE + PER_WORD*words, BASE, MAX)``. Applied to + every stored entry (write-through) — there's no permanent + tier; the periodic purge job evicts expired rows + blobs. Returns None + when TTL is disabled (``BASE<=0``) so the row never expires.""" + if settings.cache_ttl_base_seconds <= 0: + return None + words = _wc(text) + ttl = min( + settings.cache_ttl_base_seconds + + settings.cache_ttl_per_word_seconds * words, + settings.cache_ttl_max_seconds, + ) + return (datetime.now(timezone.utc) + timedelta(seconds=ttl)).strftime( + "%Y-%m-%dT%H:%M:%S+00:00" + ) + + async def _store( + self, + key: str, + req: TTSRequest, + provider: str, + model: str, + params_canon: str, + audio: bytes, + encoding: str, + sample_rate: int, + existing=_UNCHECKED, + replace: bool = False, + ) -> None: + """Store ``audio`` (already in ``encoding``/``sample_rate``) as native. + + Fresh misses (no existing row, ``replace=False``) use INSERT OR IGNORE so + concurrent identical misses can't double-count ``provider_totals``. + Existing rows (expired refresh) and explicit overrides (``replace=True``) + REPLACE the row and adjust totals by the size delta. + """ + if existing is _UNCHECKED: + existing = await self._metadata.get(key) + now = datetime.now(timezone.utc) + storage_path = await self._blobs.put(key, audio) + ttl = self._ttl_expires_at(req.transcript) + record = CacheRecord( + key=key, + provider=provider, + voice_id=req.voice.id, + model=model, + language=req.language, + params=params_canon, + text=req.transcript, + container="raw", + encoding=encoding, + sample_rate=sample_rate, + size_bytes=len(audio), + storage_path=storage_path, + hit_count=0, + created_at=now.isoformat(), + last_accessed_at=now.isoformat(), + ttl_expires_at=ttl, + ) + if existing is None and not replace: + # Fresh miss: INSERT OR IGNORE — race-free totals under concurrent + # identical misses (only the first store inserts + bumps totals). + await self._metadata.put_with_totals(record) + else: + # Existing row (refresh) or explicit override: REPLACE + totals + # delta in one transaction (put + adjust separately would let a + # concurrent delete drift provider_totals). + await self._metadata.replace_with_totals(record) + + async def _convert_audio( + self, + data: bytes, + *, + native_encoding: str, + native_rate: int, + out_encoding: str, + out_rate: int, + ) -> bytes: + """convert_audio off the event loop. + + audioop resample/μ-law conversion is CPU-bound and runs on every serve + (HIT and MISS); inline it would block the loop (and starve the Cartesia + socket pumps) under concurrent load, so dispatch it to the worker pool. + """ + return await asyncio.to_thread( + convert_audio, + data, + native_encoding=native_encoding, + native_rate=native_rate, + out_encoding=out_encoding, + out_rate=out_rate, + ) + + # -- read path ----------------------------------------------------------- + + async def _produce( + self, + req: TTSRequest, + provider: str, + model: str, + params_canon: str, + key: str, + record, + ) -> tuple[bytes, str, int, str]: + """Synth + store for a MISS. Returns (native, encoding, rate, status) + where status is 'MISS', or 'HIT' (if the cache was filled between the + outer lookup and here — e.g. by a concurrent request).""" + rec = await self._metadata.get(key) + if rec and not self._expired(rec): + return ( + await self._blobs.get(rec.storage_path), + rec.encoding, + rec.sample_rate, + "HIT", + ) + native = await self._synthesize(req, provider, model) + if settings.enable_write_through: + await self._store( + key, + req, + provider, + model, + params_canon, + native.audio, + native.encoding, + native.sample_rate, + existing=record, + ) + return native.audio, native.encoding, native.sample_rate, "MISS" + + async def _produce_native( + self, + req: TTSRequest, + provider: str, + model: str, + params_canon: str, + key: str, + record, + ) -> tuple[bytes, str, int, str, bool, bool]: + """Single-flight: produce + store native audio for ``key`` exactly once. + + Concurrent MISSes for the same key share one synth + one store (the + producer's future). Returns (native, encoding, rate, status, produced, + coalesced): + + - produced=True, coalesced=False: we ran the synth (records a miss + + synth_call). + - produced=False, coalesced=True: we awaited an in-flight producer's + future (records a hit — served without our own synth). + - produced=False, coalesced=False: we were the producer, but ``_produce`` + short-circuited to a HIT because a concurrent request filled the key + between our outer MISS lookup and the inner re-fetch (also a hit). + """ + fut = self._inflight.get(key) + if fut is not None: + native, enc, rate, status = await fut + return native, enc, rate, status, False, True # coalesced — no own synth + fut = self._new_inflight(key) + try: + result = await self._produce( + req, provider, model, params_canon, key, record + ) + if not fut.done(): + fut.set_result(result) + native, enc, rate, status = result + # `produced` = a synth actually ran. _produce can short-circuit to + # status='HIT' (a concurrent request filled the key between the outer + # MISS lookup and the inner re-fetch) — that served a cache read, not + # a synth, so it must count as a HIT, not a miss/synth_call. + produced = status == "MISS" + return native, enc, rate, status, produced, False # not coalesced + except BaseException as e: + if not fut.done(): + fut.set_exception(e) + raise + finally: + if self._inflight.get(key) is fut: + self._inflight.pop(key, None) + + def _new_inflight(self, key: str) -> asyncio.Future: + """Create + register the single-flight future for ``key``. + + A done-callback retrieves any exception so a producer that aborts with no + coalesced waiter (e.g. a streaming client disconnects, or a provider + errors mid-stream) doesn't leave an *unretrieved* exception. asyncio logs + those at GC time ("Future exception was never retrieved"), and that log + routes through the loguru intercept and deadlocks. A waiter that DOES + ``await`` the future still raises normally — this callback only marks the + exception retrieved (idempotent with the waiter's own retrieval). + """ + fut = asyncio.get_running_loop().create_future() + fut.add_done_callback(self._consume_inflight_exc) + self._inflight[key] = fut + return fut + + @staticmethod + def _consume_inflight_exc(fut: asyncio.Future) -> None: + if fut.cancelled(): + return + exc = fut.exception() # retrieve -> not "Future exception was never retrieved" + if exc is not None: + logger.warning(f"in-flight synth aborted (no waiter consumed it): {exc!r}") + + async def get_or_synthesize(self, req: TTSRequest) -> tuple[bytes, dict]: + # SPLIT_AT_SYMBOLS: when set and the transcript splits into >1 sentence, + # synth/serve each sentence independently (each caches under its own key). + # Empty (default) / no split -> this is skipped and the single-phrase path + # below runs byte-for-byte unchanged. + if settings.split_at_symbols: + parts = _split_transcript( + req.transcript, + settings.split_at_symbols, + settings.split_min_words_per_part, + ) + if len(parts) > 1: + return await self._get_or_synthesize_split(req, parts) + provider, model, of, params_canon, key = self._resolve(req) + t0 = time.perf_counter() + + record = await self._metadata.get(key) + if record and not self._expired(record): + t_cs = time.perf_counter() + native = await self._blobs.get(record.storage_path) + audio = await self._convert_audio( + native, + native_encoding=record.encoding, + native_rate=record.sample_rate, + out_encoding=of.encoding, + out_rate=of.sample_rate, + ) + await self._metrics.touch_and_record( + key, + { + "requests": 1, + "hits": 1, + "bytes_served": len(audio), + "words_served": _wc(req.transcript), + }, + provider=provider, + ) + self._hits += 1 + logger.info(f"CACHE HIT key={key[:12]}… provider={provider}") + await self._timed("cache_serve", t_cs, provider) + await self._timed("total", t0, provider) + return audio, {"X-Cache": "HIT", "X-Cache-Key": key} + + logger.info(f"CACHE MISS key={key[:12]}… provider={provider} — synthesizing") + + # Single-flight: N concurrent identical misses share one synth + one store. + native, nenc, nrate, status, produced, coalesced = await self._produce_native( + req, provider, model, params_canon, key, record + ) + audio = await self._convert_audio( + native, + native_encoding=nenc, + native_rate=nrate, + out_encoding=of.encoding, + out_rate=of.sample_rate, + ) + if produced: + await self._metrics.record_metrics( + provider=provider, + requests=1, + misses=1, + bytes_served=len(audio), + synth_calls=1, + words_served=_wc(req.transcript), + words_synthesized=_wc(req.transcript), + ) + self._misses += 1 + logger.info(f"CACHE {status} key={key[:12]}… provider={provider}") + else: + # Served from cache without our own synth: either we coalesced onto an + # in-flight producer, or _produce short-circuited to a HIT (a + # concurrent request filled the key between our MISS lookup and the + # inner re-fetch). Both count as a hit. + await self._metrics.touch_and_record( + key, + { + "requests": 1, + "hits": 1, + "bytes_served": len(audio), + "words_served": _wc(req.transcript), + }, + provider=provider, + ) + self._hits += 1 + status = "HIT" + kind = "coalesced" if coalesced else "concurrent fill" + logger.info(f"CACHE HIT ({kind}) key={key[:12]}… provider={provider}") + await self._timed("total", t0, provider) + return audio, {"X-Cache": status, "X-Cache-Key": key} + + # -- SPLIT_AT_SYMBOLS (multi-sentence request -> one entry per sentence) -- + + async def _is_cached(self, req: TTSRequest) -> bool: + """True iff ``req`` resolves to a non-expired cache entry. + + Used by the split stream path to set the aggregate X-Cache header up + front (headers must be returned before the generator streams). + """ + _provider, _model, _of, _params, key = self._resolve(req) + record = await self._metadata.get(key) + return bool(record and not self._expired(record)) + + async def _get_or_synthesize_split( + self, + req: TTSRequest, + parts: list[str], + ) -> tuple[bytes, dict]: + """Serve a multi-sentence request as one cache entry per sentence. + + Clones ``req`` per part and routes each through :meth:`get_or_synthesize` + (so each sentence is synthesized/served + cached under its OWN key and is + reused by any later request that sends that sentence alone), then + concatenates each part's audio VERBATIM — no edge trim, no inserted gap + (nothing is cut; the result is synth(part0) + synth(part1) + ...). The + aggregate X-Cache is HIT only when EVERY part was a HIT, else MISS (at + least one sentence synthesized). The full-phrase key is reported in + X-Cache-Key for caller reference — note the full phrase itself is NOT + cached, only its parts. + + Each part has no whitespace-after-symbol internally (the split consumed + those), so the recursive :meth:`get_or_synthesize` call hits the + single-phrase path and does not recurse further. Only reached when + ``settings.split_at_symbols`` is set AND the transcript splits into >1 part. + """ + # Full-phrase key for the header. _resolve normalizes req.transcript in + # place, but `parts` were already captured from the raw transcript by the + # caller, so mutating req here is harmless. + _p, _m, of, _pc, full_key = self._resolve(req) + part_reqs = [req.model_copy(update={"transcript": p}) for p in parts] + # split_parallel (env, default OFF): synth the parts concurrently instead + # of one at a time. gather preserves submission order, so the + # concatenation stays synth(part0) + synth(part1) + ... — identical audio. + if settings.split_parallel: + results = await asyncio.gather( + *(self.get_or_synthesize(pr) for pr in part_reqs) + ) + else: + results = [await self.get_or_synthesize(pr) for pr in part_reqs] + chunks = [audio for audio, _headers in results] + # Aggregate HIT only when EVERY part was a genuine HIT (any synthed part + # => MISS), from each part's real status — so it can't lie. + all_hit = all(headers.get("X-Cache") == "HIT" for _a, headers in results) + # Pure concatenation: append each part's audio verbatim — no trim, no gap + # (nothing is cut; the result is synth(part0) + synth(part1) + ...). + joined = b"".join(c for c in chunks if c) + return joined, { + "X-Cache": "HIT" if all_hit else "MISS", + "X-Cache-Key": full_key, + } + + async def _stream_split( + self, + req: TTSRequest, + parts: list[str], + ) -> AsyncGenerator[bytes, None]: + """Stream a multi-sentence request one sentence at a time, back to back. + + Each part is served via :meth:`get_or_synthesize` (so it caches under its + own key) and its converted audio chunked out VERBATIM, in order — no edge + trim, no inserted gap (nothing is cut; parts are appended exactly as + synthesized). For a MISS part this is synth-then-yield rather than live + provider streaming — acceptable for short sentences and only when + SPLIT_AT_SYMBOLS is on; the default (flag off) stream path keeps live + provider streaming. Each part's audio is already in the requested output + format (get_or_synthesize converts), so it is emitted unchanged. + """ + for part in parts: + part_req = req.model_copy(update={"transcript": part}) + audio, _headers = await self.get_or_synthesize(part_req) + if not audio: + continue # empty part: skip + async for chunk in _chunked(audio): + yield chunk + + async def _stream_resolved( + self, + results: list[tuple[bytes, dict]], + ) -> AsyncGenerator[bytes, None]: + """Stream already-resolved per-part audio in order, back to back. + + Companion to the ``split_parallel`` stream path: each (audio, headers) + pair was produced by :meth:`get_or_synthesize` (so the audio is already + in the requested output_format) and is emitted verbatim in part order, + chunked — no edge trim, no inserted gap (parts are appended exactly as + synthesized). + """ + for audio, _headers in results: + if not audio: + continue # empty part: skip + async for chunk in _chunked(audio): + yield chunk + + # -- streaming read path ------------------------------------------------ + + async def stream(self, req: TTSRequest) -> tuple[dict, AsyncGenerator[bytes, None]]: + """Resolve ``req`` and return (response headers, audio-chunk generator). + + HIT: native blob loaded, converted to the requested format if needed, + then streamed in fixed-size chunks. + MISS (requested == native): provider chunks forwarded live (low TTFB), + native clip stored on clean completion. + MISS (requested != native): can't resample per-chunk, so synthesize + native fully, store native, convert, then chunk. + """ + t0 = ( + time.perf_counter() + ) # entry; total = entry -> last byte (via _timed_chunks) + # SPLIT_AT_SYMBOLS_STREAM: when set and the transcript splits into >1 + # sentence, stream each sentence independently (each caches under its own + # key). Independent of SPLIT_AT_SYMBOLS (the /tts/bytes knob). Empty + # (default) / no split -> skipped; the single-phrase stream path below + # runs byte-for-byte unchanged. + if settings.split_at_symbols_stream: + parts = _split_transcript( + req.transcript, + settings.split_at_symbols_stream, + settings.split_min_words_per_part, + ) + if len(parts) > 1: + provider, _model, _of, _pc, full_key = self._resolve(req) + if settings.split_parallel: + # Opt-in: serve every part concurrently, THEN derive the + # aggregate X-Cache from each part's TRUE status (no stale + # metadata probe, so it can't claim HIT when a part's TTL + # expired between header and serve). Split-stream is already + # synth-then-yield (not live), so resolving up front adds no + # latency vs the split path's norm; the first byte arrives + # after the slowest part instead of after part0. + part_reqs = [ + req.model_copy(update={"transcript": p}) for p in parts + ] + results = await asyncio.gather( + *(self.get_or_synthesize(pr) for pr in part_reqs) + ) + all_hit = all(h.get("X-Cache") == "HIT" for _a, h in results) + return ( + { + "X-Cache": "HIT" if all_hit else "MISS", + "X-Cache-Key": full_key, + }, + self._timed_chunks( + self._stream_resolved(results), t0, provider + ), + ) + # Default (split_parallel off): set the header from a cheap + # metadata probe (HIT only when EVERY part is cached — any partial + # => MISS), then stream each part in order. The probe can be stale + # on a TOCTOU (a part expires between probe and serve); opt into + # split_parallel above for a truthful header. + cached = [ + await self._is_cached(req.model_copy(update={"transcript": p})) + for p in parts + ] + return ( + { + "X-Cache": "HIT" if all(cached) else "MISS", + "X-Cache-Key": full_key, + }, + self._timed_chunks(self._stream_split(req, parts), t0, provider), + ) + provider, model, of, params_canon, key = self._resolve(req) + + record = await self._metadata.get(key) + if record and not self._expired(record): + t_cs = time.perf_counter() + native = await self._blobs.get(record.storage_path) + if _same_format( + of.encoding, of.sample_rate, record.encoding, record.sample_rate + ): + served, chunks = native, _chunked(native) + else: + served = await self._convert_audio( + native, + native_encoding=record.encoding, + native_rate=record.sample_rate, + out_encoding=of.encoding, + out_rate=of.sample_rate, + ) + chunks = _chunked(served) + await self._metrics.touch_and_record( + key, + { + "requests": 1, + "hits": 1, + "bytes_served": len(served), + "words_served": _wc(req.transcript), + }, + provider=provider, + ) + self._hits += 1 + logger.info(f"CACHE HIT (stream) key={key[:12]}… provider={provider}") + await self._timed("cache_serve", t_cs, provider) + return {"X-Cache": "HIT", "X-Cache-Key": key}, self._timed_chunks( + chunks, t0, provider + ) + + logger.info( + f"CACHE MISS (stream) key={key[:12]}… provider={provider} — streaming synth" + ) + + instance = self._get_provider(provider) + if instance is None: + raise ProviderNotConfigured(provider) + + if _same_format( + of.encoding, + of.sample_rate, + instance.native_encoding, + instance.native_sample_rate, + ): + # Single-flight: if a synth is already in-flight for this key (bytes + # or stream path), coalesce onto it — await the result and stream the + # completed clip — instead of opening a 2nd provider stream. Else + # become the producer: register the future BEFORE any await so a + # concurrent request coalesces onto us (loop-thread-atomic). + fut = self._inflight.get(key) + if fut is not None and not fut.done(): + _h, _g = await self._stream_coalesced( + req, key, fut, of, provider, instance, model, params_canon, record + ) + return _h, self._timed_chunks(_g, t0, provider) + fut = self._new_inflight(key) + return ( + {"X-Cache": "MISS", "X-Cache-Key": key}, + self._timed_chunks( + self._stream_and_store( + req, + instance, + key, + provider, + model, + params_canon, + record, + instance.native_encoding, + instance.native_sample_rate, + fut, + ), + t0, + provider, + ), + ) + + # Requested format differs from native: synth fully, store native, convert. + native = await self._synthesize(req, provider, model) + if settings.enable_write_through: + await self._store( + key, + req, + provider, + model, + params_canon, + native.audio, + native.encoding, + native.sample_rate, + existing=record, + ) + audio = await self._convert_audio( + native.audio, + native_encoding=native.encoding, + native_rate=native.sample_rate, + out_encoding=of.encoding, + out_rate=of.sample_rate, + ) + await self._metrics.record_metrics( + provider=provider, + requests=1, + misses=1, + bytes_served=len(audio), + synth_calls=1, + words_served=_wc(req.transcript), + words_synthesized=_wc(req.transcript), + ) + self._misses += 1 + return {"X-Cache": "MISS", "X-Cache-Key": key}, self._timed_chunks( + _chunked(audio), t0, provider + ) + + async def _stream_and_store( + self, + req: TTSRequest, + instance: BaseTTSProvider, + key: str, + provider: str, + model: str, + params_canon: str, + record, + native_encoding: str, + native_sample_rate: int, + fut: asyncio.Future, + ) -> AsyncGenerator[bytes, None]: + """Forward native provider chunks to the caller; store native on success. + + Partial audio is never cached: if the consumer stops early (client + disconnect) or the provider errors, ``completed`` stays False and the + accumulated bytes are discarded. The inner provider generator is closed + explicitly so its socket is released promptly. + """ + accumulated = bytearray() + completed = False + gate = get_gate(provider) + gen = instance.stream_synth( + text=prepend_leading_dot(req.transcript, provider), + voice_id=req.voice.id, + model=model, + language=req.language, + params=req.params, + ) + try: + # Hold the gate for the whole stream — it IS an in-flight synth. + async with gate: + async for chunk in gen: + accumulated += chunk + yield chunk + completed = True + finally: + await gen.aclose() + if completed and accumulated: + audio = bytes(accumulated) + if settings.enable_write_through: + await self._store( + key, + req, + provider, + model, + params_canon, + audio, + native_encoding, + native_sample_rate, + existing=record, + ) + await self._metrics.record_metrics( + provider=provider, + requests=1, + misses=1, + bytes_served=len(audio), + synth_calls=1, + words_served=_wc(req.transcript), + words_synthesized=_wc(req.transcript), + ) + self._misses += 1 + if not fut.done(): + fut.set_result((audio, native_encoding, native_sample_rate, "MISS")) + logger.info( + f"CACHE STORE (stream) key={key[:12]}… provider={provider} size={len(audio)}B" + ) + elif not fut.done(): + # Aborted (client disconnect) or provider error: partial audio is + # discarded (never cached). Signal coalesced waiters to fall back + # to their own synth rather than 502 on someone else's failure. + fut.set_exception(ProviderError("stream synth failed or aborted")) + if self._inflight.get(key) is fut: + self._inflight.pop(key, None) + + async def _stream_coalesced( + self, + req, + key, + fut, + of, + provider, + instance, + model, + params_canon, + record, + ) -> tuple[dict, AsyncGenerator[bytes, None]]: + """Serve a streaming MISS by awaiting an in-flight producer for ``key``. + + The producer (bytes- or stream-path) is already synthesizing; we wait for + its result, then stream the completed clip in chunks (a coalesced HIT). If + the producer aborted/failed, fall through to our own live synth. + + On the failure path the producer's ``finally`` (which pops ``_inflight``) + runs AFTER ``set_exception`` schedules this waiter, so ``_inflight`` may + STILL hold the producer's now-done future when we reach the re-check + below. The ``not existing.done()`` guard there is load-bearing — it skips + that dead future (and any other done entry) so we coalesce only onto a + genuinely-live producer and never loop back onto the one that just failed. + Do NOT remove it on the assumption the dict is already clear. + (CancelledError is NOT caught: a cancelled waiter dies.) + """ + try: + native, enc, rate, _status = await fut + except Exception: + # Producer failed. Another waiter may already have become the new + # producer — re-check _inflight and coalesce onto it instead of each + # waiter spawning its own synth (bounds a producer failure to ONE + # retry, not N). + existing = self._inflight.get(key) + if existing is not None and not existing.done(): + return await self._stream_coalesced( + req, + key, + existing, + of, + provider, + instance, + model, + params_canon, + record, + ) + fut = self._new_inflight(key) + return ( + {"X-Cache": "MISS", "X-Cache-Key": key}, + self._stream_and_store( + req, + instance, + key, + provider, + model, + params_canon, + record, + instance.native_encoding, + instance.native_sample_rate, + fut, + ), + ) + served = ( + native + if _same_format(of.encoding, of.sample_rate, enc, rate) + else await self._convert_audio( + native, + native_encoding=enc, + native_rate=rate, + out_encoding=of.encoding, + out_rate=of.sample_rate, + ) + ) + await self._metrics.touch_and_record( + key, + { + "requests": 1, + "hits": 1, + "bytes_served": len(served), + "words_served": _wc(req.transcript), + }, + provider=provider, + ) + self._hits += 1 + logger.info(f"CACHE HIT (stream-coalesced) key={key[:12]}… provider={provider}") + return {"X-Cache": "HIT", "X-Cache-Key": key}, _chunked(served) + + # -- admin ops ----------------------------------------------------------- + + async def check(self, req: TTSRequest): + """Return (cached, record_or_none, provider, model, key). No synthesis.""" + provider, model, of, params_canon, key = self._resolve(req) + record = await self._metadata.get(key) + cached = bool(record and not self._expired(record)) + return cached, record, provider, model, key + + async def create(self, req: TTSRequest, audio_override: bytes | None = None): + """Force create/override. Returns + (key, status, source, size_bytes, provider, model, stored_encoding, + stored_sample_rate). + + Synthesized audio is stored in native format; a base64 override is + stored as-is in the requested output_format (the caller asserts the + supplied audio matches it). The returned encoding/rate is what's + ACTUALLY stored. Either way the format-agnostic key means the entry + serves every requested format on read. + """ + provider, model, of, params_canon, key = self._resolve(req) + existing = await self._metadata.get(key) + overridden = bool(existing and not self._expired(existing)) + + if audio_override is not None: + audio = audio_override + store_encoding, store_rate = of.encoding, of.sample_rate + source = "base64" + else: + native = await self._synthesize(req, provider, model) + audio = native.audio + store_encoding, store_rate = native.encoding, native.sample_rate + source = "synth" + + await self._store( + key, + req, + provider, + model, + params_canon, + audio, + store_encoding, + store_rate, + existing=existing, + replace=True, + ) + if source == "synth": + await self._metrics.record_metrics(creates=1, synth_calls=1) + else: + await self._metrics.record_metrics(creates=1, base64_uploads=1) + + status = "OVERRIDDEN" if overridden else "CREATED" + logger.info( + f"CREATE {status} source={source} key={key[:12]}… " + f"provider={provider} size={len(audio)}B" + ) + return ( + key, + status, + source, + len(audio), + provider, + model, + store_encoding, + store_rate, + ) + + async def delete(self, req: TTSRequest): + """Delete by derived key. Returns (deleted_bool, key). + + The store adjusts provider_totals atomically inside the DELETE + transaction (using the row's actual size at delete time), so a concurrent + override of the same key can't make totals drift.""" + provider, model, of, params_canon, key = self._resolve(req) + record = await self._metadata.get(key) + if not record: + return False, key + # storage_path is key-derived (content-addressed), so it's correct even + # if a concurrent override rewrote the row's bytes between get and delete. + if not await self._metadata.delete(key): + return False, key + await self._blobs.delete(record.storage_path) + await self._metrics.record_metrics(deletes=1) + logger.info(f"DELETE key={key[:12]}… provider={provider}") + return True, key + + async def clear( + self, provider: str | None = None, voice_id: str | None = None + ) -> int: + """Delete all entries (optionally filtered). Returns count removed. + + The store adjusts provider_totals atomically inside the DELETE + transaction (SELECT+DELETE in one txn, so a concurrent insert can't + escape the clear).""" + deleted = await self._metadata.delete_filtered( + provider=provider, voice_id=voice_id + ) + for _prov, _size, path in deleted: + await self._blobs.delete(path) + if deleted: + await self._metrics.record_metrics(deletes=len(deleted)) + logger.info( + f"CLEAR removed {len(deleted)} entries (provider={provider}, voice_id={voice_id})" + ) + return len(deleted) + + async def purge_expired(self) -> int: + """Delete entries whose TTL has expired — metadata first (one atomic + transaction, totals kept consistent), then unlink blobs. + + Order matters: if a blob unlink fails we log and continue — the row is + already gone, so an orphaned file is harmless (reaped by + :meth:`reconcile_blobs` at next startup) rather than leaving metadata + pointing at a missing file. Returns the number of entries purged.""" + now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00") + rows = await self._metadata.purge_expired(now_iso) + for _prov, _size, path in rows: + try: + await self._blobs.delete(path) + except Exception as e: + logger.warning(f"purge: blob delete failed for {path}: {e}") + if rows: + logger.info(f"TTL purge: removed {len(rows)} expired entries") + return len(rows) + + async def backfill_missing_ttl(self) -> int: + """One-shot admin op: give every pre-existing NULL-TTL row a RANDOM + expiry in [CACHE_TTL_BACKFILL_MIN_HOURS, MAX_HOURS] so legacy entries + (created before length-scaled TTL, e.g. under ttl_seconds=0) age out + instead of living forever. Idempotent — only touches NULL rows. Trigger + via POST /cache/backfill-ttl (not at startup). Returns rows backfilled.""" + n = await self._metadata.backfill_missing_ttl( + settings.cache_ttl_backfill_min_hours, + settings.cache_ttl_backfill_max_hours, + ) + logger.info(f"TTL backfill: set random expiry on {n} NULL-TTL entries") + return n + + # -- analytics + cache control ------------------------------------------- + + @staticmethod + def _derive(m: dict) -> dict: + """Add hit_rate (+ words_from_cache_pct when the raw sums exist) to a + metrics map. Per-provider rows carry words_synthesized, so + words_from_cache_pct is derived for them too.""" + out = dict(m) + req = m.get("requests", 0) + out["hit_rate"] = round(m["hits"] / req, 4) if req else None + wserved = m.get("words_served", 0) + wsyn = m.get("words_synthesized") + if wserved and wsyn is not None: + # clamp at 0 — a negative savings rate is nonsensical. + out["words_from_cache_pct"] = max(0, int((wserved - wsyn) * 100 / wserved)) + return out + + async def daily_stats( + self, + from_date: str | None = None, + to_date: str | None = None, + provider: str | None = None, + ) -> dict: + """Extensive day-wise metrics: per date, derived totals (hit_rate, + words_from_cache_pct on top of the raw sums) and a per-provider + breakdown. ``provider`` narrows the view to one provider — + the day's totals then reflect that provider only.""" + raw = await self._metadata.daily_summary(from_date, to_date) + lat_by_day = await self._metadata.latency_summary_daily(from_date, to_date) + days = [] + for date in sorted(raw): + byp_all = raw[date]["by_provider"] + if provider: + byp = {provider: byp_all[provider]} if provider in byp_all else {} + base = dict(byp.get(provider, {})) + else: + byp = dict(byp_all) + base = dict(raw[date]["totals"]) + days.append( + { + "date": date, + "totals": self._derive(base), + "by_provider": {p: self._derive(m) for p, m in byp.items()}, + "latency": lat_by_day.get(date, {}), + } + ) + return {"range": {"from": from_date, "to": to_date}, "days": days} + + async def _delete_rows( + self, rows: list[dict], *, dry_run: bool, label: str + ) -> None: + """Blob cleanup for a delete_where result (metadata already gone). Logs + + continues on a per-blob failure so one bad unlink can't abort the batch.""" + if dry_run or not rows: + return + for r in rows: + try: + await self._blobs.delete(r["storage_path"]) + except Exception as e: + logger.warning( + f"{label}: blob delete failed for {r['storage_path']}: {e}" + ) + await self._metrics.record_metrics(deletes=len(rows)) + + @staticmethod + def _entries(rows: list[dict]) -> list[dict]: + return [ + { + "key": r["key"], + "provider": r["provider"], + "voice_id": r["voice_id"], + "text": r["text"], + } + for r in rows + ] + + async def delete_by_text( + self, + text: str | None = None, + provider: str | None = None, + voice_id: str | None = None, + match: str = "exact", + dry_run: bool = True, + ) -> dict: + """Delete cache entries by text (exact or substring), optionally narrowed + by provider/voice. ``dry_run`` defaults True — preview matches + keys + without deleting; set False to delete. Returns matched/deleted counts and + the matched entries.""" + clauses: list[str] = [] + args: list = [] + if text is not None: + if match.lower() == "substring": + # ESCAPE '\\' so a user '%'/_'_' in text matches literally, not as + # a wildcard (else text='_' would match every single-char row). + clauses.append("text LIKE ? ESCAPE '\\'") + args.append(f"%{escape_like(text)}%") + else: + clauses.append("text = ?") + args.append(text) + if provider: + clauses.append("provider = ?") + args.append(provider) + if voice_id: + clauses.append("voice_id = ?") + args.append(voice_id) + if not clauses: + # An empty filter would match (and with dry_run=false, DELETE) the + # ENTIRE cache — that's what /cache/clear is for. Require >= 1 filter. + raise ValueError( + "delete-by-text requires at least one of text/provider/voice_id" + ) + where_sql = " AND ".join(clauses) + rows = await self._metadata.delete_where(where_sql, args, dry_run=dry_run) + await self._delete_rows(rows, dry_run=dry_run, label="delete-by-text") + return { + "matched": len(rows), + "deleted": 0 if dry_run else len(rows), + "dry_run": dry_run, + "entries": self._entries(rows), + } + + async def delete_by_age(self, older_than_days: int, dry_run: bool = True) -> dict: + """Delete entries older than ``older_than_days`` (by created_at). Same + dry_run contract as delete_by_text.""" + cutoff = ( + datetime.now(timezone.utc) - timedelta(days=older_than_days) + ).strftime("%Y-%m-%dT%H:%M:%S+00:00") + rows = await self._metadata.delete_where( + "created_at < ?", [cutoff], dry_run=dry_run + ) + await self._delete_rows(rows, dry_run=dry_run, label="delete-by-age") + return { + "matched": len(rows), + "deleted": 0 if dry_run else len(rows), + "dry_run": dry_run, + "older_than_days": older_than_days, + "entries": self._entries(rows), + } + + async def clear_preview( + self, provider: str | None = None, voice_id: str | None = None + ) -> dict: + """Dry-run preview for /cache/clear: count + bytes + per-provider breakdown + of what WOULD be deleted, without touching anything.""" + clauses: list[str] = [] + args: list = [] + if provider: + clauses.append("provider = ?") + args.append(provider) + if voice_id: + clauses.append("voice_id = ?") + args.append(voice_id) + where_sql = " AND ".join(clauses) if clauses else "" + rows = await self._metadata.delete_where(where_sql, args, dry_run=True) + by_prov: dict[str, list[int]] = {} + for r in rows: + d = by_prov.setdefault(r["provider"], [0, 0]) + d[0] += 1 + d[1] += r["size_bytes"] + return { + "would_delete": len(rows), + "bytes": sum(r["size_bytes"] for r in rows), + "by_provider": { + p: {"entries": c, "bytes": b} for p, (c, b) in by_prov.items() + }, + } + + async def reconcile_blobs(self) -> int: + """Delete blob files that have no metadata row. + + Such orphans arise when a crash/failure lands between a row-delete + commit and its blob unlink: delete/clear commit the row first to + preserve the "never a row pointing at a missing blob" invariant, at the + cost of a possible orphan FILE. Safe + idempotent to run at startup.""" + live = await self._metadata.all_keys() + removed = 0 + async for key, rel_path in self._blobs.iter_blobs(): + if key not in live: + if await self._blobs.delete(rel_path): + removed += 1 + if removed: + logger.info(f"RECONCILE removed {removed} orphaned blob(s)") + return removed + + @property + def session_stats(self) -> dict: + """Ephemeral hit/miss counters since process start.""" + total = self._hits + self._misses + return { + "hits": self._hits, + "misses": self._misses, + "requests": total, + "hit_rate": round(self._hits / total, 4) if total else None, + } diff --git a/dragontts/app/core/config.py b/dragontts/app/core/config.py new file mode 100644 index 000000000..cda55f65f --- /dev/null +++ b/dragontts/app/core/config.py @@ -0,0 +1,258 @@ +"""Application configuration — env-backed settings + provider defaults. + +Provider API-key env names mirror clairvoyance so the same k8s Secret can be +reused across both services. +""" + +from __future__ import annotations + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +# Per-provider defaults. Ported from clairvoyance BB_SPEECH_PROVIDER_DEFAULTS. +# Used as fallbacks when a request omits a field, and as the canonicalization +# baseline for cache-key collapsing (see app/cache/key.py). +PROVIDER_DEFAULTS: dict[str, dict] = { + "cartesia": { + "voice_id": "bec003e2-3cb3-429c-8468-206a393c67ad", + "model": "sonic-3.5", + "speed": 1.0, + "volume": 1.0, + "emotion": "neutral", + "language": "en", + }, + "sarvam": { + "voice_id": "shreya", + "model": "bulbul:v3", + "language": "en-IN", + "speed": 0.9, + "pitch": 0.0, + }, + "elevenlabs": { + "voice_id": "fG9s0SXJb213f4UxVHyG", + "model": "eleven_flash_v2_5", + "speed": 1.15, + "language": "en", + # SSML off by default. Listed here so canonical_params collapses an + # explicit False with "absent" into ONE cache key (both = plain-text + # synth); only enable_ssml_parsing=True gets its own key. ElevenLabs + # only honors this on eleven_flash_v2_5 etc. (NOT eleven_v3). + "enable_ssml_parsing": False, + }, + "gemini": { + "voice_id": "Kore", + "model": "gemini-3.1-flash-tts-preview", + "language": "en-IN", + }, +} + +SUPPORTED_PROVIDERS = tuple(PROVIDER_DEFAULTS.keys()) + +# Indian-residency ElevenLabs host (matches clairvoyance's residency endpoint). +# The residency KEY is the only thing that must be supplied; the URL defaults +# here and is coerced non-empty by the validator below even if .env blanks it. +ELEVENLABS_INDIAN_RESIDENCY_URL = "https://api.in.residency.elevenlabs.io" + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8", extra="ignore", case_sensitive=False + ) + + # --- Provider credentials (env names mirror clairvoyance) --- + cartesia_api_key: str = "" + sarvam_api_key: str = "" + # ElevenLabs runs ONLY against the Indian-residency endpoint (the only creds + # with API access). Env names mirror clairvoyance's residency Secret keys so + # the same k8s Secret is reused; the single "elevenlabs" route resolves here. + elevenlabs_indian_residency_api_key: str = "" + elevenlabs_indian_residency_base_url: str = ELEVENLABS_INDIAN_RESIDENCY_URL + + @field_validator("elevenlabs_indian_residency_base_url", mode="after") + @classmethod + def _coerce_residency_url(cls, v: str) -> str: + # An empty ELEVENLABS_INDIAN_RESIDENCY_BASE_URL= line in .env would + # otherwise blank the default; fall back to the known residency host. + return v or ELEVENLABS_INDIAN_RESIDENCY_URL + + google_credentials_json: str = "" + google_credentials_path: str = "" + + # --- Storage --- + db_path: str = "data/dragontts.db" + blob_dir: str = "data/blobs" + + # --- Cache policy --- + max_cache_bytes: int = 0 # 0 = unlimited + enable_write_through: bool = True + # --- Length-scaled TTL (applied to EVERY stored entry: write-through). + # ttl_for(words) = clamp(BASE + PER_WORD*words, BASE, MAX), so + # longer phrases survive longer (more synth savings to preserve) and short + # ones age out faster. There is no permanent tier: the periodic purge job + # (TTL_PURGE_INTERVAL_SECONDS) deletes expired rows + blobs. Set BASE<=0 to + # disable TTL entirely (entries never expire, no purge). Supersedes the flat + # ttl_seconds knob below, which is kept only for back-compat. + cache_ttl_base_seconds: int = 172800 # 48h floor — min TTL for any phrase + cache_ttl_per_word_seconds: int = 21600 # +6h per word + cache_ttl_max_seconds: int = 518400 # 6d cap + ttl_purge_interval_seconds: int = 1200 # purge sweep cadence (20 min) + # Backfill for pre-existing entries (ttl_expires_at IS NULL, e.g. created + # before this feature under ttl_seconds=0). At startup each NULL row gets a + # RANDOM expiry in [min,max] hours so they age out gradually instead of + # becoming permanent — and randomized (not flat) to avoid a synchronized + # re-synth spike. Idempotent: only touches NULL rows. + cache_ttl_backfill_min_hours: int = 48 + cache_ttl_backfill_max_hours: int = 72 + ttl_seconds: int = 0 # legacy flat TTL; unused now (length-scaled TTL supersedes) + # --- TTS text normalization --- + # Expand standalone numbers to English words (Indian grouping) before synth, + # applied to both the synth text and the cache key (so "599" and + # "5 hundred 99" share one entry). ElevenLabs garbles digit+word hybrids + # ("5 hundred 99" -> "finee hundres"); expanding fixes it. + tts_normalize_numbers: bool = False + # ElevenLabs only: when true, ensure the text starts with "." — prepend one + # (no space) if missing, leave it if already present (helps ElevenLabs read + # digit+word hybrids / number-first openings cleanly). On by default; opt out + # with TTS_LEADING_DOT=false. Applies to EVERY ElevenLabs synth (split or + # not); other providers are unaffected (they read a leading "." fine). + tts_leading_dot: bool = True + # Split an incoming transcript into sentences BEFORE synth/serve, so each + # sentence is synthesized + cached under its own key (reusable by any later + # request that sends that sentence alone — e.g. recurring greetings). Set to + # the symbols to split AFTER, e.g. ".:?!" — the split fires at whitespace + # following one of these, so decimals ("3.14") and URLs are not broken. Empty + # (default) = OFF -> the whole transcript is one entry (today's behavior). + # The synth and stream paths are controlled INDEPENDENTLY (each can be off, or + # use a different symbol set): + split_at_symbols: str = "" # /tts/bytes (one-shot synth) path + split_at_symbols_stream: str = "" # /tts/stream path + # PER-PART min-words gate: a part with fewer words is too small to be worth + # its own cache entry, so consecutive short parts are merged into one — but a + # long sentence is never merged with a short neighbour. So one short sentence + # ("OK.") no longer collapses the whole phrase: the long sentences keep their + # own keys. Default 2 = "more than 1 word". Set to 1 to split even + # single-word sentences. + split_min_words_per_part: int = 2 + # Synthesize the split parts CONCURRENTLY (asyncio.gather) instead of one at + # a time. Off by default — the sequential path is the proven one; opt in only + # once you've confirmed concurrent provider calls are safe for your workload + # (single-flight already de-dupes identical keys). Applies to both the + # /tts/bytes and /tts/stream split paths. When ON, the /tts/stream split + # header is derived from each part's TRUE status (no stale metadata probe); + # when OFF it stays a best-effort probe that may say HIT on a TOCTOU miss. + split_parallel: bool = False + # Split parts are pure-concatenated (no edge trim, no inserted gap) — nothing + # is cut and each part is appended exactly as synthesized. There is no gap + # knob: the provider's own leading/trailing silence between parts is left + # intact. (If you later want a normalized inter-sentence gap, re-introduce it + # in _get_or_synthesize_split / _stream_split.) + + # --- Performance --- + thread_pool_workers: int = 32 # asyncio.to_thread pool size + bulk_create_max: int = 1000 # hard cap on /tts/create/bulk items + # --- Memory: return freed glibc heap to the OS (RSS-creep mitigation) --- + # glibc keeps freed memory in its arenas instead of releasing it to the OS, + # so the large short-lived audio + numpy resample buffers fragment the heap + # and RSS climbs to a high plateau (especially across 4 workers). malloc_trim + # hands that freed memory back to the OS. Safe + idempotent (a no-op when + # nothing is trimmable), and it has NO effect on audio quality or concurrency + # — it only releases already-freed heap; it changes no workers/pools/caps. + # gc.collect() first drops unreachable Python objects that may pin C buffers. + # Runs once per worker process; skipped silently on non-glibc (musl) where + # libc.so.6 / malloc_trim is absent. Default on, every 5 min. + malloc_trim_enabled: bool = True + malloc_trim_interval_seconds: int = 300 + # Number of warm, persistent Cartesia streaming sockets kept ready for cache + # misses (each multiplexes many utterances by context_id). Set via env, e.g. + # CARTESIA_STREAM_POOL_SIZE=4. 0 => open a fresh socket per miss (no pooling). + cartesia_stream_pool_size: int = 2 + # Warm ElevenLabs multi-context WS sockets, pooled PER VOICE (the voice is in + # the WS URL). Each socket multiplexes up to 5 concurrent contexts. Streaming + # misses reuse a warm socket; if none is ready, fall back to one-shot HTTP. + elevenlabs_stream_pool_size: int = 2 + # Max server-silence gap (seconds) after audio starts that ends an ElevenLabs + # WS utterance (ElevenLabs delays is_final ~20s). Lower = faster stream + # close/turn-end; raise if long utterances ever truncate at a >N s pause. + elevenlabs_stream_idle_timeout: float = 0.8 + # Warm Sarvam WS sockets. Sarvam is NOT multiplexed (one utterance per socket + # at a time), so this is a LIFO stack of warm, pre-configured connections. 0 + # => stream via a fresh socket per miss (no pooling). + sarvam_stream_pool_size: int = 2 + # Force IPv4 for the Cartesia WS handshake. Some networks advertise IPv6 + # (AAAA) for api.cartesia.ai but black-hole the SYN, hanging the handshake + # while IPv4 works fine. Safe default (IPv4 always reaches Cartesia). + cartesia_ws_force_ipv4: bool = True + # --- Resilience: per-provider bulkhead (concurrency cap) + rate limit --- + # Caps how many in-flight synth/stream calls ONE provider may have so a slow + # or hung provider can't exhaust shared resources (event loop, worker pool, + # memory) and starve the others. Global defaults; override per provider via + # the JSON env PROVIDER_RESILIENCE='{"cartesia":{"max_concurrent":20, + # "rate_per_sec":8,"wait_timeout_ms":2000}, ...}'. A 0 value disables that + # limiter. The bulkhead waits up to wait_timeout_ms for a slot, else 503. + provider_max_concurrent_synths: int = 24 + provider_rate_limit_per_sec: float = 0.0 + provider_bulkhead_wait_timeout_ms: int = 2500 + provider_resilience_overrides: dict = Field(default_factory=dict) + # --- Write-behind metrics (off the hot HIT path) --- + # HIT touch/metric updates are batched + flushed by a background task so a HIT + # returns audio without awaiting a SQLite write. Flush by interval or batch + # size, and on graceful shutdown. enabled=false -> synchronous writes. + metrics_write_behind_enabled: bool = True + metrics_flush_interval_ms: int = 500 + metrics_flush_batch_size: int = 64 + # Latency sampling: fraction of requests timed for the avg/p95 rollup (0 + # disables). perf_counter is cheap; sampling bounds latency_samples growth. + metrics_latency_sample_rate: float = ( + 0.3 # fraction of requests timed for the latency rollup + ) + # latency_samples rows older than this are pruned by the periodic checkpoint + # loop, keeping the table bounded. + metrics_latency_retention_days: int = 14 + + # --- Slack daily summary (mirrors clairvoyance's incoming-webhook pattern) --- + # Off by default: an empty SLACK_WEBHOOK_URL disables the feature (no separate + # flag, matching clairvoyance). When set, a background task posts a daily + # cache-economics summary (hit rate, words-from-cache %, est. cost saved — + # overall + per provider) at slack_summary_time_utc. Never affects serving. + slack_webhook_url: str = "" + slack_tag_users: str = ( + "" # comma-separated handles/groups to cc (default: Breeze Sentinels) + ) + slack_summary_time_utc: str = "16:30" # daily post time (16:30 UTC == 10 PM IST) + slack_summary_tick_seconds: int = ( + 300 # how often the background loop checks the clock + ) + # Per-provider $/word for the estimated-cost-saved figure (JSON env map). + # cost_saved(provider) = words_from_cache(provider) * rate(provider); total = sum. + # Placeholder rates — calibrate SLACK_COST_PER_WORD to your blended provider pricing. + slack_cost_per_word: dict = Field( + default_factory=lambda: { + # $/word, from dashboard billing. elevenlabs: $70.36/1.36M chars (~6 c/w). + # gemini: $0.000293/word (confirm unit). cartesia/sarvam: placeholders. + "cartesia": 0.000002, + "elevenlabs": 0.00031, + "gemini": 0.000293, + "sarvam": 0.000005, + } + ) + # USD -> INR for the cost-saved DISPLAY (the Slack summary shows rupees, + # rounded to the nearest ₹ — no paisa). Rates above stay $/word; only the + # shown figure is converted. Adjust if the FX rate drifts. + slack_usd_to_inr: float = 96.0 + + @property + def configured_providers(self) -> list[str]: + """Providers whose required credential is present at startup.""" + live: list[str] = [] + if self.cartesia_api_key: + live.append("cartesia") + if self.sarvam_api_key: + live.append("sarvam") + if self.elevenlabs_indian_residency_api_key: + live.append("elevenlabs") + if self.google_credentials_json or self.google_credentials_path: + live.append("gemini") + return live + + +settings = Settings() diff --git a/dragontts/app/core/logging.py b/dragontts/app/core/logging.py new file mode 100644 index 000000000..8a5b83843 --- /dev/null +++ b/dragontts/app/core/logging.py @@ -0,0 +1,100 @@ +"""Loguru logging setup. + +By default emits one JSON object per line with a ``severity`` field — GCP Cloud +Logging auto-detects that field and labels each entry with the correct severity +(without it, GCP can't parse the level out of a text line and shows every log +as ERROR / "!!"). Set ``LOG_JSON=false`` to opt out for a readable local text +line; no env var is needed for prod. + +Stdlib logging (uvicorn's access/error logs, httpx, google, etc.) is routed +through loguru via :class:`InterceptHandler`, so a single sink formats +everything — including uvicorn's logs — with a parseable severity. +""" + +import json +import logging +import os +import sys +import threading +import traceback as _tb + +from loguru import logger + +# Default ON (JSON) so GCP labels severities with no env config. Set LOG_JSON=false +# for a readable local text line. +_JSON = os.environ.get("LOG_JSON", "").lower() not in ("0", "false", "no") + + +def _gcp_json_sink(message) -> None: + rec = message.record + msg = rec["message"] + exc = rec.get("exception") + if exc: + # Fold the traceback into the message so ERROR entries stay debuggable + # in the Logs Explorer (a custom sink doesn't render it automatically). + msg = ( + msg + + "\n" + + "".join(_tb.format_exception(exc.type, exc.value, exc.traceback)) + ) + sys.stderr.write( + json.dumps( + { + # GCP recognises this field name + these level names. + "severity": rec["level"].name, + "message": msg, + "time": rec["time"].isoformat(), + "logger": rec["name"], + "sourceLocation": f'{rec["file"].path}:{rec["line"]}', + }, + default=str, + ) + + "\n" + ) + + +class InterceptHandler(logging.Handler): + """Forward stdlib ``logging`` records into loguru so the single sink (JSON + in prod) formats them with a parseable severity. + + Re-entrancy guard: stdlib logging can fire from inside a loguru sink, a + ``__del__``, or a signal handler (e.g. asyncio logging an unretrieved Future + exception during GC). Calling back into loguru from there deadlocks its + internal lock ("Could not acquire internal lock ... deadlock avoided"). The + guard drops such nested records instead of re-entering and deadlocking. + """ + + _in_emit = threading.local() + + def emit(self, record: logging.LogRecord) -> None: + if getattr(self._in_emit, "active", False): + return # nested stdlib->loguru call: drop to avoid a re-entrant deadlock + self._in_emit.active = True + try: + try: + level = logger.level(record.levelname).name + except (ValueError, TypeError): + level = record.levelno + frame, depth = logging.currentframe(), 2 + while frame and frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + logger.opt(depth=depth, exception=record.exc_info).log( + level, record.getMessage() + ) + finally: + self._in_emit.active = False + + +logger.remove() +logger.add( + _gcp_json_sink if _JSON else sys.stderr, + level="INFO", + enqueue=True, + backtrace=False, + diagnose=False, +) +# Route any stdlib logger that propagates to root (httpx, google, …) through loguru. +logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) + +__all__ = ["logger", "InterceptHandler"] diff --git a/dragontts/app/main.py b/dragontts/app/main.py new file mode 100644 index 000000000..e477872a5 --- /dev/null +++ b/dragontts/app/main.py @@ -0,0 +1,180 @@ +"""DragonTTS FastAPI application. + +Lifespan wires up the metadata store (SQLite), blob store (filesystem), and the +provider registry (built from configured API keys), then hands a CacheService +to the routers via ``app.state``. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import ctypes +import gc +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.api.v1 import cache as cache_api, health, tts +from app.cache.service import CacheService +from app.core.config import settings +from app.core.logging import logger +from app.providers.registry import ProviderRegistry +from app.storage.filesystem import FilesystemBlobStore +from app.storage.sqlite import SQLiteMetadataStore + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Route uvicorn's own loggers (access/error) through the loguru sink so GCP + # labels their severity too — they set propagate=False, so the root + # InterceptHandler alone misses them; override their handlers at startup. + import logging as _logging + + from app.core.logging import InterceptHandler + + for _name in ("uvicorn", "uvicorn.error", "uvicorn.access"): + _lg = _logging.getLogger(_name) + _lg.handlers = [InterceptHandler()] + _lg.propagate = False + + # Sized executor for asyncio.to_thread (blocking sqlite/file I/O) so many + # concurrent requests don't queue on the tiny default pool. + loop = asyncio.get_running_loop() + executor = concurrent.futures.ThreadPoolExecutor( + max_workers=settings.thread_pool_workers + ) + loop.set_default_executor(executor) + + metadata = SQLiteMetadataStore(settings.db_path) + await metadata.init() + + blobs = FilesystemBlobStore(settings.blob_dir) + await blobs.init() + + registry = ProviderRegistry() + registry.build() + await registry.warm() # pre-warm the Cartesia streaming socket pool + + app.state.metadata = metadata + app.state.blobs = blobs + app.state.registry = registry + cache = CacheService(metadata, blobs, registry.get) + await cache.start() # write-behind metrics flusher + app.state.cache = cache + + # Reap blob files orphaned by a prior crash mid-delete/clear (idempotent). + try: + await cache.reconcile_blobs() + except Exception as e: + logger.warning(f"blob reconcile failed: {e}") + + # Periodic WAL checkpoint so the -wal file stays bounded on the PVC while + # worker connections are held open (passive auto-checkpoint won't shrink it). + async def _checkpoint_loop(): + while True: + await asyncio.sleep(300) + try: + await metadata.checkpoint() + await metadata.prune_latency(settings.metrics_latency_retention_days) + except Exception as e: + logger.debug(f"periodic maintenance failed: {e}") + + checkpoint_task = asyncio.create_task(_checkpoint_loop()) + + # Periodic TTL purge: delete expired entries (rows + blobs). Cadence is + # coarse (default 20 min) since TTLs are hour-to-day scale. Backfill of + # pre-existing NULL-TTL entries is NOT automatic — trigger it once via + # POST /cache/backfill-ttl after deploying this feature. + async def _ttl_purge_loop(): + while True: + await asyncio.sleep(settings.ttl_purge_interval_seconds) + try: + await cache.purge_expired() + except Exception as e: + logger.debug(f"TTL purge failed: {e}") + + ttl_purge_task = asyncio.create_task(_ttl_purge_loop()) + + # Once-daily Slack cache-economics summary (overall + per-provider hit rate, + # words-from-cache %, est. cost saved) at slack_summary_time_utc. Webhook + # absent => feature off (send_daily_summary is a no-op). Never fatal. + async def _slack_summary_loop(): + while True: + await asyncio.sleep(settings.slack_summary_tick_seconds) + try: + from app.alerts.summary import send_daily_summary + + await send_daily_summary(cache) + except Exception as e: + logger.debug(f"Slack summary tick failed: {e}") + + slack_summary_task = asyncio.create_task(_slack_summary_loop()) + + # Periodic glibc malloc_trim: return freed heap (the large short-lived audio + # + numpy resample buffers) to the OS so RSS doesn't plateau high. A no-op + # when nothing is trimmable; skipped silently on non-glibc (musl). No effect + # on audio quality or concurrency — it only releases already-freed heap and + # touches no synth/resample/worker/pool path. Runs once per worker process. + async def _malloc_trim_loop(): + if not settings.malloc_trim_enabled: + return + try: + libc = ctypes.CDLL("libc.so.6") + trim = getattr(libc, "malloc_trim", None) + except OSError: + logger.warning( + "malloc_trim: libc.so.6 not found — trim disabled (non-glibc?)" + ) + return + if trim is None: + return + trim.argtypes = [ctypes.c_size_t] + trim.restype = ctypes.c_int + while True: + try: + await asyncio.sleep(settings.malloc_trim_interval_seconds) + gc.collect() # drop unreachable Python objects pinning C buffers + trim(0) + except asyncio.CancelledError: + raise + except Exception as e: + logger.debug(f"malloc_trim failed: {e}") + + malloc_trim_task = asyncio.create_task(_malloc_trim_loop()) + + logger.info(f"DragonTTS ready — providers: {registry.configured()}") + yield + checkpoint_task.cancel() + ttl_purge_task.cancel() + slack_summary_task.cancel() + malloc_trim_task.cancel() + try: + await checkpoint_task + except (asyncio.CancelledError, Exception): + pass + try: + await ttl_purge_task + except (asyncio.CancelledError, Exception): + pass + try: + await slack_summary_task + except (asyncio.CancelledError, Exception): + pass + try: + await malloc_trim_task + except (asyncio.CancelledError, Exception): + pass + await cache.stop() # flush write-behind metrics (graceful shutdown loses none) + try: + await metadata.checkpoint() # compact the WAL before worker conns close + except Exception: + pass + await registry.aclose_all() + executor.shutdown(wait=False, cancel_futures=True) # releases worker sqlite conns + + +app = FastAPI(title="DragonTTS", version="0.1.0", lifespan=lifespan) +app.include_router(tts.router) +app.include_router(cache_api.router) +app.include_router(health.router) diff --git a/dragontts/app/providers/base.py b/dragontts/app/providers/base.py new file mode 100644 index 000000000..aea2f31e4 --- /dev/null +++ b/dragontts/app/providers/base.py @@ -0,0 +1,92 @@ +"""Provider adapter contract. + +Each adapter ports a clairvoyance ``_generate_*_audio`` helper into a uniform +``synth()`` that returns the provider's *native* output format. A separate +conversion layer (app/audio/format.py) then maps that native format to the +caller's requested ``output_format`` before caching. + +``stream_synth()`` is the streaming variant: it yields native-format audio +chunks as the provider synthesizes them (low TTFB on a cache miss). Providers +without a native streaming API inherit the default, which synthesizes the full +clip and yields it as a single chunk — correct, just without the streaming +latency benefit. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator +from dataclasses import dataclass + + +@dataclass +class AudioResult: + """Synthesized audio in the provider's native output format.""" + + audio: bytes + container: str # e.g. "raw" + encoding: str # e.g. "pcm_s16le", "ulaw" + sample_rate: int # e.g. 16000, 8000 + + +class ProviderError(Exception): + """A provider-level synthesis error (upstream returned an error / failed). + + Maps to HTTP 502 in the API layer (vs. network-unreachable -> 503). + """ + + +class BaseTTSProvider(ABC): + """A single TTS backend. + + Subclasses set ``name`` and implement :meth:`synth`. Fields missing from a + request fall back to the provider defaults in app.core.config. + """ + + name: str + + # Native output format — used to decide whether a streaming request can be + # served chunk-by-chunk (matching format) or must fall back to one-shot. + native_encoding: str = "pcm_s16le" + native_sample_rate: int = 16000 + + @abstractmethod + async def synth( + self, + *, + text: str, + voice_id: str, + model: str | None, + language: str | None, + params: dict, + ) -> AudioResult: + """Synthesize ``text`` and return the native-format audio.""" + raise NotImplementedError + + async def stream_synth( + self, + *, + text: str, + voice_id: str, + model: str | None, + language: str | None, + params: dict, + ) -> AsyncGenerator[bytes, None]: + """Yield native-format audio chunks as they are synthesized. + + The default implementation falls back to one-shot :meth:`synth` and + yields the whole clip as a single chunk. Providers with a streaming API + override this to emit incremental chunks for low TTFB on cache misses. + """ + result = await self.synth( + text=text, voice_id=voice_id, model=model, language=language, params=params + ) + yield result.audio + + async def warm(self) -> None: + """Pre-warm provider resources (e.g. a connection pool). Default no-op.""" + return None + + async def aclose(self) -> None: + """Release provider resources (HTTP pools etc). Default no-op.""" + return None diff --git a/dragontts/app/providers/cartesia.py b/dragontts/app/providers/cartesia.py new file mode 100644 index 000000000..18cc5d666 --- /dev/null +++ b/dragontts/app/providers/cartesia.py @@ -0,0 +1,231 @@ +"""Cartesia TTS provider adapter. + +Ports clairvoyance's ``_generate_cartesia_audio`` helper into the uniform +``BaseTTSProvider.synth`` contract. On a cache miss DragonTTS delegates raw +synthesis here, then a separate conversion layer maps the native format to the +caller's requested ``output_format`` before caching. +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator + +import httpx + +from app.core.config import PROVIDER_DEFAULTS, settings +from app.core.logging import logger +from app.providers import cartesia_pool +from app.providers.base import AudioResult, BaseTTSProvider, ProviderError + +# Cartesia bytes endpoint + protocol version +# (mirrors clairvoyance / pipecat). +_CARTESIA_URL = "https://api.cartesia.ai/tts/bytes" +_CARTESIA_VERSION = "2024-06-10" + + +class CartesiaProvider(BaseTTSProvider): + """Cartesia TTS backend. + + Returns audio in Cartesia's native raw PCM format (16-bit signed LE, + 16 kHz mono). ``synth`` uses the one-shot ``/tts/bytes`` endpoint; + ``stream_synth`` uses the streaming ``/tts/websocket`` to emit chunks as + they are synthesized (low TTFB on a cache miss). + """ + + name = "cartesia" + + @staticmethod + def _generation_config(params: dict) -> dict: + """Cartesia applies speed/volume/emotion via a top-level generation_config + (matches pipecat's CartesiaTTSService). Only non-None values are sent; + these are part of the cache key upstream.""" + return { + k: params[k] + for k in ("speed", "volume", "emotion") + if params.get(k) is not None + } + + def __init__(self, api_key: str | None = None): + """Initialize the provider. + + Args: + api_key: Cartesia API key. Defaults to ``settings.cartesia_api_key`` + when ``None``. + """ + self.api_key = api_key or settings.cartesia_api_key + self._client = httpx.AsyncClient(timeout=30.0) # pooled, reused across synths + # Warm pool of streaming sockets, created lazily and warmed at startup. + self._pool: cartesia_pool.CartesiaStreamPool | None = None + + def _get_pool(self) -> cartesia_pool.CartesiaStreamPool: + if self._pool is None: + self._pool = cartesia_pool.CartesiaStreamPool( + self.api_key, min_size=settings.cartesia_stream_pool_size + ) + return self._pool + + async def warm(self) -> None: + """Pre-warm the streaming socket pool (called at app startup).""" + if self.api_key and settings.cartesia_stream_pool_size >= 1: + try: + await self._get_pool().start() + except Exception as e: + logger.warning(f"Cartesia stream pool warm failed: {e}") + + async def aclose(self) -> None: + await self._client.aclose() + if self._pool is not None: + await self._pool.aclose() + + async def synth( + self, + *, + text: str, + voice_id: str | None, + model: str | None, + language: str | None, + params: dict, + ) -> AudioResult: + """Synthesize ``text`` and return native-format raw PCM audio. + + Fields missing from the request fall back to + ``PROVIDER_DEFAULTS["cartesia"]``. + + Args: + text: The text to synthesize. + voice_id: Voice ID override, or ``None``/empty for the default. + model: Model override, or ``None``/empty for the default. + language: Language override, or ``None``/empty for the default. + params: Reserved for future provider-specific options (phase 1: + typically empty). + + Returns: + AudioResult holding raw ``pcm_s16le`` audio at 16 kHz. + + Raises: + ValueError: If the Cartesia API key is not configured. + """ + if not self.api_key: + raise ValueError("CARTESIA_API_KEY is required") + + defaults = PROVIDER_DEFAULTS["cartesia"] + final_voice_id = voice_id or defaults.get("voice_id", "") + final_model = model or defaults.get("model", "sonic-3.5") + final_language = language or defaults.get("language", "en") + + headers = { + "X-API-Key": self.api_key, + "Cartesia-Version": _CARTESIA_VERSION, + "Content-Type": "application/json", + } + + # Cartesia applies speed/volume/emotion via a top-level generation_config + # (matches pipecat's CartesiaTTSService, model_dump(exclude_none=True)). + generation_config = self._generation_config(params) + + payload = { + "model_id": final_model, + "transcript": text, + "voice": {"mode": "id", "id": final_voice_id}, + "language": final_language, + "output_format": { + "container": "raw", + "encoding": "pcm_s16le", + "sample_rate": 16000, + }, + } + if generation_config: + payload["generation_config"] = generation_config + + logger.info( + f"Synthesizing audio with Cartesia: {text[:50]}... " + f"[voice_id={final_voice_id}, model={final_model}]" + ) + + response = await self._client.post(_CARTESIA_URL, json=payload, headers=headers) + response.raise_for_status() + return AudioResult( + audio=response.content, + container="raw", + encoding="pcm_s16le", + sample_rate=16000, + ) + + async def stream_synth( + self, + *, + text: str, + voice_id: str | None, + model: str | None, + language: str | None, + params: dict, + ) -> AsyncGenerator[bytes, None]: + """Stream native-format PCM chunks from the warm Cartesia socket pool. + + Yields raw ``pcm_s16le`` @ 16 kHz chunks as Cartesia synthesizes them, + so a cache miss reaches the caller with low TTFB. Sockets are reused + across utterances, so only the first miss (or one after an idle drop) + pays the WSS handshake. The caller accumulates and stores the full clip + on completion. + + Raises: + ProviderError: If the API key is missing, Cartesia reports an error, + the socket fails, or the stream ends without completion. + """ + if not self.api_key: + raise ProviderError("CARTESIA_API_KEY is required") + + defaults = PROVIDER_DEFAULTS["cartesia"] + final_voice_id = voice_id or defaults.get("voice_id", "") + final_model = model or defaults.get("model", "sonic-3.5") + final_language = language or defaults.get("language", "en") + + generation_config = self._generation_config(params) + + # ``continue: false`` => the transcript is complete; Cartesia synthesizes + # the whole utterance and signals the end with a ``done`` message. The + # pool injects a unique ``context_id`` for routing/multiplexing. + msg = { + "transcript": text, + "continue": False, + "model_id": final_model, + "voice": {"mode": "id", "id": final_voice_id}, + "output_format": { + "container": "raw", + "encoding": "pcm_s16le", + "sample_rate": 16000, + }, + "add_timestamps": False, + "language": final_language, + } + if generation_config: + msg["generation_config"] = generation_config + + logger.info( + f"Streaming audio via Cartesia: {text[:50]}... " + f"[voice_id={final_voice_id}, model={final_model}]" + ) + + if settings.cartesia_stream_pool_size >= 1: + streamed_any = False + try: + async for chunk in self._get_pool().stream(msg): + streamed_any = True + yield chunk + return + except cartesia_pool.SocketUnavailable: + # WS pool unreachable (e.g. blocked handshake, circuit open). + # Only fall back if we haven't streamed partial audio yet. + if streamed_any: + raise + logger.warning( + "Cartesia WS unavailable — serving miss via one-shot HTTP synth" + ) + + # Fallback: one-shot HTTP synth (also used when pooling is disabled or + # the WS pool is unreachable). Yields the full clip; the cache layer + # accumulates and stores it as usual. + result = await self.synth( + text=text, voice_id=voice_id, model=model, language=language, params=params + ) + yield result.audio diff --git a/dragontts/app/providers/cartesia_pool.py b/dragontts/app/providers/cartesia_pool.py new file mode 100644 index 000000000..d169dcf04 --- /dev/null +++ b/dragontts/app/providers/cartesia_pool.py @@ -0,0 +1,319 @@ +"""Warm pool of persistent Cartesia streaming WebSocket connections. + +Opening a fresh WSS connection per cache miss costs a full handshake +(TCP + TLS + upgrade ≈ 2-3 RTT) every sentence. Instead we keep a small set +of sockets open and reuse them: Cartesia multiplexes many utterances over one +connection, tagging each response chunk with the ``context_id`` we send. + +Design: + +- :class:`_CartesiaConnection` owns one socket and a background ``run()`` task + that connects, demultiplexes incoming messages by ``context_id`` into + per-utterance queues, and **reconnects automatically** on drop (Cartesia + closes idle sockets after ~5 min; the worker self-heals back to ready). +- :class:`CartesiaStreamPool` holds N such connections, picks the least-loaded + ready one per utterance, and is warmed at startup so misses find a live + socket immediately. +- The connector is injectable (``connect_fn``) so the routing/reconnect logic + is unit-tested with a fake socket instead of the network. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import time +import uuid +from collections.abc import AsyncGenerator +from typing import Any, Callable + +from websockets import connect + +from app.core.logging import logger +from app.providers.base import ProviderError + +_CARTESIA_WS_URL = "wss://api.cartesia.ai/tts/websocket" + +# A connector returns an async context manager whose value is a websocket-like +# object (``await ws.send(str)``, ``async for msg in ws`` -> str, ``await ws.close()``). +ConnectFn = Callable[[str], Any] + + +class SocketUnavailable(Exception): + """Raised when no warm Cartesia socket is ready within the acquire window. + + Distinct from a per-utterance error so the caller can fall back to the + one-shot HTTP synth path instead of failing the request. + """ + + +class _IPv4Connect: + """Wraps ``websockets.connect`` to force IPv4, loop-independent. + + On networks where api.cartesia.ai advertises AAAA records but the IPv6 SYN + is black-holed, the WS library (esp. under uvloop) tries IPv6 first and + hangs until open_timeout. We resolve an IPv4 address ourselves and hand + websockets an already-connected IPv4 socket (``sock=``); it still does TLS + with the correct SNI (``server_hostname`` from the URI) but skips the loop's + DNS, so this works under both asyncio and uvloop. + """ + + def __init__(self, uri: str): + self._uri = uri + self._cm = None + + def _connect_ipv4_socket(self): + import socket + from urllib.parse import urlparse + + host = urlparse(self._uri).hostname or "api.cartesia.ai" + infos = socket.getaddrinfo(host, 443, socket.AF_INET, socket.SOCK_STREAM) + family, type_, proto, _, sockaddr = infos[0] + sock = socket.socket(family, type_, proto) + sock.settimeout(5.0) + sock.connect(sockaddr) + sock.settimeout(None) + return sock + + async def __aenter__(self): + loop = asyncio.get_running_loop() + # Blocking resolve+connect off the event loop. + sock = await loop.run_in_executor(None, self._connect_ipv4_socket) + cm = connect(self._uri, open_timeout=5.0, sock=sock) + try: + ws = await cm.__aenter__() + except BaseException: + sock.close() # handshake failed before the transport took ownership + raise + self._cm = cm # success: the transport now owns + closes the socket + return ws + + async def __aexit__(self, *exc): + return await self._cm.__aexit__(*exc) + + +def _default_connect(uri: str): + """Production connector: short open_timeout so an unreachable WS trips the + circuit breaker quickly instead of hanging for the default 10s.""" + from app.core.config import settings + + if settings.cartesia_ws_force_ipv4: + return _IPv4Connect(uri) + return connect(uri, open_timeout=5.0) + + +# Per-context queue sentinels (a Queue holds bytes chunks and these markers). +_DONE = object() + + +class _Err: + """Carries a ProviderError so a dead connection fails its waiters.""" + + __slots__ = ("exc",) + + def __init__(self, exc: Exception): + self.exc = exc + + +class _CartesiaConnection: + """One persistent Cartesia socket + its receive/reconnect loop.""" + + def __init__(self, uri: str, connect_fn: ConnectFn): + self._uri = uri + self._connect_fn = connect_fn + self.ws: Any = None + self.ready = asyncio.Event() + self.contexts: dict[str, asyncio.Queue] = {} + self.inflight = 0 + self._send_lock = asyncio.Lock() + self._task: asyncio.Task | None = None + self._closed = False + + async def run(self) -> None: + """Connect, receive, and reconnect on drop until ``stop()``. + + Cartesia tears down idle sockets (~5 min); on any close/error we fail + the in-flight utterances on this socket and reconnect so the pool + self-heals to a ready state. + """ + backoff = 0.5 + while not self._closed: + try: + async with self._connect_fn(self._uri) as ws: + self.ws = ws + self.ready.set() + backoff = 0.5 + logger.info("Cartesia stream socket ready") + async for message in ws: + self._dispatch(message) + # Socket closed cleanly (e.g. idle timeout) — fail waiters, retry. + self._mark_all_dead("cartesia socket closed") + except Exception as e: # reconnect on any connection failure + # CancelledError is BaseException -> propagates, stopping the task. + self._mark_all_dead(f"cartesia socket error: {e}") + finally: + self.ready.clear() + self.ws = None + + if self._closed: + break + logger.debug(f"Cartesia socket dropped; reconnecting in {backoff}s") + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 5.0) + + def _dispatch(self, message: str) -> None: + """Route one Cartesia message to its context's queue.""" + try: + m = json.loads(message) + except (ValueError, TypeError): + return + ctx = m.get("context_id") + q = self.contexts.get(ctx) if ctx else None + if q is None: + return # unknown/already-finished context; ignore + msg_type = m.get("type") + if msg_type == "chunk": + q.put_nowait(base64.b64decode(m["data"])) + elif msg_type == "done": + q.put_nowait(_DONE) + elif msg_type == "error": + q.put_nowait(_Err(ProviderError(f"cartesia stream error: {m}"))) + + def _mark_all_dead(self, reason: str) -> None: + """Fail every utterance currently bound to this socket.""" + err = _Err(ProviderError(reason)) + for q in list(self.contexts.values()): + q.put_nowait(err) + + async def send(self, message: str) -> None: + """Send one message (serialized per socket).""" + async with self._send_lock: + if self.ws is None: + raise ProviderError("cartesia socket not ready") + await self.ws.send(message) + + async def stop(self) -> None: + self._closed = True + if self.ws is not None: + try: + await self.ws.close() + except Exception: + pass + if self._task is not None: + self._task.cancel() + + +class CartesiaStreamPool: + """A warm pool of persistent Cartesia sockets multiplexed by context_id.""" + + def __init__( + self, + api_key: str, + version: str = "2024-06-10", + min_size: int = 2, + connect_fn: ConnectFn | None = None, + acquire_timeout: float = 5.0, + failure_threshold: int = 2, + cooldown: float = 60.0, + ): + if not api_key: + raise ValueError("CartesiaStreamPool requires an api_key") + self._uri = f"{_CARTESIA_WS_URL}?api_key={api_key}&cartesia_version={version}" + self._min_size = min_size + self._connect_fn = connect_fn or _default_connect + self._conns: list[_CartesiaConnection] = [] + self._lock = asyncio.Lock() + self._started = False + # Circuit breaker: if WS can't be reached, fail fast so misses fall + # back to one-shot HTTP synth instead of hanging every request. + self._acquire_timeout = acquire_timeout + self._failure_threshold = failure_threshold + self._cooldown = cooldown + self._acquire_failures = 0 + self._cooldown_until = 0.0 + + async def start(self) -> None: + """Spawn ``min_size`` connection workers (non-blocking; they warm async).""" + if self._started: + return + self._started = True + for _ in range(self._min_size): + await self._add_connection() + logger.info(f"Cartesia stream pool warming {self._min_size} socket(s)") + + async def _add_connection(self) -> None: + conn = _CartesiaConnection(self._uri, self._connect_fn) + conn._task = asyncio.create_task(conn.run()) + self._conns.append(conn) + + async def acquire(self) -> _CartesiaConnection: + """Return the least-loaded ready socket, waiting until one is warm.""" + async with self._lock: + if not self._conns: + await self._add_connection() + # Fast path: a socket is already warm -> WS is up; clear any stale breaker. + ready = [c for c in self._conns if c.ready.is_set()] + if ready: + self._acquire_failures = 0 + return min(ready, key=lambda c: c.inflight) + # Breaker open (WS confirmed unreachable recently) -> fail fast so the + # caller falls back to HTTP instead of waiting out acquire_timeout. + if time.monotonic() < self._cooldown_until: + raise SocketUnavailable("cartesia WS unavailable (circuit open)") + # Wait briefly for a worker to finish warming a socket. + deadline = time.monotonic() + self._acquire_timeout + while time.monotonic() < deadline: + ready = [c for c in self._conns if c.ready.is_set()] + if ready: + self._acquire_failures = 0 + return min(ready, key=lambda c: c.inflight) + await asyncio.sleep(0.05) + # No socket warmed in time -> count a failure, maybe open the breaker. + self._acquire_failures += 1 + if self._acquire_failures >= self._failure_threshold: + self._cooldown_until = time.monotonic() + self._cooldown + logger.warning( + f"Cartesia WS unreachable after {self._acquire_failures} attempt(s) " + f"— streaming misses fall back to one-shot HTTP synth for {self._cooldown:.0f}s" + ) + raise SocketUnavailable("no warm cartesia socket within acquire timeout") + + async def stream(self, msg: dict) -> AsyncGenerator[bytes, None]: + """Send one utterance (``msg`` minus context_id) over a warm socket. + + Yields audio chunks until Cartesia signals ``done``. On early close + (consumer cancellation), sends Cartesia a best-effort cancel so it + stops synthesizing the abandoned utterance. + """ + conn = await self.acquire() + ctx_id = uuid.uuid4().hex + q: asyncio.Queue = asyncio.Queue() + conn.contexts[ctx_id] = q + conn.inflight += 1 + completed = False + try: + await conn.send(json.dumps({**msg, "context_id": ctx_id})) + while True: + item = await q.get() + if item is _DONE: + completed = True + break + if isinstance(item, _Err): + raise item.exc + yield item + finally: + conn.contexts.pop(ctx_id, None) + conn.inflight -= 1 + if not completed: + # Abandoned mid-synthesis (e.g. caller barge-in): tell Cartesia. + try: + await conn.send(json.dumps({"context_id": ctx_id, "cancel": True})) + except Exception: + pass + + async def aclose(self) -> None: + for conn in self._conns: + await conn.stop() + self._conns.clear() + self._started = False diff --git a/dragontts/app/providers/elevenlabs.py b/dragontts/app/providers/elevenlabs.py new file mode 100644 index 000000000..561b9cd2d --- /dev/null +++ b/dragontts/app/providers/elevenlabs.py @@ -0,0 +1,287 @@ +"""ElevenLabs TTS provider adapter. + +Ports clairvoyance's ``_generate_elevenlabs_audio`` helper into the uniform +``BaseTTSProvider`` contract. There is a single ``elevenlabs`` route, wired to +the Indian-residency endpoint (the only ElevenLabs account with API access). + +Native output format: raw PCM 16 kHz (ElevenLabs ``output_format=pcm_16000``), +matching every other provider so a single cache entry serves all formats on read +and the streaming path live-forwards 16 kHz chunks to a 16 kHz caller. A +separate conversion layer (app/audio/format.py) maps this to the caller's +requested ``output_format`` (e.g. μ-law 8 kHz for telephony) before caching. + +Two paths: +- :meth:`synth` — one-shot HTTP ``/v1/text-to-speech/{voice}`` (used by + ``/tts/bytes`` misses). +- :meth:`stream_synth` — the warm multi-context WebSocket pool + (:mod:`app.providers.elevenlabs_pool`), low TTFB on ``/tts/stream`` misses. + Falls back to one-shot HTTP if no warm socket is available. +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator + +import httpx + +from app.core.config import PROVIDER_DEFAULTS, settings +from app.core.logging import logger +from app.providers import elevenlabs_pool +from app.providers.base import AudioResult, BaseTTSProvider, ProviderError + + +class ElevenLabsProvider(BaseTTSProvider): + """ElevenLabs text-to-speech adapter. + + A single instance speaks to one ElevenLabs deployment (the Indian-residency + endpoint); the registry wires the residency credentials to the + ``elevenlabs`` route. + """ + + name = "elevenlabs" + # Native PCM @ 16 kHz so the conversation path streams live and the cache + # entry serves every requested format on read (μ-law/telephony is produced + # by convert-on-serve, as for the other 16 kHz-native providers). + native_encoding = "pcm_s16le" + native_sample_rate = 16000 + + def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None: + """Initialize the provider. + + Args: + api_key: ElevenLabs API key. Defaults to + ``settings.elevenlabs_indian_residency_api_key``. + base_url: ElevenLabs API base URL. Defaults to + ``settings.elevenlabs_indian_residency_base_url`` (the residency + host; the WS pool derives its wss host from this). + """ + self.api_key = ( + api_key + if api_key is not None + else settings.elevenlabs_indian_residency_api_key + ) + self.base_url = ( + base_url + if base_url is not None + else settings.elevenlabs_indian_residency_base_url + ) + self._client = httpx.AsyncClient(timeout=30.0) + # One warm pool per (voice_id, model_id): the WS binds voice (URL path) + # and model_id (connect-time query param) for the socket's lifetime, so + # differing voice/model need separate sockets. Lazily created on first + # streaming miss; warmed eagerly when the model matches a default. + self._pools: dict[tuple[str, str], elevenlabs_pool.ElevenLabsStreamPool] = {} + + def _voice_settings(self, params: dict) -> dict: + # Caller-supplied voice_settings win; otherwise mirror the one-shot defaults. + vs = (params or {}).get("voice_settings") + if isinstance(vs, dict) and vs: + return vs + return {"stability": 0.5, "similarity_boost": 0.75} + + def _get_pool( + self, voice_id: str, model_id: str, enable_ssml_parsing: bool = False + ) -> elevenlabs_pool.ElevenLabsStreamPool | None: + """Return the warm pool for (voice, model, ssml), creating it lazily. + + Returns ``None`` when pooling is disabled (pool size 0) or the key is + missing, so the caller falls back to one-shot synth. NB: ``_pools`` grows + with distinct (voice, model, ssml) triples and is only cleared at + shutdown — acceptable while the voice catalog stays fixed (re-add an LRU + cap if it ever diversifies). SSML is part of the key because it's a + connect-time socket setting: an SSML-on socket parses tags for + ALL its utterances, so on/off can't share one socket. + """ + if not self.api_key or settings.elevenlabs_stream_pool_size < 1: + return None + key = (voice_id, model_id, enable_ssml_parsing) + pool = self._pools.get(key) + if pool is None: + pool = elevenlabs_pool.ElevenLabsStreamPool( + api_key=self.api_key, + voice_id=voice_id, + model_id=model_id, + base_url=self.base_url, + idle_timeout=settings.elevenlabs_stream_idle_timeout, + min_size=settings.elevenlabs_stream_pool_size, + max_size=max( + settings.elevenlabs_stream_pool_size * 2, + settings.elevenlabs_stream_pool_size + 4, + ), + enable_ssml_parsing=enable_ssml_parsing, + ) + self._pools[key] = pool + return pool + + async def warm(self) -> None: + """Pre-warm the pool for the default voice+model (called at startup). + + Other (voice, model) combos warm lazily on their first streaming miss. + """ + if not self.api_key or settings.elevenlabs_stream_pool_size < 1: + return + defaults = PROVIDER_DEFAULTS.get("elevenlabs", {}) + voice = defaults.get("voice_id", "") + model = defaults.get("model", "eleven_flash_v2_5") + if not voice or not model: + return + try: + pool = self._get_pool(voice, model) + if pool is not None: + await pool.start() + except Exception as e: + logger.warning(f"ElevenLabs stream pool warm failed: {e}") + + async def aclose(self) -> None: + await self._client.aclose() + for pool in self._pools.values(): + await pool.aclose() + self._pools.clear() + + async def synth( + self, + *, + text: str, + voice_id: str, + model: str | None, + language: str | None, + params: dict, + ) -> AudioResult: + """Synthesize ``text`` and return raw PCM 16 kHz audio. + + Uses the one-shot ``/v1/text-to-speech/{voice_id}`` HTTP endpoint with + ``output_format=pcm_16000``. + + Args: + text: The text to synthesize. + voice_id: ElevenLabs voice ID. Falls back to + ``PROVIDER_DEFAULTS["elevenlabs"]["voice_id"]`` when empty. + model: ElevenLabs model ID. Falls back to the provider default when + empty. + language: BCP-47 language hint. Falls back to the provider default + when empty. + params: Extra provider-specific options. ``voice_settings`` is + honored if present. + + Returns: + AudioResult wrapping the provider's native ``pcm_16000`` bytes. + + Raises: + ValueError: If ``self.api_key`` is missing. + """ + if not self.api_key: + raise ValueError("ELEVENLABS_INDIAN_RESIDENCY_API_KEY is required") + + defaults = PROVIDER_DEFAULTS["elevenlabs"] + final_voice_id = voice_id if voice_id else defaults["voice_id"] + final_model_id = model if model else defaults["model"] + final_language = language if language else defaults["language"] + + url = f"{self.base_url}/v1/text-to-speech/{final_voice_id}?output_format=pcm_16000" + headers = { + "xi-api-key": self.api_key, + "Content-Type": "application/json", + "Accept": "audio/raw", + } + # NOTE: language_code is intentionally NOT sent — the ElevenLabs + # /v1/text-to-speech/{voice_id} body has no such field (language is + # bound to the model), and clairvoyance's reference doesn't send it. + payload = { + "text": text, + "model_id": final_model_id, + "voice_settings": self._voice_settings(params), + } + if params.get("enable_ssml_parsing"): + # SSML on: ElevenLabs parses etc. into real + # pauses instead of reading the tags aloud. Default off; only sent + # when requested (all models except eleven_v3 support it). + payload["enable_ssml_parsing"] = True + + logger.info( + f"Synthesizing with ElevenLabs (pcm_16000): {text[:50]}... " + f"[voice_id={final_voice_id}, model_id={final_model_id}, " + f"language={final_language}, base_url={self.base_url}]" + ) + + response = await self._client.post(url, json=payload, headers=headers) + response.raise_for_status() + return AudioResult( + audio=response.content, + container="raw", + encoding="pcm_s16le", + sample_rate=16000, + ) + + async def stream_synth( + self, + *, + text: str, + voice_id: str, + model: str | None, + language: str | None, + params: dict, + ) -> AsyncGenerator[bytes, None]: + """Stream native PCM 16 kHz chunks via the warm multi-context WS pool. + + Each utterance is sent on a warm socket (one handshake amortized across + many misses); ``is_final`` ends the stream and the context is closed. If + no warm socket is ready (pool disabled, circuit open, or cold start) and + nothing has been streamed yet, fall back to one-shot HTTP synth so the + miss still completes. + + Raises: + ProviderError: If the API key is missing, or the socket fails after + audio has started streaming (can't safely fall back mid-stream). + """ + if not self.api_key: + raise ProviderError("ELEVENLABS_INDIAN_RESIDENCY_API_KEY is required") + + defaults = PROVIDER_DEFAULTS["elevenlabs"] + final_voice_id = voice_id if voice_id else defaults["voice_id"] + final_model_id = model if model else defaults["model"] + final_language = language if language else defaults["language"] + + msg = {"text": text, "voice_settings": self._voice_settings(params)} + # SSML is a connect-time socket setting (see _get_pool / pool URI), so it + # selects which warm pool to use — not a per-message field. + ssml = bool(params.get("enable_ssml_parsing")) + logger.info( + f"Streaming via ElevenLabs multi-context WS: {text[:50]}... " + f"[voice_id={final_voice_id}, model_id={final_model_id}, ssml={ssml}]" + ) + + pool = self._get_pool(final_voice_id, final_model_id, ssml) + if pool is not None: + streamed_any = False + try: + async for chunk in pool.stream(msg): + streamed_any = True + yield chunk + return + except elevenlabs_pool.SocketUnavailable: + # WS pool unreachable (cold start, blocked handshake, circuit + # open). Only fall back if we haven't streamed partial audio. + if streamed_any: + raise + logger.warning( + "ElevenLabs WS unavailable — serving miss via one-shot HTTP synth" + ) + except Exception as e: + # WS connected but the utterance failed (schema/quota/error frame). + # Only fall back if we haven't streamed partial audio yet. + if streamed_any: + raise + logger.warning( + f"ElevenLabs WS stream failed ({e}) — " + f"serving miss via one-shot HTTP synth" + ) + + # Fallback: one-shot HTTP synth (also used when pooling is disabled). + result = await self.synth( + text=text, + voice_id=final_voice_id, + model=final_model_id, + language=final_language, + params=params, + ) + yield result.audio diff --git a/dragontts/app/providers/elevenlabs_pool.py b/dragontts/app/providers/elevenlabs_pool.py new file mode 100644 index 000000000..b02d3c4d5 --- /dev/null +++ b/dragontts/app/providers/elevenlabs_pool.py @@ -0,0 +1,387 @@ +"""Warm pool of persistent ElevenLabs streaming WebSocket connections. + +ElevenLabs' Multi-Context WebSocket +(``wss:///v1/text-to-speech/{voice_id}/multi-stream-input`` where ```` +is derived from the provider's ``base_url`` — https->wss — so the Indian-residency +instance streams against ``wss://api.in.residency.elevenlabs.io``) is a direct +analog of Cartesia's pool: ONE socket carries up to 5 concurrent +contexts, each tagged with a ``context_id`` we send and echoed back on every +audio frame (base64 ``audio`` + ``isFinal`` end marker). So we keep a small set +of sockets warm and reuse them, mirroring :mod:`app.providers.cartesia_pool`. + +Differences from Cartesia (encoded here): +- The voice is in the URL, so a pool is **per voice** (the provider keeps one + pool per ``voice_id``). +- ``xi-api-key`` header auth (not a query param). +- ``model_id`` and ``output_format`` are **connect-time query params** (they + cannot vary per utterance on a warm socket) — the pool is therefore keyed by + ``(voice_id, model_id)``. We set ``output_format=pcm_16000`` (the native cache + rate) and ``auto_mode=true`` (reduces latency for full phrases). +- Hard **5-context cap per socket** (server-enforced); ``acquire`` skips a socket + already at the cap and will open another up to ``max_size``. +- ``eleven_v3`` is not supported on the multi-context socket — callers must use a + v2 model (e.g. ``eleven_flash_v2_5``); this is the caller's responsibility. +- A complete utterance is sent as ``{text, voice_settings, context_id}`` then a + ``{context_id, flush: true}`` to trigger generation; ``is_final`` ends it and a + ``{context_id, close_context: true}`` frees the server-side context. + +NOTE: this follows ElevenLabs' published multi-context protocol exactly (context_id +routing, base64 ``audio``, ``is_final``). The pool *logic* (warm/reconnect/demux/ +breaker/cap) is unit-tested with a fake socket; the live wire schema needs a +smoke-test with a real key on first deploy. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import time +import uuid +from collections.abc import AsyncGenerator +from typing import Any, Callable +from urllib.parse import quote + +from websockets import connect + +from app.core.logging import logger +from app.providers.base import ProviderError + +# Path appended to the WS host. The host is derived from the provider's +# base_url (https->wss) so the Indian-residency instance streams against +# wss://api.in.residency.elevenlabs.io rather than the global endpoint. +_ELEVENLABS_WS_PATH = "/v1/text-to-speech/{voice_id}/multi-stream-input" + +# A connector returns an async context manager whose value is a websocket-like +# object (``await ws.send(str)``, ``async for msg in ws`` -> str, ``await ws.close()``). +ConnectFn = Callable[[str, dict], Any] + +# Per-context queue sentinels. +_DONE = object() + + +class _Err: + """Carries a ProviderError so a dead connection fails its waiters.""" + + __slots__ = ("exc",) + + def __init__(self, exc: Exception): + self.exc = exc + + +class SocketUnavailable(Exception): + """Raised when no warm ElevenLabs socket is ready within the acquire window. + + Distinct from a per-utterance error so the caller can fall back to the + one-shot HTTP synth path instead of failing the request. + """ + + +def _default_connect(uri: str, headers: dict): + """Production connector: short open_timeout so an unreachable WS trips the + circuit breaker quickly instead of hanging.""" + return connect(uri, additional_headers=headers, open_timeout=5.0) + + +# Max concurrent contexts ElevenLabs allows on one multi-context socket. +_MAX_CONTEXTS_PER_SOCKET = 5 + + +class _ElevenLabsConnection: + """One persistent ElevenLabs socket + its receive/reconnect loop.""" + + def __init__(self, uri: str, headers: dict, connect_fn: ConnectFn): + self._uri = uri + self._headers = headers + self._connect_fn = connect_fn + self.ws: Any = None + self.ready = asyncio.Event() + self.contexts: dict[str, asyncio.Queue] = {} + self.inflight = 0 + self._send_lock = asyncio.Lock() + self._task: asyncio.Task | None = None + self._closed = False + + async def run(self) -> None: + """Connect, receive, and reconnect on drop until ``stop()``.""" + backoff = 0.5 + while not self._closed: + try: + async with self._connect_fn(self._uri, self._headers) as ws: + self.ws = ws + self.ready.set() + backoff = 0.5 + logger.info("ElevenLabs stream socket ready") + async for message in ws: + self._dispatch(message) + self._mark_all_dead("elevenlabs socket closed") + except Exception as e: # reconnect on any connection failure + # CancelledError is BaseException -> propagates, stopping the task. + self._mark_all_dead(f"elevenlabs socket error: {e}") + finally: + self.ready.clear() + self.ws = None + + if self._closed: + break + logger.debug(f"ElevenLabs socket dropped; reconnecting in {backoff}s") + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 5.0) + + def _dispatch(self, message: str) -> None: + """Route one ElevenLabs message to its context's queue.""" + try: + m = json.loads(message) + except (ValueError, TypeError): + return + ctx = m.get("context_id") or m.get("contextId") + q = self.contexts.get(ctx) if ctx else None + if q is None: + return # unknown/already-finished context; ignore + audio_b64 = m.get("audio") + if audio_b64: + try: + q.put_nowait(base64.b64decode(audio_b64)) + except Exception: + pass + if m.get("isFinal") or m.get("is_final"): + q.put_nowait(_DONE) + + def _mark_all_dead(self, reason: str) -> None: + err = _Err(ProviderError(reason)) + for q in list(self.contexts.values()): + q.put_nowait(err) + + async def send(self, message: str) -> None: + """Send one message (serialized per socket).""" + async with self._send_lock: + if self.ws is None: + raise ProviderError("elevenlabs socket not ready") + await self.ws.send(message) + + async def stop(self) -> None: + self._closed = True + if self.ws is not None: + try: + await self.ws.close() + except Exception: + pass + if self._task is not None: + self._task.cancel() + + +class ElevenLabsStreamPool: + """A warm pool of persistent ElevenLabs sockets for ONE voice, multiplexed + by ``context_id``. The provider keeps one pool per ``voice_id``.""" + + def __init__( + self, + api_key: str, + voice_id: str, + model_id: str, + base_url: str = "https://api.elevenlabs.io", + *, + output_format: str = "pcm_16000", + connect_fn: ConnectFn | None = None, + min_size: int = 2, + max_size: int = 8, + acquire_timeout: float = 5.0, + failure_threshold: int = 2, + cooldown: float = 60.0, + # Seconds of server silence AFTER audio starts that we treat as + # end-of-utterance. ElevenLabs parks the context ~20s (or + # inactivity_timeout) past the last chunk waiting for more text and only + # then emits is_final, so waiting on is_final stalls ~20-120s. A short + # idle gap is the real end-of-speech signal (mirrors pipecat's + # stop_frame_timeout). + idle_timeout: float = 0.8, + # SSML parsing is a CONNECT-time setting (per pipecat/ElevenLabs): a + # socket parses tags for ALL utterances multiplexed over it, so + # SSML-on and SSML-off need separate warm sockets (the provider keys the + # pool by this flag). Appended to the connect URI as a query param. + enable_ssml_parsing: bool = False, + ): + if not api_key: + raise ValueError("ElevenLabsStreamPool requires an api_key") + if not voice_id: + raise ValueError("ElevenLabsStreamPool requires a voice_id") + if not model_id: + raise ValueError("ElevenLabsStreamPool requires a model_id") + self._api_key = api_key + self._voice_id = voice_id + self._model_id = model_id + # output_format + model_id are connect-time query params (the socket's + # format/model is fixed for its lifetime). auto_mode=true lowers latency + # for complete phrases; inactivity_timeout bumped so warm sockets survive + # idle gaps between bursts of misses. + # WS host mirrors the HTTP base_url host (https->wss), so a residency + # instance streams against the India endpoint, not the global one. + ws_host = ( + base_url.replace("https://", "wss://") + .replace("http://", "ws://") + .rstrip("/") + ) + self._uri = ( + f"{ws_host}{_ELEVENLABS_WS_PATH.format(voice_id=voice_id)}" + f"?model_id={quote(model_id, safe='')}" + f"&output_format={quote(output_format, safe='')}" + f"&auto_mode=true&inactivity_timeout=120" + ) + if enable_ssml_parsing: + # Connection-level: the socket parses SSML tags in every utterance + # sent over it (matches pipecat's connect-URI query param). + self._uri += "&enable_ssml_parsing=true" + self._headers = {"xi-api-key": api_key} + self._connect_fn = connect_fn or _default_connect + self._min_size = min_size + self._max_size = max_size + self._conns: list[_ElevenLabsConnection] = [] + self._lock = asyncio.Lock() + self._started = False + self._acquire_timeout = acquire_timeout + self._failure_threshold = failure_threshold + self._cooldown = cooldown + self._idle_timeout = idle_timeout + self._acquire_failures = 0 + self._cooldown_until = 0.0 + + @property + def voice_id(self) -> str: + return self._voice_id + + async def start(self) -> None: + if self._started: + return + self._started = True + for _ in range(self._min_size): + await self._add_connection() + logger.info( + f"ElevenLabs stream pool warming {self._min_size} socket(s) for voice={self._voice_id}" + ) + + async def _add_connection(self) -> None: + conn = _ElevenLabsConnection(self._uri, self._headers, self._connect_fn) + conn._task = asyncio.create_task(conn.run()) + self._conns.append(conn) + + def _available(self) -> list[_ElevenLabsConnection]: + """Ready sockets with a free context slot (under the 5-context cap).""" + return [ + c + for c in self._conns + if c.ready.is_set() and c.inflight < _MAX_CONTEXTS_PER_SOCKET + ] + + async def acquire(self) -> _ElevenLabsConnection: + """Return the least-loaded ready socket with a free context slot, + opening a new one up to ``max_size`` if all are saturated/cold. + + Reserves the context slot (``inflight++``) under the lock so concurrent + acquires can't all pick the same socket and push it past the hard + 5-context cap (the server would reject the overflow context). + """ + async with self._lock: + avail = self._available() + if not avail and len(self._conns) < self._max_size: + await self._add_connection() + avail = self._available() + if avail: + self._acquire_failures = 0 + conn = min(avail, key=lambda c: c.inflight) + conn.inflight += 1 # reserve the slot NOW (under the lock) + return conn + + if time.monotonic() < self._cooldown_until: + raise SocketUnavailable("elevenlabs WS unavailable (circuit open)") + + deadline = time.monotonic() + self._acquire_timeout + while time.monotonic() < deadline: + async with self._lock: + avail = self._available() + if avail: + self._acquire_failures = 0 + conn = min(avail, key=lambda c: c.inflight) + conn.inflight += 1 + return conn + await asyncio.sleep(0.05) + + self._acquire_failures += 1 + if self._acquire_failures >= self._failure_threshold: + self._cooldown_until = time.monotonic() + self._cooldown + logger.warning( + f"ElevenLabs WS unreachable after {self._acquire_failures} attempt(s) " + f"— streaming misses fall back to one-shot HTTP synth for {self._cooldown:.0f}s" + ) + raise SocketUnavailable("no warm elevenlabs socket within acquire timeout") + + async def stream(self, msg: dict) -> AsyncGenerator[bytes, None]: + """Send one complete utterance over a warm socket and yield its audio. + + ``msg`` carries ``text`` + ``voice_settings`` (no context_id). The pool + injects a unique ``context_id`` and runs pipecat's multi-stream-input + sequence: init (a bare space + voice_settings) -> the real text -> + ``flush`` (mirrors pipecat's ``ElevenLabsTTSService``). Audio chunks + (base64 ``audio``) are routed back via a per-context queue. ElevenLabs + does NOT emit ``is_final`` promptly — it parks the context ~20s (or + ``inactivity_timeout``) past the last audio chunk waiting for more text + (confirmed by a raw-frame probe), so we end the stream on a short idle + gap after audio starts; ``is_final`` still ends us early if it arrives. + On completion (or early cancellation) we send ``close_context`` so + ElevenLabs frees the server-side context; the socket stays warm for others. + """ + conn = ( + await self.acquire() + ) # reserves a context slot (inflight++) under the lock + ctx_id = uuid.uuid4().hex + q: asyncio.Queue = asyncio.Queue() + conn.contexts[ctx_id] = q + try: + # pipecat's multi-stream-input sequence: 1) init the context with a + # bare space + voice_settings, 2) send the real text, 3) flush. + init = {"text": " ", "context_id": ctx_id} + if msg.get("voice_settings"): + init["voice_settings"] = msg["voice_settings"] + await conn.send(json.dumps(init)) + await conn.send( + json.dumps({"text": msg.get("text", ""), "context_id": ctx_id}) + ) + await conn.send(json.dumps({"context_id": ctx_id, "flush": True})) + # ElevenLabs does NOT emit is_final promptly — it parks the context + # ~20s (or inactivity_timeout) after the last audio chunk waiting for + # more text (probe + pipecat's own comment confirm this). So we end + # the stream on a short idle gap AFTER audio starts; is_final still + # ends us early if it happens to arrive. The first frame gets a + # longer timeout so a slow cold-start doesn't false-positive. + got_audio = False + while True: + timeout = self._idle_timeout if got_audio else 10.0 + try: + item = await asyncio.wait_for(q.get(), timeout=timeout) + except asyncio.TimeoutError: + if not got_audio: + raise ProviderError( + "elevenlabs stream timed out waiting for first audio chunk" + ) + break # silence after audio => end of utterance + if item is _DONE: + break + if isinstance(item, _Err): + raise item.exc + got_audio = True + yield item + finally: + conn.contexts.pop(ctx_id, None) + conn.inflight -= 1 + # Free the server-side context (best effort); the socket stays warm. + try: + await conn.send( + json.dumps({"context_id": ctx_id, "close_context": True}) + ) + except Exception: + pass + + async def aclose(self) -> None: + for conn in self._conns: + await conn.stop() + self._conns.clear() + self._started = False diff --git a/dragontts/app/providers/gemini.py b/dragontts/app/providers/gemini.py new file mode 100644 index 000000000..02af4e244 --- /dev/null +++ b/dragontts/app/providers/gemini.py @@ -0,0 +1,302 @@ +"""Gemini TTS provider adapter. + +Ports clairvoyance's ``_generate_gemini_audio`` helper into the uniform +``BaseTTSProvider.synth`` contract. On a cache miss DragonTTS delegates raw +synthesis here, then a separate conversion layer maps the native format to the +caller's requested ``output_format`` before caching. + +Gemini TTS outputs 24 kHz PCM natively over its gRPC ``streaming_synthesize`` +API; we downsample to 16 kHz PCM (the native cache/conversation rate) so a +single entry serves every format on read and live-streams to a 16 kHz caller. + +Two paths: +- :meth:`synth` — collect the full 24k clip, downsample once, return (used by + the one-shot ``/tts/bytes`` path). +- :meth:`stream_synth` — feed each 24k gRPC chunk through a stateful + :class:`~app.audio.resample.StreamingResampler` and yield 16k chunks live + (low TTFB on a ``/tts/stream`` miss). Both converge on the same audio. +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncGenerator + +import numpy as np +from google.cloud import texttospeech_v1 +from google.oauth2 import service_account + +from app.audio.resample import StreamingResampler +from app.core.config import PROVIDER_DEFAULTS, settings +from app.core.logging import logger +from app.providers.base import AudioResult, BaseTTSProvider, ProviderError + +# Gemini TTS native sample rate — fixed by the API. +_GEMINI_SAMPLE_RATE = 24_000 + + +class GeminiProvider(BaseTTSProvider): + """Gemini TTS backend. + + Returns audio in Gemini's native raw PCM format (16-bit signed LE), + downsampled from the API's native 24 kHz to 16 kHz mono — mirroring + clairvoyance's ``_generate_gemini_audio``. + """ + + name = "gemini" + + def __init__( + self, + credentials_json: str | None = None, + credentials_path: str | None = None, + ): + """Initialize the provider. + + Args: + credentials_json: GCP service-account JSON string. Defaults to + ``settings.google_credentials_json`` when ``None``. + credentials_path: Path to a GCP service-account JSON file. Defaults + to ``settings.google_credentials_path`` when ``None``. + """ + self.credentials_json = credentials_json or settings.google_credentials_json + self.credentials_path = credentials_path or settings.google_credentials_path + # Cached gRPC client + credentials — built once, reused across synths. + # Building a channel per call is expensive (TLS + gRPC handshake) and a + # real bottleneck under concurrent misses. + self._client: texttospeech_v1.TextToSpeechAsyncClient | None = None + self._creds: service_account.Credentials | None = None + + def _get_client(self) -> texttospeech_v1.TextToSpeechAsyncClient: + """Lazily build + cache the authenticated async gRPC client.""" + if self._client is None: + if not self.credentials_json and not self.credentials_path: + raise ProviderError( + "GOOGLE_CREDENTIALS_JSON/PATH is required for Gemini TTS" + ) + if self.credentials_json: + json_account_info = json.loads(self.credentials_json) + creds = service_account.Credentials.from_service_account_info( + json_account_info + ) + else: + creds = service_account.Credentials.from_service_account_file( + self.credentials_path + ) + self._creds = creds + self._client = texttospeech_v1.TextToSpeechAsyncClient(credentials=creds) + return self._client + + def _request_generator( + self, + *, + text: str, + final_voice_id: str, + final_model: str, + final_language: str, + style_prompt: str | None, + ): + """Build the gRPC streaming request generator (config + synthesis input).""" + voice_params = texttospeech_v1.VoiceSelectionParams( + language_code=final_language, + name=final_voice_id, + model_name=final_model, + ) + streaming_config = texttospeech_v1.StreamingSynthesizeConfig( + voice=voice_params, + streaming_audio_config=texttospeech_v1.StreamingAudioConfig( + audio_encoding=texttospeech_v1.AudioEncoding.PCM, + sample_rate_hertz=_GEMINI_SAMPLE_RATE, + ), + ) + + async def _gen(): + yield texttospeech_v1.StreamingSynthesizeRequest( + streaming_config=streaming_config + ) + synthesis_params: dict = {"text": text} + if style_prompt: + synthesis_params["prompt"] = style_prompt + yield texttospeech_v1.StreamingSynthesizeRequest( + input=texttospeech_v1.StreamingSynthesisInput(**synthesis_params) + ) + + return _gen + + def _resolve( + self, + *, + voice_id: str | None, + model: str | None, + language: str | None, + params: dict, + ) -> tuple[str, str, str, str | None]: + defaults = PROVIDER_DEFAULTS["gemini"] + return ( + voice_id or defaults.get("voice_id", "Kore"), + model or defaults.get("model", ""), + language or defaults.get("language", "en-IN"), + params.get("style_prompt") if params else None, + ) + + async def synth( + self, + *, + text: str, + voice_id: str | None, + model: str | None, + language: str | None, + params: dict, + ) -> AudioResult: + """Synthesize ``text`` and return native-format (16 kHz) raw PCM audio. + + Collects the full 24 kHz gRPC stream then downsamples to 16 kHz — + matching clairvoyance's reference helper. Used by the one-shot + ``/tts/bytes`` path. + + Fields missing from the request fall back to + ``PROVIDER_DEFAULTS["gemini"]``. + + Args: + text: The text to synthesize. + voice_id: Gemini voice name (e.g. "Kore") override, or ``None``/ + empty for the default. + model: Gemini TTS model override, or ``None``/empty for the + default. + language: BCP-47 language code (e.g. "en-IN") override, or + ``None``/empty for the default. + params: Reserved for future provider-specific options. ``style_prompt`` + (a natural-language style instruction) is honored if present. + + Returns: + AudioResult holding raw ``pcm_s16le`` audio at 16 kHz. + + Raises: + ProviderError: If neither ``credentials_json`` nor ``credentials_path`` + is configured, or Gemini returns empty audio. + """ + final_voice_id, final_model, final_language, style_prompt = self._resolve( + voice_id=voice_id, model=model, language=language, params=params + ) + logger.info( + f"Synthesizing audio with Gemini: {text[:50]}... " + f"[voice_id={final_voice_id}, model={final_model}, lang={final_language}]" + ) + + client = self._get_client() + responses = await client.streaming_synthesize( + self._request_generator( + text=text, + final_voice_id=final_voice_id, + final_model=final_model, + final_language=final_language, + style_prompt=style_prompt, + )() + ) + chunks: list[bytes] = [] + try: + async for response in responses: + if response.audio_content: + chunks.append(response.audio_content) + finally: + # Cancel the gRPC stream on early abandon (see stream_synth). + _cancel = getattr(responses, "cancel", None) + if _cancel is not None: + try: + _cancel() + except Exception: + pass + + pcm_24k = b"".join(chunks) + if not pcm_24k: + raise ProviderError( + "Gemini TTS returned empty audio — check credentials and voice settings" + ) + + # Ensure whole-frame alignment before resampling (16-bit = 2 bytes/frame). + if len(pcm_24k) % 2 != 0: + pcm_24k += b"\x00" + + # Downsample 24 kHz -> 16 kHz via linear interpolation (no scipy needed). + # 24000 * (2/3) = 16000. + samples = np.frombuffer(pcm_24k, dtype=np.int16).astype(np.float32) + out_len = len(samples) * 16_000 // _GEMINI_SAMPLE_RATE + indices = np.linspace(0, len(samples) - 1, out_len) + lo = np.floor(indices).astype(np.int32) + hi = np.minimum(lo + 1, len(samples) - 1) + frac = (indices - lo).astype(np.float32) + resampled = samples[lo] + frac * (samples[hi] - samples[lo]) + pcm_16k = np.clip(resampled, -32768, 32767).astype(np.int16).tobytes() + + return AudioResult( + audio=pcm_16k, + container="raw", + encoding="pcm_s16le", + sample_rate=16000, + ) + + async def stream_synth( + self, + *, + text: str, + voice_id: str | None, + model: str | None, + language: str | None, + params: dict, + ) -> AsyncGenerator[bytes, None]: + """Stream native 16 kHz PCM chunks as Gemini synthesizes them. + + Gemini emits 24 kHz PCM over gRPC; each chunk is fed through a stateful + :class:`StreamingResampler` (24k -> 16k) so the caller receives live 16 + kHz chunks on a cache miss (low TTFB). The accumulated output matches + :meth:`synth` to within a sub-sample at the tail. + """ + final_voice_id, final_model, final_language, style_prompt = self._resolve( + voice_id=voice_id, model=model, language=language, params=params + ) + logger.info( + f"Streaming audio via Gemini: {text[:50]}... " + f"[voice_id={final_voice_id}, model={final_model}, lang={final_language}]" + ) + + client = self._get_client() + responses = await client.streaming_synthesize( + self._request_generator( + text=text, + final_voice_id=final_voice_id, + final_model=final_model, + final_language=final_language, + style_prompt=style_prompt, + )() + ) + resampler = StreamingResampler(_GEMINI_SAMPLE_RATE, 16000) + try: + async for response in responses: + if response.audio_content: + chunk = resampler.push(response.audio_content) + if chunk: + yield chunk + tail = resampler.flush() + if tail: + yield tail + except Exception as e: + raise ProviderError(f"gemini stream error: {e}") from e + finally: + # Cancel the server-side gRPC stream if the consumer abandoned early + # (a /tts/stream disconnect throws GeneratorExit, which the except + # above does NOT catch). Without this the open stream leaks on the + # process-cached channel and exhausts HTTP/2 concurrency. + _cancel = getattr(responses, "cancel", None) + if _cancel is not None: + try: + _cancel() + except Exception: + pass + + async def aclose(self) -> None: + """Release the cached gRPC channel (built lazily on first use).""" + if self._client is not None: + try: + await self._client.transport.grpc_channel.close() # type: ignore[union-attr] + except Exception as e: + logger.warning(f"Gemini gRPC channel close failed: {e}") + self._client = None diff --git a/dragontts/app/providers/registry.py b/dragontts/app/providers/registry.py new file mode 100644 index 000000000..1111684fb --- /dev/null +++ b/dragontts/app/providers/registry.py @@ -0,0 +1,85 @@ +"""Provider registry — builds adapters for every provider with a configured key. + +Providers are imported lazily inside ``build()`` so a missing optional key +(e.g. no Gemini credentials) never breaks startup. ElevenLabs runs only against +the Indian-residency endpoint (the only account with API access); the single +``elevenlabs`` route resolves to it. +""" + +from __future__ import annotations + +import asyncio + +from app.core.config import settings +from app.core.logging import logger +from app.providers.base import BaseTTSProvider + + +class ProviderNotConfigured(Exception): + """Raised when a request routes to a provider whose API key is absent.""" + + def __init__(self, provider: str): + self.provider = provider + super().__init__( + f"Provider '{provider}' is not configured. Set its API key in the " + f"environment and DragonTTS will serve it after restart." + ) + + +class ProviderRegistry: + def __init__(self): + self._providers: dict[str, BaseTTSProvider] = {} + + def build(self) -> None: + if settings.cartesia_api_key: + from app.providers.cartesia import CartesiaProvider + + self._providers["cartesia"] = CartesiaProvider() + + if settings.sarvam_api_key: + from app.providers.sarvam import SarvamProvider + + self._providers["sarvam"] = SarvamProvider() + + if settings.elevenlabs_indian_residency_api_key: + from app.providers.elevenlabs import ElevenLabsProvider + + # Single ElevenLabs route -> Indian-residency creds (constructor + # defaults). No global route: only the residency account has access. + self._providers["elevenlabs"] = ElevenLabsProvider() + + if settings.google_credentials_json or settings.google_credentials_path: + from app.providers.gemini import GeminiProvider + + self._providers["gemini"] = GeminiProvider( + credentials_json=settings.google_credentials_json or None, + credentials_path=settings.google_credentials_path or None, + ) + + logger.info(f"Configured providers: {self.configured()}") + + def get(self, name: str) -> BaseTTSProvider | None: + return self._providers.get(name) + + def configured(self) -> list[str]: + return list(self._providers) + + async def warm(self) -> None: + """Pre-warm provider resources (e.g. the Cartesia streaming socket pool). + + Warmed concurrently (the providers are independent) so startup latency is + the max, not the sum. Failures are logged, never fatal — a provider still + works via lazy warm-up if its eager warm-up failed. + """ + + async def _warm(provider: BaseTTSProvider) -> None: + try: + await provider.warm() + except Exception as e: + logger.warning(f"Provider '{provider.name}' warm-up failed: {e}") + + await asyncio.gather(*(_warm(p) for p in self._providers.values())) + + async def aclose_all(self) -> None: + for provider in self._providers.values(): + await provider.aclose() diff --git a/dragontts/app/providers/sarvam.py b/dragontts/app/providers/sarvam.py new file mode 100644 index 000000000..f8e34bf92 --- /dev/null +++ b/dragontts/app/providers/sarvam.py @@ -0,0 +1,250 @@ +"""Sarvam TTS provider adapter. + +Ports clairvoyance's ``_generate_sarvam_audio`` helper into the uniform +:class:`BaseTTSProvider` contract. Native output is raw 16-bit PCM at 16 kHz: + +- :meth:`synth` — one-shot HTTP ``/text-to-speech`` requesting + ``speech_sample_rate=16000`` (16 kHz PCM directly, base64 in the JSON body). +- :meth:`stream_synth` — the warm Sarvam WS pool + (:mod:`app.providers.sarvam_pool`). Sarvam streams at the model's native rate + (bulbul:v2 = 22050 Hz, bulbul:v3 = 24000 Hz), so each chunk is fed through a + stateful :class:`~app.audio.resample.StreamingResampler` to emit 16 kHz chunks + (low TTFB on a ``/tts/stream`` miss). Falls back to one-shot HTTP if no warm + socket is available. Both paths converge on 16 kHz PCM, so a single cache + entry serves every requested format on read. +""" + +from __future__ import annotations + +import base64 +from collections.abc import AsyncGenerator + +import httpx + +from app.audio.resample import StreamingResampler +from app.core.config import PROVIDER_DEFAULTS, settings +from app.core.logging import logger +from app.providers import sarvam_pool +from app.providers.base import AudioResult, BaseTTSProvider, ProviderError + +# Sarvam always returns 16-bit PCM; the one-shot HTTP path requests 16 kHz. +_SARVAM_SAMPLE_RATE = 16000 + + +def _is_v3(model: str) -> bool: + """bulbul:v3 rejects pitch/loudness (only pace + temperature); v2 accepts them.""" + return model.strip().lower().endswith("v3") + + +class SarvamProvider(BaseTTSProvider): + """Adapter for the Sarvam text-to-speech HTTP + WebSocket API.""" + + name = "sarvam" + + def __init__(self, api_key: str | None = None): + self.api_key = api_key or settings.sarvam_api_key + self._client = httpx.AsyncClient(timeout=30.0) + # One warm WS pool per model: the model is a connect-time query param, so + # differing models need separate sockets. Lazily created on first miss. + self._pools: dict[str, sarvam_pool.SarvamStreamPool] = {} + + def _resolve( + self, *, voice_id: str, model: str | None, language: str | None, params: dict + ): + defaults = PROVIDER_DEFAULTS["sarvam"] + voice = voice_id or defaults.get("voice_id", "shreya") + mdl = model or defaults.get("model", "bulbul:v3") + language_code = language or defaults.get("language", "en-IN") + params = params or {} + pitch = params.get("pitch") + if pitch is None: + pitch = defaults.get("pitch", 0.0) + pace = params.get("pace") + if pace is None: + pace = params.get("speed") + if pace is None: + pace = defaults.get("speed", 0.9) + return voice, mdl, language_code, pitch, pace + + def _get_pool(self, model: str) -> sarvam_pool.SarvamStreamPool | None: + if not self.api_key or settings.sarvam_stream_pool_size < 1: + return None + pool = self._pools.get(model) + if pool is None: + pool = sarvam_pool.SarvamStreamPool( + api_key=self.api_key, + model=model, + min_size=settings.sarvam_stream_pool_size, + max_size=max( + settings.sarvam_stream_pool_size * 2, + settings.sarvam_stream_pool_size + 4, + ), + ) + self._pools[model] = pool + return pool + + async def warm(self) -> None: + """Pre-warm the pool for the default model (called at startup).""" + if not self.api_key or settings.sarvam_stream_pool_size < 1: + return + model = PROVIDER_DEFAULTS.get("sarvam", {}).get("model", "bulbul:v3") + try: + pool = self._get_pool(model) + if pool is not None: + await pool.start() + except Exception as e: + logger.warning(f"Sarvam stream pool warm failed: {e}") + + async def aclose(self) -> None: + await self._client.aclose() + for pool in self._pools.values(): + await pool.aclose() + self._pools.clear() + + async def synth( + self, + *, + text: str, + voice_id: str, + model: str | None, + language: str | None, + params: dict, + ) -> AudioResult: + """Synthesize ``text`` and return raw 16-bit PCM at 16 kHz. + + Missing fields fall back to ``PROVIDER_DEFAULTS["sarvam"]``. The request + mirrors clairvoyance's ``_generate_sarvam_audio``: same endpoint, + ``api-subscription-key`` header, and JSON payload. + """ + if not self.api_key: + raise ValueError("SARVAM_API_KEY is required") + + voice, mdl, language_code, pitch, pace = self._resolve( + voice_id=voice_id, model=model, language=language, params=params + ) + + url = "https://api.sarvam.ai/text-to-speech" + headers = { + "api-subscription-key": self.api_key, + "Content-Type": "application/json", + } + payload = { + "inputs": [text], + "target_language_code": language_code, + "speaker": voice, + "pace": pace, + "speech_sample_rate": _SARVAM_SAMPLE_RATE, + "enable_preprocessing": True, + "model": mdl, + } + # bulbul:v3 rejects pitch/loudness ("do not pass these values"); v2 + # accepts them. Preprocessing is always on for v3 regardless. + if not _is_v3(mdl): + payload["pitch"] = pitch + payload["loudness"] = 1.5 + + logger.info(f"Synthesizing with Sarvam: {text[:50]}... [model={mdl}]") + + response = await self._client.post(url, json=payload, headers=headers) + response.raise_for_status() + result = response.json() + + audios = result.get("audios") or [] + audio_base64 = audios[0] if audios else None + if not audio_base64: + raise ProviderError("No audio returned from Sarvam API") + + audio = base64.b64decode(audio_base64) + return AudioResult( + audio=audio, + container="raw", + encoding="pcm_s16le", + sample_rate=_SARVAM_SAMPLE_RATE, + ) + + async def stream_synth( + self, + *, + text: str, + voice_id: str, + model: str | None, + language: str | None, + params: dict, + ) -> AsyncGenerator[bytes, None]: + """Stream native 16 kHz PCM chunks via the warm Sarvam WS pool. + + Each upstream chunk (at the model's native rate) is resampled to 16 kHz + and yielded, so a ``/tts/stream`` miss reaches the caller with low TTFB. + Falls back to one-shot HTTP synth if no warm socket is ready (and nothing + has streamed yet). + + Raises: + ProviderError: If the API key is missing, or the socket fails after + audio has started streaming. + """ + if not self.api_key: + raise ProviderError("SARVAM_API_KEY is required") + + voice, mdl, language_code, pitch, pace = self._resolve( + voice_id=voice_id, model=model, language=language, params=params + ) + # WS config (matches pipecat's SarvamTTSService): output_audio_codec is + # "linear16", speech_sample_rate is a STRING, and the model/bitrates are + # explicit. bulbul:v3 rejects pitch/loudness; v2 accepts them. + config = { + "target_language_code": language_code, + "speaker": voice, + "speech_sample_rate": str(sarvam_pool.model_sample_rate(mdl)), + "enable_preprocessing": True, + "min_buffer_size": 50, + "max_chunk_length": 150, + "output_audio_codec": "linear16", + "output_audio_bitrate": "128k", + "pace": pace, + "model": mdl, + } + if not _is_v3(mdl): + config["pitch"] = pitch + config["loudness"] = 1.5 + + logger.info(f"Streaming via Sarvam WS: {text[:50]}... [model={mdl}]") + + pool = self._get_pool(mdl) + if pool is not None: + resampler = StreamingResampler(sarvam_pool.model_sample_rate(mdl), 16000) + streamed_any = False + try: + async for chunk in pool.stream(config, text): + streamed_any = True + out = resampler.push(chunk) + if out: + yield out + tail = resampler.flush() + if tail: + yield tail + return + except sarvam_pool.SocketUnavailable: + if streamed_any: + raise + logger.warning( + "Sarvam WS unavailable — serving miss via one-shot HTTP synth" + ) + except Exception as e: + # WS connected but the utterance failed (config/schema/auth/quota). + # Only fall back if we haven't streamed partial audio yet. + if streamed_any: + raise + logger.warning( + f"Sarvam WS stream failed ({e}) — " + f"serving miss via one-shot HTTP synth" + ) + + # Fallback: one-shot HTTP synth (also used when pooling is disabled). + result = await self.synth( + text=text, + voice_id=voice_id, + model=mdl, + language=language_code, + params=params, + ) + yield result.audio diff --git a/dragontts/app/providers/sarvam_pool.py b/dragontts/app/providers/sarvam_pool.py new file mode 100644 index 000000000..23df5ca01 --- /dev/null +++ b/dragontts/app/providers/sarvam_pool.py @@ -0,0 +1,334 @@ +"""Warm pool of persistent Sarvam streaming WebSocket connections. + +Sarvam's streaming TTS WebSocket (``wss://api.sarvam.ai/text-to-speech/ws``) is +a **single-stream** protocol: one socket serves one utterance at a time (config +-> text -> flush -> audio chunks -> ``final`` event). Unlike Cartesia / +ElevenLabs there is no per-utterance routing/multiplexing, so we keep a small +LIFO stack of warm, pre-connected sockets and hand one out exclusively per miss, +returning it to the pool when the utterance finishes. + +Why: a fresh WSS handshake (TCP + TLS + upgrade) per miss is the dominant miss +latency for a streaming provider. Reusing a warm socket removes it. Sockets are +kept alive with periodic application-level ``ping`` messages (Sarvam closes idle +connections after ~1 minute) and reconnect on drop. + +Input frames (client): ``{"type":"config","data":{...}}``, +``{"type":"text","data":{"text":...}}``, ``{"type": "flush"}``, +``{"type": "ping"}``. Output frames (server): +``{"type":"audio","data":{"audio":""}}``, +``{"type":"event","data":{"event_type":"final"}}``, +``{"type":"error",...}``. + +NOTE: this follows Sarvam's published streaming protocol. The pool *logic* +(exclusive acquire/release, warm/reconnect/keepalive/breaker) is unit-tested +with a fake socket; the live wire schema + ``api-subscription-key`` header auth +need a smoke-test with a real key on first deploy. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import time +from collections.abc import AsyncGenerator +from typing import Any, Callable + +from websockets import connect + +from app.core.logging import logger +from app.providers.base import ProviderError + +_SARVAM_WS_URL = "wss://api.sarvam.ai/text-to-speech/ws" + +ConnectFn = Callable[[str, dict], Any] + + +class SocketUnavailable(Exception): + """Raised when no warm Sarvam socket is ready within the acquire window. + + Distinct from a per-utterance error so the caller falls back to one-shot + HTTP synth instead of failing the request. + """ + + +def _default_connect(uri: str, headers: dict): + """Production connector: short open_timeout so an unreachable WS trips the + circuit breaker quickly instead of hanging.""" + return connect(uri, additional_headers=headers, open_timeout=5.0) + + +class _SarvamConnection: + """One persistent Sarvam socket + its connect/reconnect/keepalive loop. + + A single socket serves one utterance at a time (the pool guarantees + exclusive access via :meth:`acquire`/:meth:`release`). Audio for an + utterance is read directly off the socket in :meth:`stream`; the background + ``run`` task never reads, so there is exactly one consumer. + """ + + def __init__( + self, + uri: str, + headers: dict, + connect_fn: ConnectFn, + ping_interval: float = 25.0, + recv_timeout: float = 30.0, + ): + self._uri = uri + self._headers = headers + self._connect_fn = connect_fn + self._ping_interval = ping_interval + self._recv_timeout = recv_timeout + self.ws: Any = None + self.ready = asyncio.Event() + self._task: asyncio.Task | None = None + self._send_lock = asyncio.Lock() + self._closed = False + self._busy = False # in an utterance (pool-owned); gates idle pings + self._dirty = False # prior utterance aborted non-cleanly -> reconnect + + async def run(self) -> None: + """Connect, stay alive (idle pings), and reconnect on drop until stop().""" + backoff = 0.5 + while not self._closed: + try: + async with self._connect_fn(self._uri, self._headers) as ws: + self.ws = ws + self.ready.set() + self._dirty = False # fresh socket carries no stale frames + backoff = 0.5 + logger.info("Sarvam stream socket ready") + last_ping = time.monotonic() + # Park until the socket closes. While idle (not in an + # utterance), send keepalive pings so Sarvam doesn't drop us. + while not self._closed: + if self._busy: + await asyncio.sleep(0.1) + continue + if self._dirty: + # The last utterance on this socket ended without a + # clean ``final`` (client cancel / timeout / error + # frame). Sarvam is single-stream with no per- + # utterance routing, so its leftover frames would be + # read as the NEXT caller's audio. Drop this socket + # and reconnect to a fresh one (ready was already + # cleared by stream()'s finally, so the pool can't + # hand it out in the meantime). + self._dirty = False + break + now = time.monotonic() + if now - last_ping >= self._ping_interval: + last_ping = now + try: + await self._send({"type": "ping"}) + except Exception: + break # socket is dead -> reconnect below + await asyncio.sleep(1.0) + except Exception as e: # reconnect on any connection failure + # CancelledError is BaseException -> propagates, stopping the task. + logger.debug(f"sarvam socket error: {e}") + finally: + self.ready.clear() + self.ws = None + + if self._closed: + break + logger.debug(f"Sarvam socket dropped; reconnecting in {backoff}s") + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 5.0) + + async def _send(self, msg: dict) -> None: + async with self._send_lock: + if self.ws is None: + raise ProviderError("sarvam socket not ready") + await self.ws.send(json.dumps(msg)) + + async def stream(self, config: dict, text: str) -> AsyncGenerator[bytes, None]: + """Run ONE utterance on this socket: config -> text -> flush -> audio. + + Yields raw PCM bytes (at the model's native rate; the provider resamples + to 16 kHz) until the ``final`` event. Raises ``ProviderError`` on an + error frame or a read timeout (a hung server). + + On any exit other than a clean ``final`` (client cancel, timeout, error + frame) the socket is marked dirty and ``ready`` cleared: ``run`` then + drops + reconnects it, so Sarvam's leftover frames for THIS utterance + can never be read as a later caller's audio (single-stream, no per- + utterance routing — that would silently cross-contaminate cached clips). + """ + completed = False + await self._send({"type": "config", "data": config}) + await self._send({"type": "text", "data": {"text": text}}) + await self._send({"type": "flush"}) + try: + while True: + try: + raw = await asyncio.wait_for( + self.ws.recv(), timeout=self._recv_timeout + ) + except asyncio.TimeoutError as e: + raise ProviderError( + "sarvam stream timed out waiting for audio" + ) from e + try: + m = json.loads(raw) + except (ValueError, TypeError): + continue # ignore non-JSON / control frames + mtype = m.get("type") + if mtype == "audio": + audio_b64 = (m.get("data") or {}).get("audio") + if audio_b64: + try: + yield base64.b64decode(audio_b64) + except Exception: + pass + elif mtype == "event": + if (m.get("data") or {}).get("event_type") == "final": + completed = True + return + elif mtype == "error": + raise ProviderError(f"sarvam stream error: {m}") + finally: + if not completed: + # Aborted mid-utterance: Sarvam keeps streaming this transcript's + # audio into the socket buffer. Clearing ``ready`` keeps the pool + # from handing the socket out, and ``_dirty`` makes ``run`` drop + # + reconnect it so the next caller gets a clean socket. + self._dirty = True + self.ready.clear() + + async def stop(self) -> None: + self._closed = True + if self.ws is not None: + try: + await self.ws.close() + except Exception: + pass + if self._task is not None: + self._task.cancel() + + +# bulbul model -> native PCM sample rate it streams at (provider uses this to +# size the resampler; not 16 kHz, so every stream chunk is resampled on the way). +_MODEL_SAMPLE_RATE = {"bulbul:v2": 22050, "bulbul:v3": 24000} + + +def model_sample_rate(model: str) -> int: + """Native PCM rate a bulbul model streams at (v2=22050, v3=24000).""" + return _MODEL_SAMPLE_RATE.get(model, 24000) + + +class SarvamStreamPool: + """A warm LIFO pool of persistent Sarvam sockets (one utterance each).""" + + def __init__( + self, + api_key: str, + model: str = "bulbul:v2", + connect_fn: ConnectFn | None = None, + min_size: int = 2, + max_size: int = 8, + acquire_timeout: float = 5.0, + failure_threshold: int = 2, + cooldown: float = 60.0, + ): + if not api_key: + raise ValueError("SarvamStreamPool requires an api_key") + self._model = model + self._uri = f"{_SARVAM_WS_URL}?model={model}&send_completion_event=true" + self._headers = {"api-subscription-key": api_key} + self._connect_fn = connect_fn or _default_connect + self._min_size = min_size + self._max_size = max_size + self._conns: list[_SarvamConnection] = [] + self._lock = asyncio.Lock() + self._started = False + self._acquire_timeout = acquire_timeout + self._failure_threshold = failure_threshold + self._cooldown = cooldown + self._acquire_failures = 0 + self._cooldown_until = 0.0 + + async def start(self) -> None: + if self._started: + return + self._started = True + for _ in range(self._min_size): + await self._add_connection() + logger.info( + f"Sarvam stream pool warming {self._min_size} socket(s) for model={self._model}" + ) + + async def _add_connection(self) -> _SarvamConnection: + conn = _SarvamConnection(self._uri, self._headers, self._connect_fn) + conn._task = asyncio.create_task(conn.run()) + self._conns.append(conn) + return conn + + def _free_ready(self) -> list[_SarvamConnection]: + """Ready sockets not currently serving an utterance (LIFO: newest first).""" + return [c for c in reversed(self._conns) if c.ready.is_set() and not c._busy] + + async def acquire(self) -> _SarvamConnection: + """Return an exclusive ready socket, growing the pool up to ``max_size``. + + Raises :class:`SocketUnavailable` (after the acquire window / breaker) + so the caller falls back to one-shot HTTP synth. + + The scan + ``_busy`` reservation is under ``self._lock`` so two callers + can never both take the same single-stream Sarvam socket — that would + interleave their config/text/flush frames on one ``ws.recv()`` and + corrupt the cached audio. + """ + # Fast path: reserve a ready socket under the lock (scan + mark atomic). + async with self._lock: + free = self._free_ready() + if free: + self._acquire_failures = 0 + free[0]._busy = True + return free[0] + if len(self._conns) < self._max_size: + await self._add_connection() + + if time.monotonic() < self._cooldown_until: + raise SocketUnavailable("sarvam WS unavailable (circuit open)") + + deadline = time.monotonic() + self._acquire_timeout + while time.monotonic() < deadline: + async with self._lock: + free = self._free_ready() + if free: + self._acquire_failures = 0 + free[0]._busy = True + return free[0] + await asyncio.sleep(0.05) + + self._acquire_failures += 1 + if self._acquire_failures >= self._failure_threshold: + self._cooldown_until = time.monotonic() + self._cooldown + logger.warning( + f"Sarvam WS unreachable after {self._acquire_failures} attempt(s) " + f"— streaming misses fall back to one-shot HTTP synth for {self._cooldown:.0f}s" + ) + raise SocketUnavailable("no warm sarvam socket within acquire timeout") + + async def stream(self, config: dict, text: str) -> AsyncGenerator[bytes, None]: + """Acquire an exclusive warm socket, run one utterance, release it. + + On any error the socket is released (not returned broken — the run loop + will reconnect it) and the exception propagates so the provider can fall + back to one-shot synth if nothing was streamed yet. + """ + conn = await self.acquire() + try: + async for chunk in conn.stream(config, text): + yield chunk + finally: + conn._busy = False + + async def aclose(self) -> None: + for conn in self._conns: + await conn.stop() + self._conns.clear() + self._started = False diff --git a/dragontts/app/schemas/cache.py b/dragontts/app/schemas/cache.py new file mode 100644 index 000000000..444b93c03 --- /dev/null +++ b/dragontts/app/schemas/cache.py @@ -0,0 +1,61 @@ +"""Response models for cache admin ops.""" + +from __future__ import annotations + +from pydantic import BaseModel + + +class CacheEntryInfo(BaseModel): + key: str + text: str | None = None + provider: str + voice_id: str + model: str + language: str + container: str + encoding: str + sample_rate: int + size_bytes: int + hit_count: int + created_at: str + + +class PaginatedCache(BaseModel): + entries: list[CacheEntryInfo] + offset: int + limit: int + has_next: bool + + +class CheckResponse(BaseModel): + """Result of /tts/check. Metadata fields are null when not cached.""" + + cached: bool + key: str + provider: str + voice_id: str + model: str + encoding: str | None = None + sample_rate: int | None = None + size_bytes: int | None = None + hit_count: int | None = None + created_at: str | None = None + + +class CreateResponse(BaseModel): + """Result of /tts/create. status is CREATED|OVERRIDDEN; source is synth|base64.""" + + key: str + status: str + source: str + provider: str + voice_id: str + model: str + encoding: str + sample_rate: int + size_bytes: int + + +class DeleteResponse(BaseModel): + status: str # "deleted" | "not_found" + key: str diff --git a/dragontts/app/schemas/tts.py b/dragontts/app/schemas/tts.py new file mode 100644 index 000000000..ac1b06895 --- /dev/null +++ b/dragontts/app/schemas/tts.py @@ -0,0 +1,34 @@ +"""Request/response models — Cartesia /tts/bytes-shaped for drop-in compatibility. + +``model_id`` carries routing as ``:``. ``params`` and +``audio_base64`` are DragonTTS-only extensions (real Cartesia ignores unknown +fields): ``params`` is reserved for phase-2 tuning, ``audio_base64`` lets +``/tts/create`` store pre-supplied audio without calling a provider. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class CartesiaVoice(BaseModel): + mode: str = "id" + id: str + + +class OutputFormat(BaseModel): + container: str = "raw" + encoding: str = "pcm_s16le" + sample_rate: int = 16000 + + +class TTSRequest(BaseModel): + """Mirrors Cartesia's POST /tts/bytes body. Clients swap only the base URL.""" + + model_id: str + transcript: str + voice: CartesiaVoice + language: str = "en" + output_format: OutputFormat = Field(default_factory=OutputFormat) + params: dict = Field(default_factory=dict) + audio_base64: str | None = None # /tts/create extension: pre-supplied audio diff --git a/dragontts/app/storage/base.py b/dragontts/app/storage/base.py new file mode 100644 index 000000000..0f23e0909 --- /dev/null +++ b/dragontts/app/storage/base.py @@ -0,0 +1,93 @@ +"""Storage abstraction — metadata (DB) and audio blobs (filesystem) as protocols. + +Today: SQLiteMetadataStore + FilesystemBlobStore. +Later: PostgresMetadataStore + GCSBlobStore — drop-in new implementations, no +change to the cache service or API layer. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + + +def escape_like(literal: str) -> str: + """Escape SQLite LIKE wildcards (``%``, ``_``, ``\\``) so a user-supplied + substring matches literally. Pair with ``... LIKE ? ESCAPE '\\'``.""" + return literal.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +@dataclass +class CacheRecord: + """One cached synthesis result. Metadata lives here; audio bytes in the blob store.""" + + key: str + provider: str + voice_id: str + model: str + language: str + params: str # canonical JSON of tuning params ("" when none) + text: str | None # nullable, admin/debug only + container: str + encoding: str + sample_rate: int + size_bytes: int + storage_path: str # relative path within the blob store + hit_count: int = 0 + created_at: str = "" + last_accessed_at: str = "" + ttl_expires_at: str | None = None + + +class MetadataStore(Protocol): + async def get(self, key: str) -> CacheRecord | None: ... + async def get_many(self, keys) -> dict[str, CacheRecord]: ... + async def put(self, record: CacheRecord) -> None: ... + async def touch(self, key: str) -> None: ... # hit_count++, last_accessed_at=now + async def touch_and_record( + self, key: str, metric_deltas: dict, *, provider: str | None = None + ) -> None: ... + async def delete( + self, key: str + ) -> bool: ... # also adjusts provider_totals atomically + async def delete_filtered( + self, provider: str | None = None, voice_id: str | None = None + ) -> list[ + tuple + ]: ... # (provider, size_bytes, storage_path) per row; also adjusts totals atomically + async def adjust_totals( + self, provider: str, delta_entries: int, delta_bytes: int, delta_words: int = 0 + ) -> None: ... + async def record_metrics( + self, *, provider: str | None = None, **deltas: int + ) -> None: ... + async def record_latency_batch(self, samples: list[tuple]) -> None: ... + async def record_latency( + self, kind: str, latency_us: int, provider: str | None = None + ) -> None: ... + async def provider_metrics_summary( + self, from_date: str | None = None, to_date: str | None = None + ) -> dict: ... + async def latency_summary( + self, from_date: str | None = None, to_date: str | None = None + ) -> dict: ... + async def prune_latency(self, retention_days: int) -> None: ... + async def metrics_summary( + self, from_date: str | None = None, to_date: str | None = None + ) -> dict: ... + async def list( + self, + provider: str | None = None, + voice_id: str | None = None, + limit: int = 100, + offset: int = 0, + ) -> list[CacheRecord]: ... + async def stats(self) -> dict: ... + + +class BlobStore(Protocol): + async def put( + self, key: str, data: bytes + ) -> str: ... # returns relative storage_path + async def get(self, storage_path: str) -> bytes: ... + async def delete(self, storage_path: str) -> bool: ... diff --git a/dragontts/app/storage/filesystem.py b/dragontts/app/storage/filesystem.py new file mode 100644 index 000000000..ccc025e28 --- /dev/null +++ b/dragontts/app/storage/filesystem.py @@ -0,0 +1,115 @@ +"""Filesystem blob store — content-addressed, sharded (ab/cd/). + +No in-process cache: hot reads are served by the OS page cache (kernel-managed, +self-invalidating), which makes an application-level LRU redundant — it only saved +~tens of μs over a page-cache hit, cost 64MB of heap, and its invalidation was a +correctness hazard. Bound the pod's memory with a k8s limit so the kernel caps the +page cache under pressure. + +Writes ``fsync`` the file so the blob is at least as durable as the metadata row that +references it — ``cache/service._store`` writes the blob BEFORE committing metadata, so +a crash can never leave a row pointing at a missing/truncated blob. +""" + +from __future__ import annotations + +import asyncio +import os +import threading +from pathlib import Path + +from app.core.logging import logger + + +class FilesystemBlobStore: + def __init__(self, blob_dir: str): + self.blob_dir = Path(blob_dir) + # Shard dirs known to already exist; lets put() skip the mkdir syscall on + # every write after the first per dir (mkdir is costly on a networked PVC). + # Guarded by a lock because concurrent worker threads call put(). + self._seen_dirs: set[Path] = set() + self._dirs_lock = threading.Lock() + + async def init(self) -> None: + await asyncio.to_thread(self.blob_dir.mkdir, parents=True, exist_ok=True) + logger.info(f"Filesystem blob store ready at {self.blob_dir}") + + def _rel_path(self, key: str) -> Path: + return Path(key[:2]) / key[2:4] / key + + # -- store ops ----------------------------------------------------------- + + async def put(self, key: str, data: bytes) -> str: + rel = self._rel_path(key) + + def _write() -> str: + path = self.blob_dir / rel + parent = path.parent + # mkdir is idempotent but a syscall; skip it once the shard dir is + # known to exist. Double-checked so the lock is only taken on the + # first create of each dir, not on every write. + if parent not in self._seen_dirs: + with self._dirs_lock: + if parent not in self._seen_dirs: + parent.mkdir(parents=True, exist_ok=True) + self._seen_dirs.add(parent) + # Atomic publish: write a sibling temp file, fsync, then os.replace + # onto the final path (atomic rename-over on POSIX for same-FS paths). + # Two worker processes can MISS the same key at once (single-flight is + # per-process), so a plain open("wb") -- which truncates at open -- + # could let a reader overlapping a writer observe a truncated/empty + # file. With temp+replace a reader always sees either the previous + # complete blob or the new complete blob, never a half-write. fsync + # still makes the blob at least as durable as the metadata row + # committed after it (cache/service._store writes blob BEFORE row). + tmp = parent / f".{path.name}.{os.getpid()}.{threading.get_ident():x}.tmp" + try: + with open(tmp, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + finally: + # After a successful os.replace the temp no longer exists (it was + # renamed onto `path`); this only cleans up on a pre-replace failure. + if tmp.exists(): + try: + tmp.unlink() + except OSError: + pass + return str(rel) + + return await asyncio.to_thread(_write) + + async def get(self, storage_path: str) -> bytes: + # No app-level cache: served straight from the OS page cache (hot) or disk. + return await asyncio.to_thread((self.blob_dir / storage_path).read_bytes) + + async def delete(self, storage_path: str) -> bool: + path = self.blob_dir / storage_path + + def _del() -> bool: + if path.exists(): + path.unlink() + return True + return False + + return await asyncio.to_thread(_del) + + async def iter_blobs(self): + """Yield ``(key, rel_path)`` for every stored blob, for reconciliation. + + ``rel_path`` matches what :meth:`delete` expects (``ab/cd/``), and + ``key`` is the bare filename so it can be checked against the metadata + store's key set.""" + + def _scan(): + out = [] + for root, _dirs, files in os.walk(self.blob_dir): + for name in files: + rel = str(Path(root).relative_to(self.blob_dir) / name) + out.append((name, rel)) + return out + + for item in await asyncio.to_thread(_scan): + yield item diff --git a/dragontts/app/storage/sqlite.py b/dragontts/app/storage/sqlite.py new file mode 100644 index 000000000..38ad5ec0d --- /dev/null +++ b/dragontts/app/storage/sqlite.py @@ -0,0 +1,1254 @@ +"""SQLite metadata store (stdlib sqlite3, wrapped with asyncio.to_thread). + +Each worker thread owns its own connection (thread-local), fetched INSIDE the +threaded call via :meth:`_run`. This is what makes the store safe and parallel +under concurrency: WAL then allows many concurrent readers across the +per-thread connections and a single serialized writer, and writes can't corrupt +the way they would if one event-loop-thread connection were shared across +workers (``check_same_thread=False`` + sqlite3 threadsafety=1 is unsafe for +concurrent use of a single connection). +""" + +from __future__ import annotations + +import asyncio +import sqlite3 +import threading +from collections.abc import Callable +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import TypeVar + +from app.core.logging import logger +from app.storage.base import CacheRecord, escape_like + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS cache_entries ( + key TEXT PRIMARY KEY, + provider TEXT NOT NULL, + voice_id TEXT NOT NULL, + model TEXT NOT NULL, + language TEXT NOT NULL, + params TEXT NOT NULL DEFAULT '', + text TEXT, + container TEXT NOT NULL, + encoding TEXT NOT NULL, + sample_rate INTEGER NOT NULL, + size_bytes INTEGER NOT NULL, + storage_path TEXT NOT NULL, + hit_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + last_accessed_at TEXT NOT NULL, + ttl_expires_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_provider_voice ON cache_entries(provider, voice_id); + +-- Request metrics rolled up per UTC day (date-filterable via SUM, never a scan +-- of cache_entries). +CREATE TABLE IF NOT EXISTS metrics_daily ( + date TEXT PRIMARY KEY, + requests INTEGER NOT NULL DEFAULT 0, + hits INTEGER NOT NULL DEFAULT 0, + misses INTEGER NOT NULL DEFAULT 0, + bytes_served INTEGER NOT NULL DEFAULT 0, + synth_calls INTEGER NOT NULL DEFAULT 0, + base64_uploads INTEGER NOT NULL DEFAULT 0, + creates INTEGER NOT NULL DEFAULT 0, + deletes INTEGER NOT NULL DEFAULT 0, + words_served INTEGER NOT NULL DEFAULT 0, + words_synthesized INTEGER NOT NULL DEFAULT 0 +); + +-- Per-provider daily rollup (hit rate / synth calls per provider). +CREATE TABLE IF NOT EXISTS metrics_daily_provider ( + date TEXT NOT NULL, + provider TEXT NOT NULL, + requests INTEGER NOT NULL DEFAULT 0, + hits INTEGER NOT NULL DEFAULT 0, + misses INTEGER NOT NULL DEFAULT 0, + synth_calls INTEGER NOT NULL DEFAULT 0, + bytes_served INTEGER NOT NULL DEFAULT 0, + words_served INTEGER NOT NULL DEFAULT 0, + words_synthesized INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, provider) +); + +-- Latency samples for the avg/p95 rollup (day-filtered). Bounded by sample rate +-- + retention prune; auto-rowid, indexed (kind, date) for count + sorted p95. +CREATE TABLE IF NOT EXISTS latency_samples ( + date TEXT NOT NULL, + kind TEXT NOT NULL, + latency_us INTEGER NOT NULL, + provider TEXT +); +CREATE INDEX IF NOT EXISTS idx_latency_kind_date ON latency_samples(kind, date); + +-- Once-daily Slack summary coordination across uvicorn workers (DragonTTS has no +-- Redis, so this table is the shared claim). key='summary_last_post', value = +-- today's UTC date once a worker has posted. See claim_slack_summary/release. +CREATE TABLE IF NOT EXISTS slack_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' +); + +-- Cache snapshot maintained incrementally so /stats is O(providers), not a +-- full-table GROUP BY. +CREATE TABLE IF NOT EXISTS provider_totals ( + provider TEXT PRIMARY KEY, + entries INTEGER NOT NULL DEFAULT 0, + total_bytes INTEGER NOT NULL DEFAULT 0, + total_words INTEGER NOT NULL DEFAULT 0 +); +""" + +_COLUMNS = ( + "key", + "provider", + "voice_id", + "model", + "language", + "params", + "text", + "container", + "encoding", + "sample_rate", + "size_bytes", + "storage_path", + "hit_count", + "created_at", + "last_accessed_at", + "ttl_expires_at", +) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _row_to_record(row: sqlite3.Row) -> CacheRecord: + d = dict(row) + return CacheRecord(**{c: d[c] for c in _COLUMNS}) + + +def _wc(text: str | None) -> int: + """Word count of a stored transcript (whitespace tokens, punctuation kept).""" + return len((text or "").split()) + + +def _word_count_sql(column: str = "text") -> str: + """SQL word count of a TEXT column (whitespace tokens); NULL/empty -> 0. + Accurate for normalize_text output (whitespace already collapsed).""" + return ( + f"CASE WHEN {column} IS NULL OR {column} = '' THEN 0 " + f"ELSE LENGTH({column}) - LENGTH(REPLACE({column}, ' ', '')) + 1 END" + ) + + +# Columns mirrored into metrics_daily_provider when record_metrics/touch_and_record +# carry a provider (the per-provider-relevant subset of the daily counters). +_PROVIDER_COLS = ( + "requests", + "hits", + "misses", + "synth_calls", + "bytes_served", + "words_served", + "words_synthesized", +) + +# Idempotent column additions for existing DBs (ALTER ... ADD COLUMN ... DEFAULT 0). +_METRICS_MIGRATIONS = [ + ("metrics_daily", "words_served INTEGER NOT NULL DEFAULT 0"), + ("metrics_daily", "words_synthesized INTEGER NOT NULL DEFAULT 0"), + ("provider_totals", "total_words INTEGER NOT NULL DEFAULT 0"), + # Per-provider words_synthesized (enables per-provider words-from-cache % + + # per-provider cost-saved in the Slack summary). The data was already routed + # here (every synth-path record_metrics passes provider=), it just wasn't + # persisted before this column existed. + ("metrics_daily_provider", "words_synthesized INTEGER NOT NULL DEFAULT 0"), + # Per-provider latency. Nullable so pre-existing rows stay NULL (the + # by-provider query filters provider IS NOT NULL). Idempotent ADD COLUMN. + ("latency_samples", "provider TEXT"), +] + + +def _migrate(conn: sqlite3.Connection) -> None: + """Add new columns to existing tables (no-op on a fresh DB, which already has + them via _SCHEMA). SQLite ADD COLUMN ... DEFAULT 0 is online/non-locking. + Each check+ALTER is wrapped in BEGIN IMMEDIATE so the table_info guard and + the ADD COLUMN are atomic across worker processes (see comment below).""" + for table, ddl in _METRICS_MIGRATIONS: + col = ddl.split()[0] + # BEGIN IMMEDIATE makes the table_info check + ADD COLUMN atomic ACROSS + # worker processes: without it, two uvicorn workers starting against the + # same PVC DB can both read "column absent" then both ALTER, the second + # failing with 'duplicate column name' and crash-looping that worker. + conn.execute("BEGIN IMMEDIATE") + try: + existing = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")} + if col not in existing: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}") + conn.execute("COMMIT") + except Exception: + conn.execute("ROLLBACK") + raise + # Indexes that depend on a migrated column are created here (after the ALTER), + # NOT in _SCHEMA — _SCHEMA runs before _migrate and would reference a + # not-yet-existing column on an old DB (the provider index failed that way). + # Idempotent via IF NOT EXISTS; a no-op once it exists. + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_latency_provider_kind_date " + "ON latency_samples(provider, kind, date)" + ) + + +T = TypeVar("T") + + +class SQLiteMetadataStore: + """Async-friendly metadata store backed by a single SQLite file (WAL mode).""" + + def __init__(self, db_path: str): + self.db_path = db_path + self._local = threading.local() + + def _new_connection(self) -> sqlite3.Connection: + # One connection per OS thread. WAL permits many concurrent readers + # across connections and a single serialized writer; busy_timeout makes + # contending writers wait instead of raising "database is locked". + # isolation_level=None => autocommit. Our ops are single-statement + # (point lookups / single inserts); autocommit avoids implicit nested + # transactions ("cannot start a transaction within a transaction") on + # long-lived, reused thread connections. + conn = sqlite3.connect( + self.db_path, timeout=30, isolation_level=None, check_same_thread=False + ) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + # Match connect()'s timeout (30s): contending writers retry up to 30s + # before raising SQLITE_BUSY. + conn.execute("PRAGMA busy_timeout=30000") + # Per-connection page cache. With one connection per worker thread, + # keep this modest (8MB) so N connections stay memory-bounded; the + # 256MB mmap below carries the real read working set (OS page cache). + conn.execute( + "PRAGMA cache_size = -2048" + ) # ~2MB page cache (was 8MB) -- the mmap + OS page cache carry the real read working set, so keep this small; bounds per-connection RSS x (THREAD_POOL_WORKERS conns) x (--workers processes) under the pod's memory limit + conn.execute("PRAGMA temp_store = MEMORY") + conn.execute( + "PRAGMA mmap_size = 67108864" + ) # 64MB mmap for reads (was 256MB) -- bounds resident mmap pages under the cgroup limit; hot reads still come from the OS page cache + return conn + + async def init(self) -> None: + Path(self.db_path).parent.mkdir(parents=True, exist_ok=True) + + def _setup() -> None: + conn = self._new_connection() + try: + conn.executescript(_SCHEMA) + _migrate(conn) # add new columns to an existing DB (no-op if fresh) + # Backfill snapshot totals if empty but entries exist (handles a + # migration from before provider_totals existed). + has_totals = conn.execute( + "SELECT COUNT(*) FROM provider_totals" + ).fetchone()[0] + if has_totals == 0: + conn.execute( + "INSERT INTO provider_totals (provider, entries, total_bytes, total_words) " + "SELECT provider, COUNT(*), COALESCE(SUM(size_bytes), 0), " + f"COALESCE(SUM({_word_count_sql('text')}), 0) " + "FROM cache_entries GROUP BY provider " + "ON CONFLICT(provider) DO UPDATE SET " + "entries = excluded.entries, total_bytes = excluded.total_bytes, " + "total_words = excluded.total_words" + ) + # Backfill total_words from cache_entries ONLY on the first start + # after the migration (entries exist but total_words is still the 0 + # default) — avoids a per-restart scan of a growing live cache. + has_entries = ( + conn.execute("SELECT COUNT(*) FROM cache_entries").fetchone()[0] > 0 + ) + words_zero = ( + conn.execute( + "SELECT COALESCE(SUM(total_words), 0) FROM provider_totals" + ).fetchone()[0] + == 0 + ) + if has_entries and words_zero: + conn.execute( + "UPDATE provider_totals SET total_words = COALESCE(" + f"(SELECT SUM({_word_count_sql('cache_entries.text')}) " + "FROM cache_entries WHERE cache_entries.provider = provider_totals.provider), 0)" + ) + # Compact any leftover -wal from a prior run now that the schema + # is open (runtime growth is bounded by the periodic checkpoint). + try: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + except Exception: + pass + finally: + conn.close() + + await asyncio.to_thread(_setup) + logger.info(f"SQLite metadata store ready at {self.db_path}") + + async def checkpoint(self) -> None: + """Run a TRUNCATE WAL checkpoint so the ``-wal`` file is compacted back + into the main db. Passive auto-checkpoint (1000 frames) moves frames but + doesn't shrink the ``-wal`` while worker connections stay open, so this + is run on startup, periodically, and on shutdown to keep it bounded on + the PVC. Best-effort: never fails a request over a checkpoint error.""" + + def _cp(conn: sqlite3.Connection) -> None: + try: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + except Exception: + pass + + await self._run(_cp) + + def _conn(self) -> sqlite3.Connection: + """Lazily-created, thread-local connection (cached for reuse). MUST be + called from the worker thread that will use it — see :meth:`_run`.""" + conn = getattr(self._local, "conn", None) + if conn is None: + conn = self._new_connection() + self._local.conn = conn + return conn + + async def _run(self, fn: Callable[[sqlite3.Connection], T]) -> T: + """Run ``fn(connection)`` on a worker thread using THAT thread's own + connection. This is the concurrency-safe path: every worker thread gets + its own SQLite connection, so reads parallelize under WAL and concurrent + writes don't corrupt (no shared event-loop-thread connection).""" + return await asyncio.to_thread(lambda: fn(self._conn())) + + async def get(self, key: str) -> CacheRecord | None: + def _q(conn: sqlite3.Connection) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM cache_entries WHERE key = ?", (key,) + ).fetchone() + + row = await self._run(_q) + return _row_to_record(row) if row else None + + async def put(self, record: CacheRecord) -> None: + values = tuple(getattr(record, c) for c in _COLUMNS) + placeholders = ",".join("?" for _ in _COLUMNS) + sql = ( + f"INSERT OR REPLACE INTO cache_entries ({','.join(_COLUMNS)}) " + f"VALUES ({placeholders})" + ) + + def _w(conn: sqlite3.Connection) -> None: + conn.execute(sql, values) + conn.commit() + + await self._run(_w) + + async def put_with_totals(self, record: CacheRecord) -> None: + """Insert a NEW cache row; bump provider_totals only if this call + actually inserted it. + + ``INSERT OR IGNORE`` + rowcount is race-free under WAL: writers + serialize, so concurrent stores of the SAME key produce exactly one + insert and one totals bump — ``provider_totals`` can't drift. Each + statement autocommits (no explicit BEGIN, which conflicts with + isolation_level=None on reused thread-local connections). For overrides + and expired-refresh use ``put`` (REPLACE) + ``adjust_totals``. + """ + values = tuple(getattr(record, c) for c in _COLUMNS) + placeholders = ",".join("?" for _ in _COLUMNS) + insert_sql = ( + f"INSERT OR IGNORE INTO cache_entries ({','.join(_COLUMNS)}) " + f"VALUES ({placeholders})" + ) + totals_sql = ( + "INSERT INTO provider_totals (provider, entries, total_bytes, total_words) " + "VALUES (?, 1, ?, ?) " + "ON CONFLICT(provider) DO UPDATE SET " + "entries = entries + 1, total_bytes = total_bytes + excluded.total_bytes, " + "total_words = total_words + excluded.total_words" + ) + + def _w(conn: sqlite3.Connection) -> None: + # Atomic: the row insert and the provider_totals bump must both land + # or neither. Under isolation_level=None each execute autocommits + # independently, so wrap the pair in an explicit transaction. + conn.execute("BEGIN IMMEDIATE") + try: + cur = conn.execute(insert_sql, values) + if cur.rowcount == 1: # newly inserted; a concurrent dup was ignored + conn.execute( + totals_sql, + (record.provider, record.size_bytes, _wc(record.text)), + ) + conn.execute("COMMIT") + except BaseException: + conn.execute("ROLLBACK") + raise + + await self._run(_w) + + async def replace_with_totals(self, record: CacheRecord) -> None: + """REPLACE an existing row and adjust ``provider_totals`` atomically. + + Used by the refresh/override store path. ``put`` + ``adjust_totals`` + separately would not be atomic: a concurrent ``delete`` of the same key + landing between them would drift ``provider_totals`` (delete decrements, + then the stale adjust re-applies a delta against a row that's gone). The + current row's size is re-read INSIDE the ``BEGIN IMMEDIATE`` transaction + so the delta reflects the row's actual state under the write lock. + ``provider`` can't change (it's part of the key), so only the size delta + matters for an existing row; a missing prior row is a fresh insert (+1). + """ + values = tuple(getattr(record, c) for c in _COLUMNS) + placeholders = ",".join("?" for _ in _COLUMNS) + replace_sql = ( + f"INSERT OR REPLACE INTO cache_entries ({','.join(_COLUMNS)}) " + f"VALUES ({placeholders})" + ) + totals_sql = ( + "INSERT INTO provider_totals (provider, entries, total_bytes, total_words) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT(provider) DO UPDATE SET " + "entries = entries + excluded.entries, total_bytes = total_bytes + excluded.total_bytes, " + "total_words = total_words + excluded.total_words" + ) + + def _w(conn: sqlite3.Connection) -> None: + conn.execute("BEGIN IMMEDIATE") + try: + prev = conn.execute( + "SELECT size_bytes, text FROM cache_entries WHERE key = ?", + (record.key,), + ).fetchone() + conn.execute(replace_sql, values) + new_wc = _wc(record.text) + if prev is None: + conn.execute( + totals_sql, (record.provider, 1, record.size_bytes, new_wc) + ) + else: + conn.execute( + totals_sql, + ( + record.provider, + 0, + record.size_bytes - prev[0], + new_wc - _wc(prev[1]), + ), + ) + conn.execute("COMMIT") + except BaseException: + conn.execute("ROLLBACK") + raise + + await self._run(_w) + + async def touch(self, key: str) -> None: + def _t(conn: sqlite3.Connection) -> None: + conn.execute( + "UPDATE cache_entries SET hit_count = hit_count + 1, " + "last_accessed_at = ? WHERE key = ?", + (_now(), key), + ) + conn.commit() + + await self._run(_t) + + async def delete(self, key: str) -> bool: + """Delete one row AND adjust provider_totals atomically. + + SELECT + DELETE + totals-adjust run in one ``BEGIN IMMEDIATE`` + transaction, so the captured size is the row's actual size at delete + time (a concurrent override of the same key can't make totals drift) + and a concurrent insert can't escape. Returns True iff a row was deleted. + """ + totals_sql = ( + "INSERT INTO provider_totals (provider, entries, total_bytes, total_words) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT(provider) DO UPDATE SET " + "entries = entries + excluded.entries, total_bytes = total_bytes + excluded.total_bytes, " + "total_words = total_words + excluded.total_words" + ) + + def _d(conn: sqlite3.Connection) -> bool: + conn.execute("BEGIN IMMEDIATE") + try: + row = conn.execute( + "SELECT provider, size_bytes, text FROM cache_entries WHERE key = ?", + (key,), + ).fetchone() + if row is None: + conn.execute("COMMIT") + return False + conn.execute("DELETE FROM cache_entries WHERE key = ?", (key,)) + conn.execute(totals_sql, (row[0], -1, -row[1], -_wc(row[2]))) + conn.execute("COMMIT") + return True + except BaseException: + conn.execute("ROLLBACK") + raise + + return await self._run(_d) + + async def list( + self, + provider: str | None = None, + voice_id: str | None = None, + limit: int = 100, + offset: int = 0, + q: str | None = None, + match: str = "exact", + created_before: str | None = None, + created_after: str | None = None, + ) -> list[CacheRecord]: + clauses: list[str] = [] + args: list = [] + if provider: + clauses.append("provider = ?") + args.append(provider) + if voice_id: + clauses.append("voice_id = ?") + args.append(voice_id) + if q: + if match.lower() == "substring": + # ESCAPE '\\' so a user '%'/_'_' in q matches literally, not as a + # wildcard (else q='_' would match every single-char text row). + clauses.append("text LIKE ? ESCAPE '\\'") + args.append(f"%{escape_like(q)}%") + else: + clauses.append("text = ?") + args.append(q) + # created_at is a full ISO timestamp; bare-date args compare sensibly: + # created_after=YYYY-MM-DD is inclusive of that whole day (timestamp sorts + # after the bare date); created_before=YYYY-MM-DD is exclusive (strictly + # before that day's start). + if created_after: + clauses.append("created_at >= ?") + args.append(created_after) + if created_before: + clauses.append("created_at < ?") + args.append(created_before) + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + args.append(limit) + args.append(offset) + + def _q(conn: sqlite3.Connection) -> list[sqlite3.Row]: + return conn.execute( + f"SELECT * FROM cache_entries{where} ORDER BY created_at DESC " + f"LIMIT ? OFFSET ?", + args, + ).fetchall() + + rows = await self._run(_q) + return [_row_to_record(r) for r in rows] + + async def all_keys(self) -> set[str]: + """Every cache key — used to reap orphaned blob files.""" + + def _q(conn: sqlite3.Connection) -> set[str]: + return {row[0] for row in conn.execute("SELECT key FROM cache_entries")} + + return await self._run(_q) + + async def delete_filtered( + self, provider: str | None = None, voice_id: str | None = None + ) -> list[tuple]: + """Bulk-delete matching rows AND adjust provider_totals atomically; + return (provider, size_bytes, storage_path) per row for blob cleanup. + + SELECT + DELETE + per-provider totals-adjust run in one ``BEGIN + IMMEDIATE`` transaction, so a concurrent insert can't escape the clear + and totals can't drift. + """ + clauses: list[str] = [] + args: list = [] + if provider: + clauses.append("provider = ?") + args.append(provider) + if voice_id: + clauses.append("voice_id = ?") + args.append(voice_id) + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + totals_sql = ( + "INSERT INTO provider_totals (provider, entries, total_bytes, total_words) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT(provider) DO UPDATE SET " + "entries = entries + excluded.entries, total_bytes = total_bytes + excluded.total_bytes, " + "total_words = total_words + excluded.total_words" + ) + + def _d(conn: sqlite3.Connection) -> list[tuple]: + conn.execute("BEGIN IMMEDIATE") + try: + rows = conn.execute( + f"SELECT provider, size_bytes, storage_path, text FROM cache_entries{where}", + args, + ).fetchall() + if rows: + conn.execute(f"DELETE FROM cache_entries{where}", args) + # Sum (entries, bytes, words) deltas per provider, apply once each. + deltas: dict[str, list[int]] = {} + for r in rows: + d = deltas.setdefault(r[0], [0, 0, 0]) + d[0] -= 1 + d[1] -= r[1] + d[2] -= _wc(r[3]) + for prov, (de, db, dw) in deltas.items(): + conn.execute(totals_sql, (prov, de, db, dw)) + conn.execute("COMMIT") + return [(r[0], r[1], r[2]) for r in rows] + except BaseException: + conn.execute("ROLLBACK") + raise + + return await self._run(_d) + + async def purge_expired(self, now_iso: str) -> list[tuple]: + """Delete rows whose ``ttl_expires_at`` is set and < ``now_iso``, adjusting + ``provider_totals`` atomically; return ``(provider, size_bytes, + storage_path)`` per row so the caller can unlink the blobs. Mirrors + :meth:`delete_filtered`'s transaction so a concurrent insert can't escape + the purge or drift totals. Rows with NULL ``ttl_expires_at`` (never + expiring) are left alone.""" + where = " WHERE ttl_expires_at IS NOT NULL AND ttl_expires_at < ?" + totals_sql = ( + "INSERT INTO provider_totals (provider, entries, total_bytes, total_words) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT(provider) DO UPDATE SET " + "entries = entries + excluded.entries, total_bytes = total_bytes + excluded.total_bytes, " + "total_words = total_words + excluded.total_words" + ) + + def _d(conn: sqlite3.Connection) -> list[tuple]: + conn.execute("BEGIN IMMEDIATE") + try: + rows = conn.execute( + f"SELECT provider, size_bytes, storage_path, text FROM cache_entries{where}", + (now_iso,), + ).fetchall() + if rows: + conn.execute(f"DELETE FROM cache_entries{where}", (now_iso,)) + deltas: dict[str, list[int]] = {} + for r in rows: + d = deltas.setdefault(r[0], [0, 0, 0]) + d[0] -= 1 + d[1] -= r[1] + d[2] -= _wc(r[3]) + for prov, (de, db, dw) in deltas.items(): + conn.execute(totals_sql, (prov, de, db, dw)) + conn.execute("COMMIT") + return [(r[0], r[1], r[2]) for r in rows] + except BaseException: + conn.execute("ROLLBACK") + raise + + return await self._run(_d) + + async def backfill_missing_ttl(self, min_hours: int, max_hours: int) -> int: + """Set a RANDOM expiry in ``[min_hours, max_hours]`` from now on every row + whose ``ttl_expires_at`` is NULL (pre-existing entries created before + length-scaled TTL, e.g. under ``ttl_seconds=0``). Idempotent — only + touches NULL rows, so re-running on restart is a no-op. Returns the + number of rows backfilled. + + ``abs(random()) % span`` is evaluated per row, so expiries spread across + the window instead of expiring en masse (which would spike re-synths). + """ + if max_hours < min_hours: + max_hours = min_hours + span = max(1, max_hours - min_hours + 1) # inclusive [min, max] + sql = ( + "UPDATE cache_entries " + "SET ttl_expires_at = strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', " + "(? + (abs(random()) % ?)) || ' hours') " + "WHERE ttl_expires_at IS NULL" + ) + + def _u(conn: sqlite3.Connection) -> int: + cur = conn.execute(sql, (min_hours, span)) + return cur.rowcount + + return await self._run(_u) + + async def daily_summary( + self, from_date: str | None = None, to_date: str | None = None + ) -> dict: + """Extensive day-wise metrics over [from_date, to_date] (UTC dates): + for each date, the full ``metrics_daily`` totals (requests, hits, misses, + synth_calls, words_served/synthesized, bytes_served, creates, deletes) + AND a per-provider breakdown from ``metrics_daily_provider``. + Returns ``{date: {"totals": {...raw sums...}, "by_provider": {prov: {...}}}}``. + Derived rates (hit_rate, words_from_cache_pct) are computed by the + caller — this returns raw sums only.""" + clauses: list[str] = [] + args: list = [] + if from_date: + clauses.append("date >= ?") + args.append(from_date) + if to_date: + clauses.append("date <= ?") + args.append(to_date) + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + + def _q(conn: sqlite3.Connection) -> dict: + out: dict[str, dict] = {} + for r in conn.execute( + f"SELECT * FROM metrics_daily{where} ORDER BY date", args + ): + d = r["date"] + out.setdefault(d, {"totals": {}, "by_provider": {}}) + out[d]["totals"] = { + "requests": r["requests"], + "hits": r["hits"], + "misses": r["misses"], + "bytes_served": r["bytes_served"], + "synth_calls": r["synth_calls"], + "base64_uploads": r["base64_uploads"], + "creates": r["creates"], + "deletes": r["deletes"], + "words_served": r["words_served"], + "words_synthesized": r["words_synthesized"], + } + prow = ( + "SELECT date, provider, requests, hits, misses, synth_calls, " + f"bytes_served, words_served, words_synthesized " + f"FROM metrics_daily_provider{where} ORDER BY date, provider" + ) + for r in conn.execute(prow, args): + d = r["date"] + out.setdefault(d, {"totals": {}, "by_provider": {}}) + out[d]["by_provider"][r["provider"]] = { + "requests": r["requests"], + "hits": r["hits"], + "misses": r["misses"], + "synth_calls": r["synth_calls"], + "bytes_served": r["bytes_served"], + "words_served": r["words_served"], + "words_synthesized": r["words_synthesized"], + } + return out + + return await self._run(_q) + + async def delete_where( + self, where_sql: str, args: list, *, dry_run: bool + ) -> list[dict]: + """Generic filtered delete for the cache-control endpoints. SELECTs rows + matching ``where_sql`` (always returned for preview + blob cleanup); when + not ``dry_run``, DELETEs them and adjusts ``provider_totals`` in one + ``BEGIN IMMEDIATE`` transaction. Returns a list of dicts + ``{provider, size_bytes, storage_path, text, key, voice_id}`` per matched + row. ``where_sql`` is the clause text without the leading ``WHERE``.""" + where = f" WHERE {where_sql}" if where_sql else "" + select = ( + f"SELECT provider, size_bytes, storage_path, text, key, voice_id " + f"FROM cache_entries{where}" + ) + totals_sql = ( + "INSERT INTO provider_totals (provider, entries, total_bytes, total_words) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT(provider) DO UPDATE SET " + "entries = entries + excluded.entries, total_bytes = total_bytes + excluded.total_bytes, " + "total_words = total_words + excluded.total_words" + ) + cols = ("provider", "size_bytes", "storage_path", "text", "key", "voice_id") + + def _matched(conn: sqlite3.Connection) -> list[dict]: + return [dict(zip(cols, r)) for r in conn.execute(select, args).fetchall()] + + def _d(conn: sqlite3.Connection) -> list[dict]: + if dry_run: + return _matched(conn) # preview only — no write lock taken + # SELECT inside the write transaction so a concurrent insert between + # the snapshot and the DELETE can't orphan a blob or drift the totals + # delta (TOCTOU-safe, matching delete_filtered / purge_expired). + conn.execute("BEGIN IMMEDIATE") + try: + rows = conn.execute(select, args).fetchall() + if rows: + conn.execute(f"DELETE FROM cache_entries{where}", args) + deltas: dict[str, list[int]] = {} + for r in rows: + d = deltas.setdefault(r[0], [0, 0, 0]) + d[0] -= 1 + d[1] -= r[1] + d[2] -= _wc(r[3]) + for prov, (de, db, dw) in deltas.items(): + conn.execute(totals_sql, (prov, de, db, dw)) + conn.execute("COMMIT") + return [dict(zip(cols, r)) for r in rows] + except BaseException: + conn.execute("ROLLBACK") + raise + + return await self._run(_d) + + async def adjust_totals( + self, provider: str, delta_entries: int, delta_bytes: int, delta_words: int = 0 + ) -> None: + sql = ( + "INSERT INTO provider_totals (provider, entries, total_bytes, total_words) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT(provider) DO UPDATE SET " + "entries = entries + excluded.entries, total_bytes = total_bytes + excluded.total_bytes, " + "total_words = total_words + excluded.total_words" + ) + + def _w(conn: sqlite3.Connection) -> None: + conn.execute(sql, (provider, delta_entries, delta_bytes, delta_words)) + + await self._run(_w) + + async def record_metrics( + self, *, provider: str | None = None, **deltas: int + ) -> None: + """Upsert today's UTC daily-rollup row, adding the given metric deltas. + + When ``provider`` is given, also upsert the per-provider-relevant subset + into metrics_daily_provider (per-provider hit rate / synth calls).""" + if not deltas: + return + today = datetime.now(timezone.utc).date().isoformat() + cols = list(deltas) + col_list = ", ".join(cols) + placeholders = ", ".join("?" for _ in cols) + upd = ", ".join(f"{c} = {c} + excluded.{c}" for c in cols) + sql = ( + f"INSERT INTO metrics_daily (date, {col_list}) VALUES (?, {placeholders}) " + f"ON CONFLICT(date) DO UPDATE SET {upd}" + ) + values = [today, *(deltas[c] for c in cols)] + + # Per-provider rollup (only the per-provider-relevant counters). + psub = {c: deltas[c] for c in _PROVIDER_COLS if c in deltas} + psql, pvals = None, None + if provider is not None and psub: + pcols = list(psub) + pcol_list = ", ".join(pcols) + pph = ", ".join("?" for _ in pcols) + pupd = ", ".join(f"{c} = {c} + excluded.{c}" for c in pcols) + psql = ( + f"INSERT INTO metrics_daily_provider (date, provider, {pcol_list}) " + f"VALUES (?, ?, {pph}) ON CONFLICT(date, provider) DO UPDATE SET {pupd}" + ) + pvals = [today, provider, *(psub[c] for c in pcols)] + + def _w(conn: sqlite3.Connection) -> None: + conn.execute(sql, values) + if psql is not None: + conn.execute(psql, pvals) + + await self._run(_w) + + async def touch_and_record( + self, key: str, metric_deltas: dict, *, provider: str | None = None + ) -> None: + """Increment an entry's hit_count/last_accessed AND daily metrics in one hop. + + When ``provider`` is given, also upsert the per-provider subset.""" + today = datetime.now(timezone.utc).date().isoformat() + cols = list(metric_deltas) + col_list = ", ".join(cols) + placeholders = ", ".join("?" for _ in cols) + upd = ", ".join(f"{c} = {c} + excluded.{c}" for c in cols) + msql = ( + f"INSERT INTO metrics_daily (date, {col_list}) VALUES (?, {placeholders}) " + f"ON CONFLICT(date) DO UPDATE SET {upd}" + ) + mvals = [today, *(metric_deltas[c] for c in cols)] + + psub = {c: metric_deltas[c] for c in _PROVIDER_COLS if c in metric_deltas} + psql, pvals = None, None + if provider is not None and psub: + pcols = list(psub) + pcol_list = ", ".join(pcols) + pph = ", ".join("?" for _ in pcols) + pupd = ", ".join(f"{c} = {c} + excluded.{c}" for c in pcols) + psql = ( + f"INSERT INTO metrics_daily_provider (date, provider, {pcol_list}) " + f"VALUES (?, ?, {pph}) ON CONFLICT(date, provider) DO UPDATE SET {pupd}" + ) + pvals = [today, provider, *(psub[c] for c in pcols)] + + def _t(conn: sqlite3.Connection) -> None: + # Bump hit_count by the ``hits`` delta (default 1) so batched writes + # (write-behind sums N hits into one call) advance the row by N, not 1. + hits_delta = int(metric_deltas.get("hits", 1)) + conn.execute( + "UPDATE cache_entries SET hit_count = hit_count + ?, last_accessed_at = ? " + "WHERE key = ?", + (hits_delta, _now(), key), + ) + conn.execute(msql, mvals) + if psql is not None: + conn.execute(psql, pvals) + + await self._run(_t) + + async def record_latency_batch(self, samples: list[tuple]) -> None: + """Persist a batch of ``(kind, latency_us, provider)`` latency samples (today). + + ``provider`` is nullable for back-compat (older call sites may pass None).""" + if not samples: + return + today = datetime.now(timezone.utc).date().isoformat() + rows = [(today, kind, int(us), prov) for kind, us, prov in samples] + + def _w(conn: sqlite3.Connection) -> None: + conn.executemany( + "INSERT INTO latency_samples (date, kind, latency_us, provider) " + "VALUES (?, ?, ?, ?)", + rows, + ) + + await self._run(_w) + + async def record_latency( + self, kind: str, latency_us: int, provider: str | None = None + ) -> None: + """Single-sample convenience (used when write-behind is disabled).""" + await self.record_latency_batch([(kind, latency_us, provider)]) + + async def metrics_summary( + self, from_date: str | None = None, to_date: str | None = None + ) -> dict: + clauses: list[str] = [] + args: list = [] + if from_date: + clauses.append("date >= ?") + args.append(from_date) + if to_date: + clauses.append("date <= ?") + args.append(to_date) + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + + def _q(conn: sqlite3.Connection) -> dict: + r = conn.execute( + "SELECT COALESCE(SUM(requests),0), COALESCE(SUM(hits),0), " + "COALESCE(SUM(misses),0), COALESCE(SUM(bytes_served),0), " + "COALESCE(SUM(synth_calls),0), COALESCE(SUM(base64_uploads),0), " + "COALESCE(SUM(creates),0), COALESCE(SUM(deletes),0), " + "COALESCE(SUM(words_served),0), COALESCE(SUM(words_synthesized),0) " + f"FROM metrics_daily{where}", + args, + ).fetchone() + ( + requests, + hits, + misses, + bytes_served, + synth_calls, + base64_uploads, + creates, + deletes, + words_served, + words_synthesized, + ) = r + return { + "requests": requests, + "hits": hits, + "misses": misses, + "hit_rate": round(hits / requests, 4) if requests else None, + "bytes_served": bytes_served, + "synth_calls": synth_calls, + "base64_uploads": base64_uploads, + "creates": creates, + "deletes": deletes, + "words_served": words_served, + "words_synthesized": words_synthesized, + } + + return await self._run(_q) + + async def provider_metrics_summary( + self, from_date: str | None = None, to_date: str | None = None + ) -> dict: + """Per-provider daily rollup: {provider: {requests, hits, misses, + synth_calls, bytes_served, words_served, words_synthesized, hit_rate}} + (day-filtered).""" + clauses: list[str] = [] + args: list = [] + if from_date: + clauses.append("date >= ?") + args.append(from_date) + if to_date: + clauses.append("date <= ?") + args.append(to_date) + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + + def _q(conn: sqlite3.Connection) -> dict: + rows = conn.execute( + "SELECT provider, COALESCE(SUM(requests),0), COALESCE(SUM(hits),0), " + "COALESCE(SUM(misses),0), COALESCE(SUM(synth_calls),0), " + "COALESCE(SUM(bytes_served),0), COALESCE(SUM(words_served),0), " + "COALESCE(SUM(words_synthesized),0) " + f"FROM metrics_daily_provider{where} GROUP BY provider", + args, + ).fetchall() + out: dict[str, dict] = {} + for r in rows: + ( + prov, + requests, + hits, + misses, + synth_calls, + bytes_served, + words_served, + words_synthesized, + ) = r + out[prov] = { + "requests": requests, + "hits": hits, + "misses": misses, + "synth_calls": synth_calls, + "bytes_served": bytes_served, + "words_served": words_served, + "words_synthesized": words_synthesized, + "hit_rate": round(hits / requests, 4) if requests else None, + } + return out + + return await self._run(_q) + + async def claim_slack_summary(self, today: str) -> bool: + """Atomically claim today's daily-summary slot. Returns True if this + caller wins (first to post today); False if another worker already + claimed it. The claim key is the UTC date string, so it's once-per-day. + On a Slack send failure the caller should :meth:`release_slack_summary` + so a later tick retries. + + Safe across the N uvicorn workers via ``BEGIN IMMEDIATE``: the first + worker to commit the UPDATE wins (rowcount 1); concurrent callers then + see ``value == today`` and get rowcount 0. + """ + + def _c(conn: sqlite3.Connection) -> bool: + conn.execute("BEGIN IMMEDIATE") + try: + conn.execute( + "INSERT OR IGNORE INTO slack_state(key, value) " + "VALUES('summary_last_post', '')" + ) + cur = conn.execute( + "UPDATE slack_state SET value=? " + "WHERE key='summary_last_post' AND value None: + """Clear today's claim (e.g. after a Slack send failure) so a later tick + retries. Idempotent.""" + + def _r(conn: sqlite3.Connection) -> None: + conn.execute( + "UPDATE slack_state SET value='' WHERE key='summary_last_post'" + ) + + await self._run(_r) + + async def latency_summary( + self, from_date: str | None = None, to_date: str | None = None + ) -> dict: + """Per-kind latency {kind: {avg_us, p95_us, count}} (day-filtered). + p95 via ORDER BY + OFFSET (SQLite has no PERCENTILE).""" + kinds = ("ttfb", "synth", "cache_serve", "total") + + def _q(conn: sqlite3.Connection) -> dict: + out: dict[str, dict] = {} + for kind in kinds: + clauses = ["kind = ?"] + ca: list = [kind] + if from_date: + clauses.append("date >= ?") + ca.append(from_date) + if to_date: + clauses.append("date <= ?") + ca.append(to_date) + where = " WHERE " + " AND ".join(clauses) + cnt, avg = conn.execute( + f"SELECT COUNT(*), COALESCE(AVG(latency_us), 0) " + f"FROM latency_samples{where}", + ca, + ).fetchone() + if not cnt: + out[kind] = {"avg_us": None, "p95_us": None, "count": 0} + continue + offset = max(0, min(cnt - 1, int(cnt * 0.95))) + p95 = conn.execute( + f"SELECT latency_us FROM latency_samples{where} " + f"ORDER BY latency_us LIMIT 1 OFFSET ?", + [*ca, offset], + ).fetchone()[0] + out[kind] = {"avg_us": round(avg, 1), "p95_us": int(p95), "count": cnt} + return out + + return await self._run(_q) + + async def latency_summary_daily( + self, from_date: str | None = None, to_date: str | None = None + ) -> dict: + """Per-day, per-kind latency ``{date: {kind: {avg_us, p95_us, count}}}``. + p95 via ORDER BY + OFFSET (matches :meth:`latency_summary`); one ``_run`` + walks the distinct sampled dates in range. Powers the per-day ``latency`` + block on /stats/daily.""" + kinds = ("ttfb", "synth", "cache_serve", "total") + clauses: list[str] = [] + args: list = [] + if from_date: + clauses.append("date >= ?") + args.append(from_date) + if to_date: + clauses.append("date <= ?") + args.append(to_date) + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + + def _q(conn: sqlite3.Connection) -> dict: + out: dict[str, dict] = {} + dates = [ + r[0] + for r in conn.execute( + f"SELECT DISTINCT date FROM latency_samples{where} ORDER BY date", + args, + ) + ] + for d in dates: + present = { + kind: (cnt, avg) + for kind, cnt, avg in conn.execute( + "SELECT kind, COUNT(*), COALESCE(AVG(latency_us), 0) " + "FROM latency_samples WHERE date = ? GROUP BY kind", + (d,), + ) + } + d_out: dict[str, dict] = {} + for kind in kinds: + if kind not in present: + d_out[kind] = {"avg_us": None, "p95_us": None, "count": 0} + continue + cnt, avg = present[kind] + offset = max(0, min(cnt - 1, int(cnt * 0.95))) + p95 = conn.execute( + "SELECT latency_us FROM latency_samples " + "WHERE date = ? AND kind = ? ORDER BY latency_us LIMIT 1 OFFSET ?", + (d, kind, offset), + ).fetchone()[0] + d_out[kind] = { + "avg_us": round(avg, 1), + "p95_us": int(p95), + "count": cnt, + } + out[d] = d_out + return out + + return await self._run(_q) + + async def latency_summary_by_provider( + self, + from_date: str | None = None, + to_date: str | None = None, + provider: str | None = None, + ) -> dict: + """Per-provider x per-day x per-kind latency + ``{provider: {date: {kind: {avg_us, p95_us, count}}}}``. + + p95 via ORDER BY + OFFSET (matches :meth:`latency_summary_daily`); uses + idx_latency_provider_kind_date. Pre-migration rows have NULL provider and + are excluded (``provider IS NOT NULL`` when no filter is given).""" + kinds = ("ttfb", "synth", "cache_serve", "total") + clauses: list[str] = ["provider IS NOT NULL"] + args: list = [] + if provider is not None: + clauses.append("provider = ?") + args.append(provider) + if from_date: + clauses.append("date >= ?") + args.append(from_date) + if to_date: + clauses.append("date <= ?") + args.append(to_date) + base_where = " WHERE " + " AND ".join(clauses) + + def _q(conn: sqlite3.Connection) -> dict: + out: dict[str, dict] = {} + rows = conn.execute( + f"SELECT DISTINCT provider, date FROM latency_samples{base_where} " + f"ORDER BY provider, date", + args, + ).fetchall() + for prov, d in rows: + p_out = out.setdefault(prov, {}) + present = { + kind: (cnt, avg) + for kind, cnt, avg in conn.execute( + "SELECT kind, COUNT(*), COALESCE(AVG(latency_us), 0) " + "FROM latency_samples WHERE provider = ? AND date = ? GROUP BY kind", + (prov, d), + ) + } + d_out: dict[str, dict] = {} + for kind in kinds: + if kind not in present: + d_out[kind] = {"avg_us": None, "p95_us": None, "count": 0} + continue + cnt, avg = present[kind] + offset = max(0, min(cnt - 1, int(cnt * 0.95))) + p95 = conn.execute( + "SELECT latency_us FROM latency_samples " + "WHERE provider = ? AND date = ? AND kind = ? " + "ORDER BY latency_us LIMIT 1 OFFSET ?", + (prov, d, kind, offset), + ).fetchone()[0] + d_out[kind] = { + "avg_us": round(avg, 1), + "p95_us": int(p95), + "count": cnt, + } + p_out[d] = d_out + return out + + return await self._run(_q) + + async def prune_latency(self, retention_days: int) -> None: + """Delete latency_samples older than ``retention_days`` (called by the + periodic checkpoint loop to bound table growth).""" + cutoff = ( + datetime.now(timezone.utc).date() - timedelta(days=retention_days) + ).isoformat() + + def _w(conn: sqlite3.Connection) -> None: + conn.execute("DELETE FROM latency_samples WHERE date < ?", (cutoff,)) + + await self._run(_w) + + async def stats(self) -> dict: + """Cache snapshot from incrementally-maintained provider_totals (O(providers)).""" + + def _q(conn: sqlite3.Connection) -> dict: + rows = conn.execute( + "SELECT provider, entries, total_bytes, total_words FROM provider_totals" + ).fetchall() + by_provider = { + r[0]: {"entries": r[1], "total_bytes": r[2], "total_words": r[3]} + for r in rows + } + return { + "entries": sum(p["entries"] for p in by_provider.values()), + "total_bytes": sum(p["total_bytes"] for p in by_provider.values()), + "total_words": sum(p["total_words"] for p in by_provider.values()), + "by_provider": by_provider, + } + + return await self._run(_q) diff --git a/dragontts/pyproject.toml b/dragontts/pyproject.toml new file mode 100644 index 000000000..87c5617c9 --- /dev/null +++ b/dragontts/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "dragontts" +version = "0.1.0" +description = "DragonTTS — caching TTS proxy compatible with clairvoyance providers" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.34.0", + "httpx>=0.27.0", + "websockets>=13.0", + "pydantic>=2.7.0", + "pydantic-settings>=2.3.0", + "loguru>=0.7.0", + "pydub>=0.25.1", + "soundfile>=0.12.0", + "numpy>=1.26.0", + "google-cloud-texttospeech>=2.16.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "respx>=0.21.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +pythonpath = ["."] diff --git a/dragontts/uv.lock b/dragontts/uv.lock new file mode 100644 index 000000000..7bc96ecf5 --- /dev/null +++ b/dragontts/uv.lock @@ -0,0 +1,1304 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[[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.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[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 = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "dragontts" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "google-cloud-texttospeech" }, + { name = "httpx" }, + { name = "loguru" }, + { name = "numpy" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pydub" }, + { name = "soundfile" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "websockets" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "respx" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "google-cloud-texttospeech", specifier = ">=2.16.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "loguru", specifier = ">=0.7.0" }, + { name = "numpy", specifier = ">=1.26.0" }, + { name = "pydantic", specifier = ">=2.7.0" }, + { name = "pydantic-settings", specifier = ">=2.3.0" }, + { name = "pydub", specifier = ">=0.25.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21.0" }, + { name = "soundfile", specifier = ">=0.12.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, + { name = "websockets", specifier = ">=13.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "fastapi" +version = "0.138.0" +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/5b/58/ff455d9fe47c60abadb34b9e05a304b1f05f5ab8000ac01565156b6f5e43/fastapi-0.138.0.tar.gz", hash = "sha256:d445a4877636ad191e7053e08c9bf98cb921a6756776848400bb773d1740c061", size = 419240, upload-time = "2026-06-20T01:18:05.259Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/ff/8496d9847a5fedae775eb49460722d3efaa80487854273e9647ae876218c/fastapi-0.138.0-py3-none-any.whl", hash = "sha256:b6f54fd1bd72c80b0f899f172c61a600f6f7af9b43d4d772a018f35624048cb0", size = 126779, upload-time = "2026-06-20T01:18:03.483Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/22/155cadf1d49272a9cf48f3168c0f3874fa13397297e611a5ea00cd093880/google_api_core-2.31.0.tar.gz", hash = "sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2", size = 176492, upload-time = "2026-06-03T14:52:17.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/40/9bdbb60b03a332bd45acb8703da08bbc27d991d35286b62e42acc86d243a/google_api_core-2.31.0-py3-none-any.whl", hash = "sha256:ef79fb3784c71cbac89cbd03301ba0c8fb8ad2aa95d7f9204dd9628f7adf59ab", size = 173102, upload-time = "2026-06-03T14:51:26.729Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-auth" +version = "2.55.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/1c/70b23fc52b2bb3c70b379f3bd05c4a60ab3a873e30c6bd21c57e0154848a/google_auth-2.55.0.tar.gz", hash = "sha256:fcd3a130f575fa36403d38774af1c64a4fbfbca09215f0589d2372b5119697cb", size = 349379, upload-time = "2026-06-15T22:33:16.466Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/71/c0321dc6d63d99946da45f7c06299b934e4f7f7da5c4f14d101bcb39adf1/google_auth-2.55.0-py3-none-any.whl", hash = "sha256:a17cef9dedf98c4ebae2fb0c48c8f75952c877cbc2efe09f329ef16c2783d88a", size = 252400, upload-time = "2026-06-15T22:33:14.992Z" }, +] + +[[package]] +name = "google-cloud-texttospeech" +version = "2.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/fe/670610dce6852c8fbdd723101a2ffb497d196bc242cb04a1312339f49d67/google_cloud_texttospeech-2.36.0.tar.gz", hash = "sha256:6c605af7e4774c1bac99fcaaf4538f152b10bba7738a23f42184557f444dc6b7", size = 196558, upload-time = "2026-04-02T21:23:42.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/1a/0317eaa1ccd6e3645d570851574bb7c1a1cfa3504bb114b7211db59b154d/google_cloud_texttospeech-2.36.0-py3-none-any.whl", hash = "sha256:03f76162543e9d77ecbab823c1cc3728c42ef40547353bcfdbd9ac0e71cb8121", size = 200086, upload-time = "2026-04-02T21:23:13.444Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + +[[package]] +name = "grpcio" +version = "1.81.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/ea/1c2fa386b718ff493225e61cfc052ef400b4d6ffc54cbe261026432624b5/grpcio-1.81.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:d71d30f2d92f67d944631c523713934fee37292469e182ebcd2c1dd8a64ce53f", size = 6093112, upload-time = "2026-06-11T12:44:52.131Z" }, + { url = "https://files.pythonhosted.org/packages/2b/18/acf45fa8bd1bc5d7b0c2fd3dc4c209379fbd5bb396b440b68a83342226b7/grpcio-1.81.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b137f4bf3ada9dc44d411478decc6ff09a79ed30b306cd2abaa98408c3588137", size = 12074277, upload-time = "2026-06-11T12:44:55.354Z" }, + { url = "https://files.pythonhosted.org/packages/48/d7/ee86a60699b7db039f772a2c4a7e4facc7138984ff42c0130933a0063884/grpcio-1.81.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a3acb384427816dd5d470f47e62137b87f74da694faa8a50147012cf40df276a", size = 6640348, upload-time = "2026-06-11T12:44:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/26/ee/d2de5e47378ffc207d476c230fea3be4d2601edbce9995f4fe45535d4896/grpcio-1.81.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f9a0ebbe45c29b5e5866593c12b78bd9035f0f0f0d4bc8361680cd580d99db49", size = 7331842, upload-time = "2026-06-11T12:45:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/23/d6/abeda5c2b896a0b341584fe5ac411bbf72e197a9a374c355fb90965e08d2/grpcio-1.81.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a37165cc80b1a368384b383e63a4c38116a10467ae44c904d2d7468c4470ec2", size = 6842229, upload-time = "2026-06-11T12:45:04.76Z" }, + { url = "https://files.pythonhosted.org/packages/10/1c/1f0da7d590b4aeee006826ba568d0e419ca14b23e18f901a3da3e9fba613/grpcio-1.81.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6282caffb41ec326d4cb67ca9cf53b739d1b2f975a2acb498c7418e9f7d9a416", size = 7446096, upload-time = "2026-06-11T12:45:07.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/5c505d508f7c887aa7982d21443a4126597c80d34b0bcf40f9cec576d7f3/grpcio-1.81.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a35009284d0d3d5c2c9601c164a911b8b4331608d98a9a66d47d97bb2f522b70", size = 8445238, upload-time = "2026-06-11T12:45:10.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b2/524847365122ee509ca17bcc4e092198b700e94af7bfd5bb5e6dd9f3ee66/grpcio-1.81.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1b22c80559854b789a01fd89e8929b3798a156c0829b5282a8939f33ad4115ad", size = 7873989, upload-time = "2026-06-11T12:45:13.102Z" }, + { url = "https://files.pythonhosted.org/packages/18/fa/07c037c50b006909d1d13a5848774f8aa7b242f70dc03a035c64eea0e6db/grpcio-1.81.1-cp311-cp311-win32.whl", hash = "sha256:428bec0161b48d8cf583c068591bc0016d0d9cfff52462b72b3884861ea768c5", size = 4202223, upload-time = "2026-06-11T12:45:16.166Z" }, + { url = "https://files.pythonhosted.org/packages/41/ed/6bff15376920942fac6b95b9802752b837437172c9e8fc2d3170546b89cc/grpcio-1.81.1-cp311-cp311-win_amd64.whl", hash = "sha256:30e825f6848d9f18bba350ed6c75c1b02a0b5184474a31db9a32b1fa66fd8c79", size = 4941303, upload-time = "2026-06-11T12:45:18.724Z" }, + { url = "https://files.pythonhosted.org/packages/85/07/9a979c81738863a738dc23d65177056e71fbb2db817740ed870b33434e7a/grpcio-1.81.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:8b39472beafc0bdcafc4c8c73ad082ebfdb449d566897a61e7acb4fa88089115", size = 6053264, upload-time = "2026-06-11T12:45:21.017Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/539706ca0d3bd40dbad583dc56fd883da941f37556b629132da5762781b9/grpcio-1.81.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:12b7524c88d4026d3dcb7b0ebe16b6714f3b4af402ddd0f0639ab064a00c87c3", size = 12052560, upload-time = "2026-06-11T12:45:23.652Z" }, + { url = "https://files.pythonhosted.org/packages/e0/44/f257b7e0bd69c93b06c6cb8ac8d1b901ccb42bedabd83c1a4c77a71f8810/grpcio-1.81.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1e123f9b37edb8375fd74130d1f69c944bbf0a7b06761ae7211154b8759e94d2", size = 6595983, upload-time = "2026-06-11T12:45:26.963Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f3/19782aa04c960968bef8c5539329d8e3bbc3364e2e46d19eb5e5cc5e43b7/grpcio-1.81.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2c2e2ae6867c2966b8daccc836d54a13218e0007e9a490aeb81dd05be64d22d7", size = 7303455, upload-time = "2026-06-11T12:45:29.707Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8c/dea020b6d91508cd84463917a63149ec196ee7db505d032ae43fcb3303b9/grpcio-1.81.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:766bc7c9a9c340342f4c864ccbda8e78111e4751f13b895812b9c148fb79e9d0", size = 6809167, upload-time = "2026-06-11T12:45:32.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/3030dd940408083bd32cd95d634777a71605ade4887154d93e8a89244946/grpcio-1.81.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b259a04a737cb3496be0901328eb8b7552ed8df4865d8c8f1cf1bffcfc0776a3", size = 7412536, upload-time = "2026-06-11T12:45:35.403Z" }, + { url = "https://files.pythonhosted.org/packages/e0/dd/1172a9e42b168edcafefad6115346ef619a3fc02158bb170e66ced24bcdd/grpcio-1.81.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:85b10a45b8993d195c4f3ff57025b8d1e11834909ee475c403bfa60cb4caefaf", size = 8408276, upload-time = "2026-06-11T12:45:37.78Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/71437c7f3596e5246155c515852795a85a1a8d228190212432b13b97a95d/grpcio-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8ea1936c26b99999b27479853039a7f34713f56c49375ad52b38535ec93a796c", size = 7849660, upload-time = "2026-06-11T12:45:40.627Z" }, + { url = "https://files.pythonhosted.org/packages/65/40/7debc0da45d2efebafb82da75644be347497fe4ee250514b8cd3b86ae8bf/grpcio-1.81.1-cp312-cp312-win32.whl", hash = "sha256:a185a04039df6cae8648bc8ab6d6fde7bf94f7188ecf7828e76ac52eef1e41d6", size = 4185819, upload-time = "2026-06-11T12:45:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8fe3ba5ed462067774ebc1f9c7f26aa7ebcc280ddd476be107153de1339e/grpcio-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ad74f8bb1a18963914c5452d289422830b39459e8776ebbcd207be1fbfb1d94", size = 4930461, upload-time = "2026-06-11T12:45:45.775Z" }, + { url = "https://files.pythonhosted.org/packages/7a/42/dcc2e4b600538ef18327c0839d56b7d3c3812337c5d710df5877dbb39b1e/grpcio-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b10e1ff4756ed27d5a29d7fc79cfce7ef1ff56ad20025b89bac7cf79e09abbbe", size = 6054466, upload-time = "2026-06-11T12:45:48.43Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4a/a36e03210183a8a7d4c80c3936acee679f4bd77d5861f369db47b2cc5f05/grpcio-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e", size = 12048795, upload-time = "2026-06-11T12:45:54.011Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/d68e30b29098f63beab6fe501100fe82674ff142b32c672532da86a99b3a/grpcio-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0", size = 6599094, upload-time = "2026-06-11T12:45:57.799Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/e837954d279754f638a11cca5dcf6b24a005efb398984cefaf7735945a54/grpcio-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14", size = 7307182, upload-time = "2026-06-11T12:46:00.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/b47957057e729adc6cdf519a47f8be2562b7140e280f1418443eb4022192/grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae", size = 6810962, upload-time = "2026-06-11T12:46:03.312Z" }, + { url = "https://files.pythonhosted.org/packages/40/26/569868e364e05b19ec8f969da53d230bcd89c962cd198f7c29943155c4d3/grpcio-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5", size = 7415698, upload-time = "2026-06-11T12:46:06.005Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/5440a0582cb5653fc42a6e262eeb22700943313f8076f9dc927491b20a59/grpcio-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb", size = 8407779, upload-time = "2026-06-11T12:46:08.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/aa/66fe9f39871d766987d869a03ee0842a026f499c7b1e62decb9e78a8088e/grpcio-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7", size = 7844521, upload-time = "2026-06-11T12:46:12.171Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9e/69bb7194861bcd28fb3193261d4f9c3831b4446993f002cf59068943e7ab/grpcio-1.81.1-cp313-cp313-win32.whl", hash = "sha256:78e29211f26da2fdd0e9c6d2b79f489476140cf7029b6a64808ade7ca4156a42", size = 4182786, upload-time = "2026-06-11T12:46:15.192Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/3da8bb0d637feccdc3e1e419bb511ce93651ce7d54164f95de22cc0b8b34/grpcio-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:edb59506291b647a30884b1d51a599d605f40b20af4a7dc3d33786a47a31de60", size = 4928648, upload-time = "2026-06-11T12:46:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/b6/58/19414622b1bf6981bc9c05a365bd548e71876c89000083b3af489251e9c0/grpcio-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:506f48f2f9c29b143fca3dad7b0d518c188b6c9648c75a2ae6e2d9f2c13a060b", size = 6055336, upload-time = "2026-06-11T12:46:20.557Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/2ec88adb92b0eba970dd0e0e7dd086341daa3c75eba4f735f9e44bf684b0/grpcio-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d865db4a6318e1c1bea83292e0ed231090538fc4ca45425b0f0480eb338bbc6e", size = 12056279, upload-time = "2026-06-11T12:46:24.255Z" }, + { url = "https://files.pythonhosted.org/packages/41/36/e8c5f8c6ec71de73733695ebc809e98b178b534ec6d8eaa31a7ebab4ad4c/grpcio-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2aa72e3ce1770317ef534f63d397b55e130725f5149bd36077c3b539019db27", size = 6608225, upload-time = "2026-06-11T12:46:27.601Z" }, + { url = "https://files.pythonhosted.org/packages/30/22/96fc577a845ab093326d9ab1adb874bd4936c8cf98ac8ed2f3db13a0a2fb/grpcio-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0490c30c261eded63f3f354979f9dc4502a9fb944cccb60cd9dc85f5a7349854", size = 7306576, upload-time = "2026-06-11T12:46:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/76/7b/61dab5d5969f28d97fb1009cead1df0a5cd987d3315e1b37f18a4449f8bc/grpcio-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:410482da976329fe5f4067270401b12cf2bd552ff8020f054ecfaddb5475f9d6", size = 6812165, upload-time = "2026-06-11T12:46:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/6e501929d4f5f96462fd82fd9f0f06e5f9612207582b862868d68757b27d/grpcio-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3657301562ac3cb8018d30d0d3ebfa39932239f7b5703422057ef14b69949f5", size = 7422962, upload-time = "2026-06-11T12:46:36.511Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7e/f2157589e66daa78ebb3165942d05a08bdea93b9d11c2bc1e172aef89685/grpcio-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24c8e57504c8f45b237e40b99262d181071e5099a07053695b75d97bb53053a0", size = 8408176, upload-time = "2026-06-11T12:46:39.803Z" }, + { url = "https://files.pythonhosted.org/packages/da/df/c6717fef716e00d235ffb96123baf6dce76d6004f6233fa767c502861460/grpcio-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b427c19380991a4eaab2f6144b64b99b412043314c6bf4ab544f97bb31ee4190", size = 7846681, upload-time = "2026-06-11T12:46:43.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/84/3502e9f210a6a5c4438c8aca3f88edd2e04f6a27f3d41b26cf0a0024b096/grpcio-1.81.1-cp314-cp314-win32.whl", hash = "sha256:61233fe8951e5c85dff81c2458b6528624760166946b5b47ea150a589168411f", size = 4264615, upload-time = "2026-06-11T12:46:45.741Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/4af731ff7492c68a96e4c71bfd0f4590acde92b31c6fe4894e6465c10ff6/grpcio-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821", size = 5070275, upload-time = "2026-06-11T12:46:48.486Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.81.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/26/0aa9168c87882381fd810d140c279a2490ed6aee655f0515d6f56c5ca404/grpcio_status-1.81.1.tar.gz", hash = "sha256:9389a03e746017b10f0630c064289201458f3ce01f5d7ef4b0bebc1ef6cf82ad", size = 13923, upload-time = "2026-06-11T12:58:48.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/5e/5abfec5f7e89d3b7993d57cfb025ca5f968a2c18656d7fcda2b6919440b9/grpcio_status-1.81.1-py3-none-any.whl", hash = "sha256:08072fa9995f4a95c647fc6f4f85e2411573d00087bcabdf30f260114338f232", size = 14638, upload-time = "2026-06-11T12:58:31.982Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8", size = 50410, upload-time = "2026-05-07T08:03:31.962Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pydub" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "respx" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, +] + +[[package]] +name = "soundfile" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "numpy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/db/949331952a6fb1c5b12e9de80fd08747966c2039d1a61db4764fbd3981c2/soundfile-0.14.0.tar.gz", hash = "sha256:ba1c1a2d618bca5c406647c83b89f07cc8810fa506a50622a6993ba130c1de11", size = 47842, upload-time = "2026-06-06T08:58:47.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/d1/5e338af9ca6ed0786cd5bb03f6d60de1c325728c1189014f3b59aae7403c/soundfile-0.14.0-py2.py3-none-any.whl", hash = "sha256:8ba81ae3a89fd5ab3bef8a8eb481fbbe794e806309675a89b4df48b8d31908a8", size = 26799, upload-time = "2026-06-06T08:58:33.269Z" }, + { url = "https://files.pythonhosted.org/packages/7e/72/c6b21e58d3113596e7e8de0a08d6f1d95173492cfbca0a4db14148cbba2a/soundfile-0.14.0-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:19be05428da76ed61a4cad29b8e4bcf43a3e5c100089d2ec81dc961eed1b0dd4", size = 1144568, upload-time = "2026-06-06T08:58:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/dfdd6f8c748988427119f75eb860a3cedd858d1aea1fe28f39ad8559ef22/soundfile-0.14.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:d828d35a059626da52f1415b5faee610aeab393319cb3fc4a9aef47b619fc14c", size = 1103726, upload-time = "2026-06-06T08:58:37.948Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f8/fc39fad6f879633461d27394cd1ddaf1f769ffa0597dca35872f51b16461/soundfile-0.14.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e85724a90bc99a6e8062c0b4ddf725f53b2a3b70afd4da875e9d2cfc4e92f377", size = 1238050, upload-time = "2026-06-06T08:58:39.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a2/70fd4432b924684c372df8b0a45708c36c057ef3596c9eb53e0a806b980b/soundfile-0.14.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:1e38bac1853412871318e82a1ba69a8be677619b56025bbfcccdb41b6cafe82d", size = 1315963, upload-time = "2026-06-06T08:58:41.716Z" }, + { url = "https://files.pythonhosted.org/packages/d9/34/c9e80783d83eab739a9531fdee03675d53e0bf1b2ccb4bb3af5844675046/soundfile-0.14.0-py2.py3-none-win32.whl", hash = "sha256:0a6ae43c50c71b4e020cc55382925cb89451c1ed1a0c3d0f5d802da269226849", size = 902199, upload-time = "2026-06-06T08:58:43.289Z" }, + { url = "https://files.pythonhosted.org/packages/ed/97/b39c18ac1df45e755ca22b8b00e872929da5d107998a207a5e4ac831bfda/soundfile-0.14.0-py2.py3-none-win_amd64.whl", hash = "sha256:299491d3499460fb1b74bb4bd78b57ffc2d243a5fafa7b6ec1b264875c78453e", size = 1021480, upload-time = "2026-06-06T08:58:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/f4/83/55c65e61cf457805ce2ec157c1c6ae17715d0851aa2374422de0538838ca/soundfile-0.14.0-py2.py3-none-win_arm64.whl", hash = "sha256:e090704718e124e7c844695236f1fce8d18a5e761eaf7c82dfcd124620805f98", size = 888858, upload-time = "2026-06-06T08:58:46.593Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +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 = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[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/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.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[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/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 = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] diff --git a/pyproject.toml b/pyproject.toml index cc096e9c1..102dac4eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,9 @@ line_length = 88 combine_as_imports = true [tool.pyrefly] -project-excludes = [] +# dragontts/ is a self-contained sub-service (own pyproject/venv, ported from a +# non-pyrefly repo) — exclude it from clairvoyance's type-check. +project-excludes = ["dragontts"] [dependency-groups] dev = [