diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b57f7b7..08aac9c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,10 +42,19 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently. ### Fixed - Backend journal, dictation reset, voice-catalog, and crash-notification failures are now visible and retryable instead of being silently ignored. (#1459) +- YouTube imports that require a signed-in session can now use an explicitly selected `cookies.txt` export for one import; VoiceStudio never reads browser cookies silently and makes two best-effort attempts to delete its temporary copy. (#1429, #1432) — thanks @dongqing1968-sudo and @phamvandu9595-tech! +- First-run source builds no longer stop after uv was successfully downloaded just because its installer failed during a later shell-profile step; app-private uv installs no longer touch shell profiles at all. (#1438) — thanks @AdrianoCahete! +- Model files damaged by an interrupted download now repair themselves instead of failing every generation, including invalid `config.json` files and corrupt weight headers. — thanks @overrunau and @zherunh! (#1406, #1437) +- ROCm Docker now installs and starts the backend with the same Python whose AMD torch build was validated, instead of launching a second CUDA-only environment and silently running on CPU. (#1274) — thanks @simmessa and @spicchio72! +- An error whose text merely contained the digits 401 — a file path, a byte count, a job id — no longer tells you to fix your Hugging Face token. (#1427) +- Custom MLX model IDs and saved voice instructions are now validated in bounded time, so malformed input cannot stall the backend. (#1446) +- Streaming and provider failures now return stable recovery guidance without exposing exception details. (#1462) +- Server-mode settings mutations require the admin API key, while host destinations and executable paths can only be selected through the native desktop app. (#1448) - Automatic model-mirror checks now reject untrusted URLs before opening a network connection. (#1447) - Sidecar engines no longer break when a library they load prints to the console. Those bytes landed in the middle of the engine's data stream, failing the generation and leaving the connection scrambled for every request after it. (#1428) — thanks @1335-Group! - A generation abandoned while stuck on an internal lock now says so, instead of blaming your hardware and suggesting shorter text. Nothing had been computed, so none of that advice applied. (#1416, #1419) - A machine with a GPU that ends up on CPU now says why — a missing device node, a permissions problem, a card newer than the installed ROCm, an `HSA_OVERRIDE_GFX_VERSION` that is doing more harm than good, or an NVIDIA driver the container can't reach each read differently. Before, all of them looked identical to having no GPU at all. (#1274, #1228) +- The first generation on an engine that still has to install itself no longer gives up part-way. The install reports progress now, so the generation waits for it instead of hitting its own five-minute limit. (#1414) - A slow machine is no longer told its IndexTTS-2 install isn't there. The check that confirms an engine's virtualenv gave up after 10 seconds and counted that as a broken install, so a cold first run 500'd; it now waits longer and treats slow as unproven, not broken. (#1414) — thanks @OracleNightmare! - A broken Python environment now says so, instead of blaming the app's own install. A missing or mismatched torch/transformers surfaced as "omnivoice not importable" and sent people reinstalling the wrong thing. (#1415) - A model that fails to load at startup no longer leaves the app looking healthy while producing nothing — the failure and its remedy now show up in the model status. (#1415) diff --git a/backend/api/dependencies.py b/backend/api/dependencies.py index 83bf17a5..2c8bc394 100644 --- a/backend/api/dependencies.py +++ b/backend/api/dependencies.py @@ -186,6 +186,45 @@ def require_loopback(request: Request) -> None: raise HTTPException(status_code=403, detail="loopback origin required") +def require_admin(request: Request) -> None: + """Gate RCE/filesystem-capable admin routers. + + Desktop callers keep the loopback-only contract. Docker cannot reliably + observe the host operator as loopback, so authenticated remote admin stays + available there, but every state-changing request must present the long API + key. An unconfigured server must never expose executable-path or filesystem + settings to every client that can reach its published port. + + Read-only requests retain the bare-Docker bootstrap behaviour until an API + key is configured. Share PINs and trusted CIDRs are consumption credentials; + neither authorizes this gate. + """ + host = request.client.host if request.client else None + if is_loopback(host): + return + if _server_mode(): + method = str(getattr(request, "method", "GET")).upper() + read_only = method in {"GET", "HEAD", "OPTIONS"} + if read_only and not os.environ.get("OMNIVOICE_API_KEY", "").strip(): + return + if _request_presents_admin_credential(request): + return + raise HTTPException(status_code=403, detail="loopback origin or admin API key required") + + +def require_desktop(request: Request) -> None: + """Gate capabilities that may select or execute host filesystem paths. + + An API key authorizes remote administration, not access to the desktop + shell's native file-picker boundary. These capabilities therefore remain + strictly loopback-only even when server mode is enabled. + """ + host = request.client.host if request.client else None + if is_loopback(host): + return + raise HTTPException(status_code=403, detail="desktop origin required") + + def require_local(request: Request) -> None: """Reject any request whose client.host is not loopback OR on a configured trusted network. The consumption-tier companion to :func:`require_loopback`: diff --git a/backend/api/routers/dub_core.py b/backend/api/routers/dub_core.py index 5198bacd..576d5e24 100644 --- a/backend/api/routers/dub_core.py +++ b/backend/api/routers/dub_core.py @@ -4,9 +4,12 @@ import asyncio import logging import shutil import subprocess +import tempfile +from urllib.parse import urlsplit import soundfile as sf import torch from typing import Optional +from fastapi import Request from fastapi import APIRouter, File, Form, UploadFile, HTTPException from fastapi.responses import FileResponse, StreamingResponse, JSONResponse @@ -40,6 +43,67 @@ from services import dub_pipeline router = APIRouter() logger = logging.getLogger("omnivoice.api") +_MAX_COOKIE_EXPORT_BYTES = 1024 * 1024 + + +def _cookie_transport_allowed( + scheme: str, client_host: str | None, origin: str | None +) -> bool: + """Credentials may cross HTTP only from a local UI to a loopback peer.""" + from api.dependencies import is_local_host + + if scheme == "https": + return True + try: + origin_host = urlsplit(origin or "").hostname or "" + except ValueError: + return False + return is_local_host(client_host or "") and ( + is_local_host(origin_host) or origin_host == "tauri.localhost" + ) + + +def _stage_cookie_export(contents: str | None) -> str | None: + """Write an explicitly supplied cookies.txt export to a private temp file.""" + if contents is None: + return None + cookie_bytes = contents.encode("utf-8") + if len(cookie_bytes) > _MAX_COOKIE_EXPORT_BYTES: + raise HTTPException( + status_code=400, + detail=( + "Cookie file is too large (maximum 1 MB). Export cookies in " + "Netscape cookies.txt format and try again." + ), + ) + first_line = contents.lstrip("\ufeff\r\n ").splitlines()[0] if contents.strip() else "" + if not first_line.startswith(("# Netscape HTTP Cookie File", "# HTTP Cookie File")): + raise HTTPException( + status_code=400, + detail=( + "This is not a Netscape cookies.txt export. Export cookies as " + "cookies.txt from your browser, then choose that file." + ), + ) + fd, cookie_path = tempfile.mkstemp( + prefix="voicestudio-ytdlp-", suffix=".cookies.txt", + ) + try: + os.chmod(cookie_path, 0o600) + with os.fdopen(fd, "wb") as cookie_handle: + cookie_handle.write(cookie_bytes) + except Exception: + try: + os.close(fd) + except OSError: + pass # Best effort: fdopen may already have consumed/closed the descriptor. + try: + os.unlink(cookie_path) + except OSError: + pass # Best effort: preserve the original staging error. + raise + return cookie_path + # ── Legacy-name aliases to services/dub_pipeline.py ──────────────────────── # Phase 2.4 moved the business logic into a service. Other routers @@ -395,7 +459,7 @@ async def dub_upload( @router.post("/dub/ingest-url") -async def dub_ingest_url(req: DubIngestUrlRequest): +async def dub_ingest_url(req: DubIngestUrlRequest, request: Request): """Ingest a remote video URL via yt-dlp. Queues background prep task. Returns 202 immediately with {job_id, task_id}. All work (download, @@ -424,7 +488,17 @@ async def dub_ingest_url(req: DubIngestUrlRequest): status_code=400, detail="Invalid job_id. Must be alphanumeric + hyphens/underscores only, ≤64 chars. Generate a fresh job_id or omit it to auto-create one.", ) + if req.cookie_file and not _cookie_transport_allowed( + request.url.scheme, + request.client.host if request.client else None, + request.headers.get("origin"), + ): + raise HTTPException( + status_code=403, + detail="Cookie exports require HTTPS or the local desktop app.", + ) os.makedirs(job_dir, exist_ok=True) + cookie_path = _stage_cookie_export(req.cookie_file) task_id = f"prep_{job_id}" source = { @@ -432,12 +506,21 @@ async def dub_ingest_url(req: DubIngestUrlRequest): "url": url, "fetch_subs": bool(req.fetch_subs), "sub_langs": req.sub_langs or None, + "cookie_file": cookie_path, } - await task_manager.add_task( - task_id, "prep", - _ingest_gen, job_id, job_dir, - source, None, - ) + try: + await task_manager.add_task( + task_id, "prep", + _ingest_gen, job_id, job_dir, + source, None, + ) + except Exception: + if cookie_path: + try: + os.unlink(cookie_path) + except OSError: + pass # Best effort: do not hide the task-enqueue failure. + raise return JSONResponse( status_code=202, content={"job_id": job_id, "task_id": task_id, "filename": ""}, @@ -734,7 +817,7 @@ async def dub_transcribe_stream( preflight_error = asr_model_missing_detail(e.payload) preflight_payload = e.payload except Exception as e: - logger.exception("transcribe preflight: ASR load failed (job=%r)", job_id) + logger.error("Transcription preflight ASR load failed") from core.failure import build_failure f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False) preflight_error = "ASR backend initialization failed: " + f["reason"] + ( @@ -765,9 +848,10 @@ async def dub_transcribe_stream( try: audio_np, sr = await loop.run_in_executor(_cpu_pool, _load) - except Exception as e: + except Exception: # Terminal error → always emit `done` (see preflight note, #578). - yield _sse_event("error", {"detail": f"audio load failed: {e}", "retryable": True}) + from core.public_errors import stream_failure + yield _sse_event("error", stream_failure("transcription_failed")) yield _sse_event("done", {}) return @@ -867,9 +951,16 @@ async def dub_transcribe_stream( continue turns.append({"start": s0 + offset, "end": s1 + offset, "speaker": spk}) return {"chunks": shifted, "language": r.get("language"), "speaker_turns": turns} - except Exception as e: - logger.exception("chunk transcribe failed (backend=%s)", _asr_backend.id) - return {"chunks": [], "language": None, "error": str(e)} + except Exception: + logger.error("Chunk transcription failed (backend=%s)", _asr_backend.id) + from core.public_errors import stream_failure + failure = stream_failure("transcription_failed") + return { + "chunks": [], + "language": None, + "error": failure["detail"], + "error_code": failure["code"], + } # Retry a failed/timed-out chunk once on a fresh pool before giving # up. Otherwise a transient wedge on the FIRST chunk (whisperx often @@ -900,7 +991,7 @@ async def dub_transcribe_stream( yield _sse_event("ping", {}) try: part = task.result() - except ASRTimeoutError as e: + except ASRTimeoutError: # The guard already reset the pool; keep the actionable # message (it names the durable fixes, and — after repeated # timeouts — the crash-isolated engine escape hatch). @@ -910,7 +1001,14 @@ async def dub_transcribe_stream( i + 1, chunks_n, transcribe_timeout_s, _attempt, _CHUNK_TRANSCRIBE_ATTEMPTS, job_id, ) - part = {"chunks": [], "language": None, "error": str(e)} + from core.public_errors import stream_failure + failure = stream_failure("transcription_timeout") + part = { + "chunks": [], + "language": None, + "error": failure["detail"], + "error_code": failure["code"], + } # Success → keep it. Failure/timeout → retry once on a fresh # worker (the internal _transcribe_chunk except returns an # error-part; the timeout path already reset the pool). @@ -964,6 +1062,7 @@ async def dub_transcribe_stream( "segments": chunk_segs, "progress": (i + 1) / chunks_n, "error": part.get("error"), + "error_code": part.get("error_code"), }) if job.get("aborted"): @@ -1447,12 +1546,10 @@ async def dub_transcribe_stream( try: async for ev in _gen_body(): yield ev - except Exception as e: # noqa: BLE001 — last-resort stream finalizer - logger.exception("transcribe stream crashed (job=%r)", job_id) - from core.failure import build_failure - f = build_failure(e, stage="transcribe", include_diagnostic=False) - detail = f["reason"] + (f" — {f['hint']}" if f.get("hint") else "") - yield _sse_event("error", {"detail": detail, "retryable": True}) + except Exception: # noqa: BLE001 — last-resort stream finalizer + logger.error("Transcription stream failed unexpectedly") + from core.public_errors import stream_failure + yield _sse_event("error", stream_failure("transcription_failed")) yield _sse_event("done", {}) finally: # Last-resort VRAM release (see _loaded_asr above): covers crashes, diff --git a/backend/api/routers/engines.py b/backend/api/routers/engines.py index 774b7f84..8da81e5c 100644 --- a/backend/api/routers/engines.py +++ b/backend/api/routers/engines.py @@ -16,11 +16,12 @@ Environment variables (`OMNIVOICE_TTS_BACKEND`, `OMNIVOICE_ASR_BACKEND`, a backend without Settings silently undoing it. """ import os -import re import threading from time import perf_counter from fastapi import APIRouter, Depends, HTTPException +from huggingface_hub import utils as hf_utils +from huggingface_hub.errors import HFValidationError from pydantic import BaseModel from api.dependencies import require_loopback @@ -37,6 +38,16 @@ _FAMILIES = { "llm": (llm_backend, "llm_backend"), } +def _is_hf_repo_id(value: str) -> bool: + """Validate the route's ``owner/repo`` contract in bounded time.""" + if not isinstance(value, str) or len(value) > 96 or value.count("/") != 1: + return False + try: + hf_utils.validate_repo_id(value) + except (HFValidationError, TypeError): + return False + return True + @router.get("/engines") def list_all_engines(): @@ -579,11 +590,11 @@ def select_engine(req: SelectEngineRequest): # Anything else (typo'd key, malformed id) is rejected outright # rather than silently persisted as a "custom repo" that then fails # to resolve at load time. - if req.model_id not in known_keys and not re.fullmatch(r"[\w.-]+/[\w.-]+", req.model_id): + if req.model_id not in known_keys and not _is_hf_repo_id(req.model_id): raise HTTPException( 400, - f"Unknown mlx-audio model: {req.model_id!r}. Expected one of " - f"{sorted(known_keys)} or a HF repo id like 'owner/name'.", + "Unknown mlx-audio model. Expected a curated model key or a " + "Hugging Face repo ID like 'owner/name'.", ) prefs.set_("mlx_audio_model_id", req.model_id) prefs.set_(pref_key, req.backend_id) diff --git a/backend/api/routers/generation.py b/backend/api/routers/generation.py index d6346f97..3b7f5d82 100644 --- a/backend/api/routers/generation.py +++ b/backend/api/routers/generation.py @@ -1538,17 +1538,19 @@ async def generate_speech( # In-band error frame carries the machine-readable retryable # marker (#1190) — an NDJSON consumer can back off instead of # guessing from the prose. - logger.error("Streaming generate timed out: %s", e) - yield _line({ - "type": "error", "detail": str(e), "retryable": True, - "retry_after": getattr(e, "retry_after", 30), - }) - except ValueError as e: - logger.error("Streaming generate validation failed: %s", e) - yield _line({"type": "error", "detail": str(e)}) - except Exception as e: - logger.error("Streaming generate failed: %s\n%s", e, traceback.format_exc()) - yield _line({"type": "error", "detail": _safe_exc_text(e)}) + logger.error("Streaming generation capacity unavailable") + from core.public_errors import stream_failure + failure = stream_failure("generation_busy") + failure["retry_after"] = getattr(e, "retry_after", 30) + yield _line({"type": "error", **failure}) + except ValueError: + logger.error("Streaming generation request rejected") + from core.public_errors import stream_failure + yield _line({"type": "error", **stream_failure("invalid_request")}) + except Exception: + logger.error("Streaming generation failed unexpectedly") + from core.public_errors import stream_failure + yield _line({"type": "error", **stream_failure("generation_failed")}) finally: # Ownership of the temp reference clip moves to this generator # in stream mode (the route returns before rendering starts). diff --git a/backend/api/routers/media_tools.py b/backend/api/routers/media_tools.py index 95a93e38..770cd865 100644 --- a/backend/api/routers/media_tools.py +++ b/backend/api/routers/media_tools.py @@ -19,7 +19,7 @@ router = APIRouter(dependencies=[Depends(require_loopback)]) class CustomPathRequest(BaseModel): - path: str + authorization: str def _svc(): @@ -61,8 +61,13 @@ def media_tools_ytdlp_restore(): @router.post("/media-tools/{tool}/custom-path") def media_tools_custom_path(tool: str, body: CustomPathRequest): + from core.path_authorization import PathAuthorizationError, consume + try: - return _svc().set_custom_path(tool, body.path) + path = consume(body.authorization, tool) + return _svc().set_custom_path(tool, path) + except PathAuthorizationError as e: + raise HTTPException(status_code=403, detail=str(e)) from e except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/api/routers/settings.py b/backend/api/routers/settings.py index b4394f50..10290140 100644 --- a/backend/api/routers/settings.py +++ b/backend/api/routers/settings.py @@ -1,11 +1,10 @@ """Settings API — HF token save/clear/state endpoints (Phase 1 AUTH-03 backend half). These endpoints are the backend half of the Wave 2 Settings → API Keys -panel. Threat T-01-03 mitigation: every write endpoint is gated by the -router-level `require_loopback` dep, so non-loopback origins get 403 -before the handler runs. Reads are loopback-gated too — the masked -token preview is useful telemetry that we still don't want exposed on -the LAN. +panel. Threat T-01-03 mitigation: the router-level `require_admin` dependency +keeps desktop callers loopback-only and requires the long API key for every +remote server-mode mutation. Read-only bare-Docker discovery remains available +until an API key is configured; once configured, reads require it too. The state endpoint duplicates `/system/hf-token/state` (which lives on `system.py` for legacy-router compatibility); both return the same shape. @@ -20,14 +19,14 @@ from dataclasses import asdict from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field -from api.dependencies import require_loopback +from api.dependencies import require_admin logger = logging.getLogger("omnivoice.api.settings") router = APIRouter( prefix="/api/settings", tags=["settings"], - dependencies=[Depends(require_loopback)], + dependencies=[Depends(require_admin)], ) @@ -445,42 +444,20 @@ def test_llm_provider(provider_id: str): "reply": reply[:80], "latency_ms": int((_time.monotonic() - t0) * 1000), } - except Exception as e: # noqa: BLE001 — surface a clean, scrubbed error to the UI + except Exception as e: # noqa: BLE001 — classify without exposing diagnostics kind = _classify_llm_error(e) - detail = _scrub_llm_detail(e, api_key) - # A 404 from a LOCAL server is almost never a wrong URL — the request - # reached it — it is a model name the server does not have loaded. The - # generic "check the model name and Base URL path" sends the user to - # audit a URL that works. Ask what IS loaded and say so (#1332). + from core.public_errors import provider_failure + failure = provider_failure(kind) + # A successful local catalog probe proves the cached model is stale. + # Invalidate it, but never include catalog or exception text in the + # response: both are controlled by the provider. if kind == "not_found" and p.local: available = _local_models(base_url, api_key) - asked = llm_providers.resolve_model(p) - if available: - detail = ( - f"{p.display_name} is running, but has no model named " - f"{asked!r}. Loaded right now: {', '.join(available[:10])}" - + (" …" if len(available) > 10 else "") - + ". Pick one in the Model field above." - ) - # The cached discovery, if any, produced a name this server - # rejects — most likely the user swapped the loaded model - # inside the local app. Drop it so the next attempt re-asks - # rather than repeating the same 404 until the TTL expires. + if available is not None: llm_providers.forget_discovered_models(p.id) - elif available == []: - detail = ( - f"{p.display_name} is running, but reports no loaded models, " - f"so {asked!r} cannot be served. Load a model in " - f"{p.display_name} first, then test again." - ) - llm_providers.forget_discovered_models(p.id) - # available is None: the model listing itself failed, so nothing - # here is established. Keep the generic 404 text rather than - # inventing a diagnosis the lookup did not support. return { "ok": False, - "kind": kind, - "detail": detail, + **failure, "latency_ms": int((_time.monotonic() - t0) * 1000), } @@ -529,10 +506,10 @@ def list_llm_provider_models(provider_id: str): # can say "first 200 shown" rather than implying it's the full list. return {"ok": True, "models": ids[:200], "truncated": len(ids) > 200} except Exception as e: # noqa: BLE001 + from core.public_errors import provider_failure return { "ok": False, - "kind": _classify_llm_error(e), - "detail": _scrub_llm_detail(e, api_key), + **provider_failure(_classify_llm_error(e)), "models": [], } @@ -685,7 +662,7 @@ def _effective_models_dir() -> str: class _ModelsDirBody(BaseModel): - path: str = Field(default="", description="Absolute directory; empty clears → default cache") + authorization: str = Field(description="One-shot native desktop authorization") @router.get("/storage/models-dir") @@ -714,17 +691,18 @@ def set_models_dir(body: _ModelsDirBody): saved. Returns restart_required=True. """ from core import user_env + from core.path_authorization import PathAuthorizationError, consume - raw = (body.path or "").strip() + try: + raw = consume(body.authorization, "models_dir").strip() + except PathAuthorizationError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc if not raw: user_env.unset_user_env(_MODELS_DIR_ENV) return {"configured": None, "default": _default_models_dir(), "restart_required": True} - # Reject control characters / NUL before touching the filesystem: an - # embedded NUL makes os.makedirs raise ValueError (→ 500). This is also - # the input-validation barrier for the path before it reaches any fs call - # (the dir is user-chosen by design — this is a loopback-gated, same-user - # local file picker, not a cross-privilege boundary). + # Tauri already validates this before issuing the capability. Keep the + # backend checks as defense in depth against a corrupt capability file. if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in raw): raise HTTPException(status_code=400, detail="Path contains invalid control characters") diff --git a/backend/api/routers/system.py b/backend/api/routers/system.py index 676c2829..ac6bc22f 100644 --- a/backend/api/routers/system.py +++ b/backend/api/routers/system.py @@ -11,7 +11,7 @@ from core.prefs import set_ as prefs_set, delete as prefs_delete from services import network_share from services import tailscale as _tailscale from api.schemas import SysinfoResponse, SystemInfoResponse, ModelStatusResponse -from api.dependencies import require_loopback +from api.dependencies import is_loopback, require_admin from fastapi.responses import FileResponse, StreamingResponse import torch import shutil @@ -21,17 +21,16 @@ from core.version import APP_VERSION from services.model_manager import get_model_status, get_best_device, resolve_omnivoice_checkpoint from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg -# Router-level loopback gate. Every route mounted on `router` (GET + POST, -# present and future) is gated by `require_loopback`, which 403s any request -# whose `client.host` is not a loopback address. This closes the same trust +# Router-level admin gate. Every route mounted on `router` (GET + POST, +# present and future) is gated by `require_admin`: desktop requests must be +# loopback; server-mode mutations require the long API key. This closes the trust # boundary that PR #81 only patched on `/system/set-env` and that the # 260518-ivy deferred-items file enumerated for follow-up: /model/unload/*, # /system/logs/clear, /system/logs/tauri/clear, /system/flush-memory, # /clean-audio (POSTs) plus the read-side info-disclosure routes # /system/info, /system/logs, /system/logs/tauri, /system/logs/stream. -# This router only ever serves the local Tauri shell and the dev frontend -# at http://127.0.0.1:3901 — both are loopback origins. -router = APIRouter(dependencies=[Depends(require_loopback)]) +# Native Tauri/dev callers remain loopback and need no credential. +router = APIRouter(dependencies=[Depends(require_admin)]) logger = logging.getLogger("omnivoice.api") # Cache device checks at module load — they don't change at runtime @@ -806,7 +805,6 @@ async def ack_crash(): PERSISTENT_KEYS = { "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", - "FFMPEG_PATH", "FFPROBE_PATH", "TRANSLATE_BASE_URL", "TRANSLATE_API_KEY", "TRANSLATE_MODEL", "DEEPL_API_KEY", "DEEPL_BASE_URL", "MICROSOFT_API_KEY", "MICROSOFT_BASE_URL", @@ -843,7 +841,7 @@ async def set_env_var(body: dict): are set on ``os.environ`` for the running process. The loopback-origin gate that previously lived inline here is now applied - at the router level via `dependencies=[Depends(require_loopback)]` on + at the router level via `dependencies=[Depends(require_admin)]` on `router` — see the top of this file. Every route on this router is gated, including this one. The 403 body and behavior are unchanged. """ @@ -858,23 +856,6 @@ async def set_env_var(body: dict): ) if value: - # Validate executable paths if the user is setting them manually. - # Reject control characters / null bytes (defense-in-depth against - # path-injection), then require an existing regular file. NOTE: this - # endpoint is loopback-only and MUST remain so — a remote caller able - # to set FFMPEG_PATH/FFPROBE_PATH could point it at an arbitrary - # binary (RCE). Network sharing must never expose /system/set-env. - if key in ("FFMPEG_PATH", "FFPROBE_PATH"): - if any(ord(c) < 0x20 or ord(c) == 0x7F for c in value): - raise HTTPException( - status_code=400, - detail="Invalid path: control characters are not allowed", - ) - if not os.path.isfile(value): - raise HTTPException( - status_code=400, - detail=f"File not found: {value}", - ) # Port keys must be a numeric string in the unprivileged range so a # typo can't drop the backend onto a privileged port (<1024) or an # out-of-range value uvicorn would reject at bind time. @@ -1101,12 +1082,21 @@ def quarantine_status(): # ── Network sharing (loopback-only control surface) ────────────────────────── @router.get("/system/network/state") -async def network_state(): +async def network_state(request: Request): st = network_share.get_state() + # PIN-only server mode permits unauthenticated read-only discovery, but the + # PIN is itself a consumption credential. Reveal it only to the native + # loopback UI or to a remote caller that already passed the configured + # long API-key gate. The boolean lets headless dashboards remain useful. + host = request.client.host if request.client else None + may_reveal_pin = is_loopback(host) or bool( + os.environ.get("OMNIVOICE_API_KEY", "").strip() + ) return { "enabled": st.enabled, "share_port": st.share_port, - "pin": st.pin, + "pin": st.pin if may_reveal_pin else None, + "pin_required": bool(st.pin), "lan_addresses": st.lan_addresses, } diff --git a/backend/core/failure.py b/backend/core/failure.py index 6100251c..c0c0b851 100644 --- a/backend/core/failure.py +++ b/backend/core/failure.py @@ -29,6 +29,24 @@ from core.logging_filter import REDACTED, _HF_TOKEN_RE # Env vars whose *name* implies a credential — their values are redacted. _SECRET_NAME_RE = re.compile(r"(TOKEN|KEY|SECRET)", re.IGNORECASE) + +#: An HTTP 401, not any number or identifier that contains 401. +#: +#: Digit-only boundaries are not enough: they reject `4012` and `1401` but +#: still accept `pytest-401`, `x401y` and `401.0`, so an error that mentions +#: Hugging Face anywhere and carries one of those elsewhere still classified +#: as an auth failure (CodeRabbit, #1427). +#: +#: The three guards, each for a family the others let through: +#: `(?-- folder inside the Hugging Face cache, and restart — the model re-downloads automatically.", + "MODEL_CACHE_CORRUPT": "A model file is missing or damaged — a download that stopped part-way, a broken link to downloaded data, or a file changed on disk after it arrived (interrupted renames and antivirus interference both cause this). VoiceStudio repairs it automatically and retries the load once, re-downloading the damaged file where a resume would not have replaced it. If the error persists, quit VoiceStudio, delete the model's models---- folder inside the Hugging Face cache, and restart — the model re-downloads automatically.", # HF_MIRROR_UNREACHABLE has a DYNAMIC hint (it names the configured mirror) # — see hf_mirror_hint(); build_failure special-cases it. } @@ -333,7 +351,16 @@ def classify(reason: str) -> str: # load surface can leak them) and VoiceStudio's own repair messages, so the # user-facing error and the auto bug report name the class and its # automatic repair. - if is_incomplete_cache_message(low) or "broken file link" in low: + # Same class, both halves of it: a shard that is MISSING and a shard that + # is PRESENT but unparseable are the same problem (an interrupted or + # mangled download) with the same remedy, and only the first half was ever + # matched — so "Error while deserializing header: header too large" fell + # through to "" and shipped as a raw 500 with no repair (#1406). + if ( + is_incomplete_cache_message(low) + or is_corrupt_weights_message(low) + or "broken file link" in low + ): return "MODEL_CACHE_CORRUPT" # #1347: an import that failed because its DOWNLOAD died is a network # problem wearing an import problem's clothes. The reporter's message named @@ -404,8 +431,17 @@ def classify(reason: str) -> str: or "sslcertverificationerror" in low ): return "SSL_HANDSHAKE_FAILURE" - if ("huggingface" in low or "hf_token" in low or "401" in low or "unauthorized" in low) and ( - "token" in low or "auth" in low or "401" in low or "unauthorized" in low + # A bare 401 is no longer sufficient evidence on its own. It used to + # satisfy BOTH halves of this condition, which made the `and` vacuous, so + # any message containing those three digits — a path, a byte count, an id + # — classified as an auth failure by itself. CI hit it when pytest's + # numbered temp directory reached `pytest-401` and an audio-save error + # came back as "set a valid HF_TOKEN". A real 401 always arrives with the + # word Unauthorized or an HF URL beside it, so requiring that costs + # nothing and closes the class. + has_401 = _HTTP_401.search(low) is not None + if ("huggingface" in low or "hf_token" in low or "unauthorized" in low) and ( + "token" in low or "auth" in low or has_401 or "unauthorized" in low ): return "HF_AUTH_FAILED" # #874: a model download that failed because the CONFIGURED HF mirror is @@ -704,6 +740,72 @@ def is_incomplete_cache_message(text: str) -> bool: return False +#: The weight file is PRESENT but its bytes are not a valid tensor file. +#: +#: The sibling of ``_INCOMPLETE_CACHE_PHRASES`` above, and the half that had no +#: handling at all (#1406). An interrupted download that stops mid-file, an +#: antivirus that truncates a shard, or a proxy that saved an HTML error page +#: under the shard's name all leave a file transformers is happy to open — so +#: the "does not appear to have a file named …" check passes — and safetensors +#: then fails parsing its 8-byte header-length prefix. The user got a raw 500 +#: ("Error while deserializing header: header too large") on every generation, +#: from voice design and gallery previews alike, with nothing actionable in it +#: and no repair attempted, because the recovery ladder is reached only through +#: the *missing*-weights signature. +#: +#: Matched on wording rather than exception type on purpose: safetensors raises +#: ``SafetensorError`` from a Rust extension, torch raises ``UnpicklingError`` +#: or a bare ``RuntimeError`` for the same condition in a ``.bin``, and none of +#: them are ``OSError`` — the type is the least stable thing about this class. +_CORRUPT_MODEL_FILE_PHRASES = ( + # safetensors (Rust): header length prefix is larger than the file, or the + # declared metadata runs past the end of the buffer. + "error while deserializing header", + "headertoolarge", + "metadataincompletebuffer", + "invalidheaderdeserialization", + "deserializing header", + # torch.load on a truncated / non-pickle .bin shard. "invalid load key" is + # unambiguous — only the pickle reader says it. "unexpected end of file" is + # not: zipfile, tarfile, gzip and several parsers share the wording, and any + # of them can surface inside a model-load chain, where a false positive + # would force a multi-GB re-download of an undamaged cache (CodeRabbit). It + # therefore needs a weight-file co-marker, like the entry below. + "invalid load key", + ("unexpected end of file", "safetensors"), + ("unexpected end of file", "pytorch_model"), + ("unexpected end of file", "checkpoint"), + ("failed to load", "checkpoint", "corrupt"), + # transformers wraps a JSONDecodeError from a truncated/HTML config file + # with this stable, path-bearing message (#1437). + ("config file", "not a valid json file"), +) + + +def is_corrupt_model_file_message(text: str) -> bool: + """True when a downloaded model weight or config file cannot be parsed. + + Distinct from :func:`is_incomplete_cache_message`, which means the file is + absent. Here it exists and its bytes are wrong — a different repair (force + a re-download; a resume would trust the bad blob) and a different thing to + tell the user. Shared by :func:`classify` and model_manager's self-heal so + the healer and the message can never disagree. + """ + low = str(text).lower() + for phrase in _CORRUPT_MODEL_FILE_PHRASES: + if isinstance(phrase, tuple): + if all(part in low for part in phrase): + return True + elif phrase in low: + return True + return False + + +def is_corrupt_weights_message(text: str) -> bool: + """Backward-compatible name for :func:`is_corrupt_model_file_message`.""" + return is_corrupt_model_file_message(text) + + def is_os_write_refusal(reason: Optional[str]) -> bool: """True when *reason* looks like the OS refusing a file operation (a full or removed drive, a read-only folder, an antivirus/cloud-sync lock) rather diff --git a/backend/core/path_authorization.py b/backend/core/path_authorization.py new file mode 100644 index 00000000..86d88620 --- /dev/null +++ b/backend/core/path_authorization.py @@ -0,0 +1,88 @@ +"""Consume one-shot host paths authorized by the native Tauri process. + +The web API never accepts a filesystem destination or executable path. Tauri +validates the user's native IPC request, writes a private capability file, and +only the unguessable capability token crosses loopback HTTP. +""" +from __future__ import annotations + +import json +import os +import re +import secrets +import stat + +from core.config import DATA_DIR + +_TOKEN_RE = re.compile(r"[0-9a-f]{64}\Z") +_KINDS = {"models_dir", "ffmpeg", "ffprobe"} +_AUTH_DIR = os.path.join(DATA_DIR, ".path-authorizations") + + +class PathAuthorizationError(ValueError): + pass + + +def consume(token: str, expected_kind: str) -> str: + """Consume and return a single Tauri-authorized path. + + Capability files are one-shot and opened without following symlinks. Tauri + writes them into the app's private data directory; source/Docker callers + cannot mint a valid token through HTTP. + """ + if expected_kind not in _KINDS or not _TOKEN_RE.fullmatch(token or ""): + raise PathAuthorizationError("Invalid or expired desktop authorization") + root = _AUTH_DIR + candidate = None + try: + for entry in os.scandir(root): + if not _TOKEN_RE.fullmatch(entry.name.removesuffix(".json")): + continue + if not entry.is_file(follow_symlinks=False): + continue + try: + with open(entry.path, "r", encoding="utf-8") as handle: + probe = json.load(handle) + except (OSError, UnicodeError, json.JSONDecodeError): + continue # Ignore corrupt/stale capabilities; they authorize nothing. + if isinstance(probe, dict) and secrets.compare_digest( + str(probe.get("token", "")), token + ): + candidate = entry.path + break + if candidate is None: + raise OSError("capability not found") + claimed = os.path.join(root, f".consuming-{os.getpid()}-{secrets.token_hex(16)}") + os.replace(candidate, claimed) + except OSError as exc: + raise PathAuthorizationError("Invalid or expired desktop authorization") from exc + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + fd = os.open(claimed, flags) + except OSError as exc: + raise PathAuthorizationError("Invalid or expired desktop authorization") from exc + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_size > 16_384: + raise PathAuthorizationError("Invalid desktop authorization") + with os.fdopen(fd, "r", encoding="utf-8") as handle: + fd = -1 + payload = json.load(handle) + except (OSError, UnicodeError, json.JSONDecodeError, TypeError) as exc: + raise PathAuthorizationError("Invalid desktop authorization") from exc + finally: + if fd >= 0: + os.close(fd) + try: + os.unlink(claimed) + except OSError: + pass # Best-effort cleanup; the random claimed name cannot be reused. + if not isinstance(payload, dict): + raise PathAuthorizationError("Invalid desktop authorization") + if not secrets.compare_digest(str(payload.get("token", "")), token): + raise PathAuthorizationError("Invalid desktop authorization") + if payload.get("kind") != expected_kind or not isinstance(payload.get("path"), str): + raise PathAuthorizationError("Desktop authorization does not match this setting") + return payload["path"] diff --git a/backend/core/public_errors.py b/backend/core/public_errors.py new file mode 100644 index 00000000..fec94cd6 --- /dev/null +++ b/backend/core/public_errors.py @@ -0,0 +1,53 @@ +"""Data-independent error metadata safe for API and streaming responses.""" +from __future__ import annotations + +_PROVIDER_DETAILS = { + "auth": "Authentication failed. Check the provider API key.", + "not_found": "Provider or model not found. Check the model and Base URL.", + "rate_limit": "The provider rate limit was reached. Try again later.", + "network": "The provider could not be reached. Check the connection and Base URL.", + "config": "Configure the provider before using it.", + "error": "The provider request failed. Try again.", +} + + +def provider_failure(kind: str) -> dict[str, str]: + """Return a stable provider error class and remediation message.""" + safe_kind = kind if kind in _PROVIDER_DETAILS else "error" + return {"kind": safe_kind, "detail": _PROVIDER_DETAILS[safe_kind]} + + +def stream_failure(code: str) -> dict[str, object]: + """Return stable stream metadata selected only from an internal code.""" + failures: dict[str, dict[str, object]] = { + "generation_busy": { + "code": "generation_busy", + "detail": "Generation capacity is busy. Try again shortly.", + "retryable": True, + }, + "invalid_request": { + "code": "invalid_request", + "detail": "The generation request could not be processed.", + "retryable": False, + }, + "generation_failed": { + "code": "generation_failed", + "detail": "Generation failed. Check the selected engine and try again.", + "retryable": True, + }, + "transcription_failed": { + "code": "transcription_failed", + "detail": "Transcription failed. Check the selected ASR engine and try again.", + "retryable": True, + }, + "transcription_timeout": { + "code": "transcription_timeout", + "detail": ( + "Transcription timed out while the backend is running. Increase " + "OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S or select the " + "faster-whisper-isolated engine, then try again." + ), + "retryable": True, + }, + } + return dict(failures.get(code, failures["generation_failed"])) diff --git a/backend/schemas/requests.py b/backend/schemas/requests.py index c7d606f4..8d501b8a 100644 --- a/backend/schemas/requests.py +++ b/backend/schemas/requests.py @@ -197,6 +197,10 @@ class DubIngestUrlRequest(BaseModel): # YouTube auto-translates for us. fetch_subs: Optional[bool] = False sub_langs: Optional[List[str]] = None + # Explicit, per-import Netscape cookie export. This is never populated + # automatically: browser cookie stores contain unrelated login secrets and + # VoiceStudio must not inspect them without a deliberate user action. + cookie_file: Optional[str] = None class ProjectSaveRequest(BaseModel): name: str diff --git a/backend/services/director.py b/backend/services/director.py index ee830d8b..59b39dbd 100644 --- a/backend/services/director.py +++ b/backend/services/director.py @@ -160,10 +160,10 @@ def parse(text: str) -> Direction: try: body = llm.chat(system=_LLM_PROMPT, user=text) - except Exception as e: - logger.warning("director LLM parse failed: %s", e) + except Exception: + logger.warning("director LLM parse failed; using heuristic parser") d = _heuristic_parse(text) - d.error = f"llm-parse-failed: {e}" + d.error = "llm-parse-failed" return d raw = body.strip() diff --git a/backend/services/dub_pipeline.py b/backend/services/dub_pipeline.py index 954a6055..a1df9a31 100644 --- a/backend/services/dub_pipeline.py +++ b/backend/services/dub_pipeline.py @@ -840,6 +840,18 @@ def _cleanup_partial_download(job_dir: str) -> None: pass +def _delete_cookie_export(cookie_file: str | None) -> bool: + """Best-effort removal of the per-import authentication export.""" + if not cookie_file: + return True + try: + os.unlink(cookie_file) + except OSError: + # Best effort: cleanup must never replace the download result. + return False + return True + + def yt_download_sync( url: str, job_dir: str, @@ -847,6 +859,7 @@ def yt_download_sync( fetch_subs: bool = False, sub_langs: list[str] | None = None, progress_hook=None, + cookie_file: str | None = None, ) -> tuple[str, str, list[str]]: """Blocking yt-dlp download into `job_dir`. @@ -918,6 +931,8 @@ def yt_download_sync( "extractor_retries": 5, "skip_unavailable_fragments": True, } + if cookie_file: + ydl_opts["cookiefile"] = cookie_file # #712: the format selector above pulls separate video+audio streams, so # yt-dlp muxes them via ffmpeg (merge_output_format=mp4). yt-dlp only looks # for ffmpeg on PATH and aborts with "you have requested merging of multiple @@ -1108,6 +1123,7 @@ async def ingest_pipeline( url = source["url"] fetch_subs = bool(source.get("fetch_subs")) sub_langs = source.get("sub_langs") or None + cookie_file = source.get("cookie_file") or None yield prep_event("download_start", url=url) # Bridge yt-dlp's per-fragment progress callback (fires inside # the worker thread) into the async generator via a threadsafe @@ -1137,6 +1153,7 @@ async def ingest_pipeline( yt_download_sync, url, job_dir, fetch_subs=fetch_subs, sub_langs=sub_langs, progress_hook=_yt_progress, + cookie_file=cookie_file, )) try: while not dl_task.done(): @@ -1157,6 +1174,10 @@ async def ingest_pipeline( yield prep_event("error", **failure.build_failure(e, stage="download")) shutil.rmtree(job_dir, ignore_errors=True) return + # yt-dlp (including its optional subtitle pass) is finished. Drop + # the login credential before the much longer audio-prep stages. + if _delete_cookie_export(cookie_file): + source["cookie_file"] = None filename = title or os.path.basename(video_path) try: size = os.path.getsize(video_path) @@ -1439,6 +1460,11 @@ async def ingest_pipeline( yield prep_event("error", **failure.build_failure(e, stage="ingest")) return finally: + # Cookie exports are login credentials. Keep an explicitly selected + # export only for this download, then remove it on success, failure or + # cancellation; never copy it into the project/job directory. + cookie_file = source.get("cookie_file") + _delete_cookie_export(cookie_file) end_ingest(job_id) with _active_procs_lock: _active_procs.pop(job_id, None) diff --git a/backend/services/model_manager.py b/backend/services/model_manager.py index ec1a6948..74f03f19 100644 --- a/backend/services/model_manager.py +++ b/backend/services/model_manager.py @@ -1491,6 +1491,35 @@ def _is_incomplete_cache_error(exc: BaseException) -> bool: return is_incomplete_cache_message(str(exc)) +def _is_corrupt_model_file_error(exc: BaseException) -> bool: + """True when a model weight or config file cannot be parsed. + + The other half of the interrupted-download class (#1406). transformers + only raises the "does not appear to have a file named …" signature when + the shard is *absent*; a shard that stops mid-file, gets truncated by + antivirus, or is actually a saved HTML error page opens fine and then + fails inside safetensors: + + Error while deserializing header: header too large + + That is a ``SafetensorError`` from a Rust extension — not an ``OSError``, + so it never reached the recovery ladder and surfaced as a raw 500 on every + generation (the reporter hit it from voice design *and* from a gallery + preview, which is what a shared broken shard looks like). + + The whole exception chain is checked, not just the outermost message: + transformers wraps the tensor library's error in its own before it gets + here, and matching only the surface would miss every wrapped case.""" + from core.failure import is_corrupt_model_file_message + + return any(is_corrupt_model_file_message(str(e)) for e in _exception_chain(exc)) + + +def _is_corrupt_weights_error(exc: BaseException) -> bool: + """Backward-compatible wrapper for the original #1406 helper name.""" + return _is_corrupt_model_file_error(exc) + + def _hf_offline() -> bool: """Respect HF's offline switches so repair never makes a network call the user opted out of. `snapshot_download` would itself raise offline, but @@ -1516,6 +1545,13 @@ def _hf_offline() -> bool: # stays broken can't loop repair↔retry. _LINK_REPAIR_ATTEMPTED: set[str] = set() +#: Repos whose weights we have already force-re-downloaded this process +#: (#1406). Without it, a shard that stays unparseable after a full re-fetch +#: would pull the whole model again on EVERY generate request — one bad file +#: turning into unbounded traffic. Same once-per-repo-per-process contract as +#: the snapshot-link repair above (CodeRabbit). +_FORCED_REDOWNLOAD_ATTEMPTED: set[str] = set() + def _selfheal_broken_snapshot_links(checkpoint: str) -> bool: """Rung 0 of cache recovery: delete-and-restore broken snapshot entries. @@ -1879,14 +1915,74 @@ def _load_model_sync(): logger.info("Loading VoiceStudio model on device: %s", device) preload_asr = should_preload_tts_asr() if preload_asr: - logger.info("Preloading PyTorch Whisper with TTS model.") + logger.info("Preloading PyTorch Whisper after TTS model load.") else: logger.info("Skipping PyTorch Whisper preload; ASR will load on demand.") def _load(): return VoiceStudio.from_pretrained( - checkpoint, device_map=device, dtype=torch.float16, load_asr=preload_asr, + checkpoint, device_map=device, dtype=torch.float16, load_asr=False, ) + def _recover_corrupt_weights(exc: BaseException): + """Re-fetch weights that are on disk but unparseable (#1406). + + Deliberately a FORCED re-download rather than the resume ladder + below: a resume trusts a blob that is already the expected size + and would never re-fetch the one that is actually wrong. + """ + repair_checkpoint = checkpoint + for nested_exc in _exception_chain(exc): + repository_id = getattr(nested_exc, "repository_id", None) + if repository_id == "eustlb/higgs-audio-v2-tokenizer": + repair_checkpoint = repository_id + break + asset_label = ( + "audio tokenizer" + if repair_checkpoint != checkpoint + else "TTS model" + ) + if repair_checkpoint in _FORCED_REDOWNLOAD_ATTEMPTED: + # Already re-fetched this repo once this process and it is + # still unparseable. Re-downloading again would be the same + # gigabytes for the same result, once per generate request. + raise RuntimeError( + f"The {asset_label} files for {repair_checkpoint} are damaged and a " + "re-download did not fix them. Open Settings → Models, " + "delete the VoiceStudio TTS model, and install it again." + f"{_manual_cache_delete_hint(repair_checkpoint)}" + ) from exc + _FORCED_REDOWNLOAD_ATTEMPTED.add(repair_checkpoint) + logger.warning( + "%s files for %s are present but unparseable (%s) — a " + "download that stopped mid-file, or a file altered on disk " + "after it arrived. Re-fetching them.", + asset_label, + repair_checkpoint, + exc, + ) + _set_loading("loading_weights", "Model files are damaged — re-downloading…") + if not _repair_model_cache(repair_checkpoint, force=True): + raise RuntimeError( + f"The {asset_label} files for {repair_checkpoint} are damaged — a " + "download that stopped part-way, or a file changed on " + "disk after it arrived — and could not be re-downloaded " + f"automatically.{_repair_failure_detail()} Open Settings " + "→ Models, delete the VoiceStudio TTS model, and install " + f"it again.{_manual_cache_delete_hint(repair_checkpoint)}" + ) from exc + _set_loading("loading_weights", f"Loading TTS weights on {device}…") + try: + return _load() + except Exception as exc2: + if not _is_corrupt_weights_error(exc2): + raise + raise RuntimeError( + f"The {asset_label} files for {repair_checkpoint} are still damaged " + "after being re-downloaded. Open Settings → Models, " + "delete the VoiceStudio TTS model, and install it again." + f"{_manual_cache_delete_hint(repair_checkpoint)}" + ) from exc2 + try: _model = _load() except OSError as e: @@ -1897,78 +1993,117 @@ def _load_model_sync(): # interrupted download leaves the cache missing only some files, # and snapshot_download() resumes/fills exactly those (a complete # cache never reaches this branch, so the fast path is untouched). - if not _is_incomplete_cache_error(e): + if _is_corrupt_weights_error(e): + # Present-but-unparseable wearing an OSError (#1406) — + # transformers wraps a tensor-library failure in one. The + # resume ladder below is the wrong repair (it would trust the + # bad blob), so divert before the missing-shard check drops + # this as unrecognised and 500s. + _model = _recover_corrupt_weights(e) + elif not _is_incomplete_cache_error(e): raise - # Rung 0: broken snapshot links — the blobs are on disk but the - # snapshot entries don't resolve (dangling symlinks / zero-byte - # stand-ins). Delete exactly the broken entries, restore, and - # retry the load ONCE (guarded per repo per process). A cache - # without broken links falls straight through to the resume - # ladder below. - _model = None - if _selfheal_broken_snapshot_links(checkpoint): - _set_loading( - "loading_weights", - "Model cache had broken file links — repaired " - "automatically, retrying…", - ) - try: - _model = _load() - except OSError as e_link: - if not _is_incomplete_cache_error(e_link): - raise - logger.warning( - "Load still failing after snapshot-link repair of %s — " - "falling back to resume repair.", checkpoint, + else: + # Rung 0: broken snapshot links — the blobs are on disk but the + # snapshot entries don't resolve (dangling symlinks / zero-byte + # stand-ins). Delete exactly the broken entries, restore, and + # retry the load ONCE (guarded per repo per process). A cache + # without broken links falls straight through to the resume + # ladder below. + _model = None + if _selfheal_broken_snapshot_links(checkpoint): + _set_loading( + "loading_weights", + "Model cache had broken file links — repaired " + "automatically, retrying…", ) - e = e_link - _model = None - if _model is None: - _set_loading("loading_weights", "Repairing incomplete model cache…") - if not _repair_model_cache(checkpoint): - raise RuntimeError( - f"The TTS model cache for {checkpoint} is incomplete " - "(weights missing — usually an interrupted download)." - f"{_repair_failure_detail()} " - "Open Settings → Models, delete the VoiceStudio TTS model, " - f"and install it again.{_manual_cache_delete_hint(checkpoint)}" - ) from e - _set_loading("loading_weights", f"Loading TTS weights on {device}…") - try: - _model = _load() - except OSError as e2: - # Resume-repair ran but the cache is still unusable. The usual - # cause beyond "repo genuinely lacks weights" is a blob that's - # present with the right size but corrupt — snapshot_download's - # resume trusts it and never re-fetches it (#739). Force a full - # re-download (replaces corrupt blobs) and retry once more before - # falling back to the manual delete-and-reinstall message. - if _is_incomplete_cache_error(e2): - _set_loading("loading_weights", "Re-downloading model files…") - if _repair_model_cache(checkpoint, force=True): - try: - _model = _load() - except OSError as e3: + try: + _model = _load() + except OSError as e_link: + if not _is_incomplete_cache_error(e_link): + raise + logger.warning( + "Load still failing after snapshot-link repair of %s — " + "falling back to resume repair.", checkpoint, + ) + e = e_link + _model = None + if _model is None: + _set_loading("loading_weights", "Repairing incomplete model cache…") + if not _repair_model_cache(checkpoint): + raise RuntimeError( + f"The TTS model cache for {checkpoint} is incomplete " + "(weights missing — usually an interrupted download)." + f"{_repair_failure_detail()} " + "Open Settings → Models, delete the VoiceStudio TTS model, " + f"and install it again.{_manual_cache_delete_hint(checkpoint)}" + ) from e + _set_loading("loading_weights", f"Loading TTS weights on {device}…") + try: + _model = _load() + except OSError as e2: + # Resume-repair ran but the cache is still unusable. The usual + # cause beyond "repo genuinely lacks weights" is a blob that's + # present with the right size but corrupt — snapshot_download's + # resume trusts it and never re-fetches it (#739). Force a full + # re-download (replaces corrupt blobs) and retry once more before + # falling back to the manual delete-and-reinstall message. + if _is_corrupt_weights_error(e2): + # The resume filled the missing files, then exposed a + # present-but-damaged blob. A second resume would trust + # that blob, so switch to the forced corruption repair. + _model = _recover_corrupt_weights(e2) + elif _is_incomplete_cache_error(e2): + _set_loading("loading_weights", "Re-downloading model files…") + if _repair_model_cache(checkpoint, force=True): + try: + _model = _load() + except OSError as e3: + raise RuntimeError( + f"The TTS model cache for {checkpoint} is incomplete " + "and could not be auto-repaired. Open Settings → " + "Models, delete the VoiceStudio TTS model, and install " + f"it again.{_manual_cache_delete_hint(checkpoint)}" + ) from e3 + else: raise RuntimeError( - f"The TTS model cache for {checkpoint} is incomplete " - "and could not be auto-repaired. Open Settings → " - "Models, delete the VoiceStudio TTS model, and install " - f"it again.{_manual_cache_delete_hint(checkpoint)}" - ) from e3 + f"The TTS model cache for {checkpoint} is incomplete and " + f"could not be auto-repaired.{_repair_failure_detail()} " + "Open Settings → Models, delete the VoiceStudio TTS model, " + f"and install it again.{_manual_cache_delete_hint(checkpoint)}" + ) from e2 else: raise RuntimeError( f"The TTS model cache for {checkpoint} is incomplete and " - f"could not be auto-repaired.{_repair_failure_detail()} " - "Open Settings → Models, delete the VoiceStudio TTS model, " - f"and install it again.{_manual_cache_delete_hint(checkpoint)}" + "could not be auto-repaired. Open Settings → Models, delete " + "the VoiceStudio TTS model, and install it again." + f"{_manual_cache_delete_hint(checkpoint)}" ) from e2 - else: - raise RuntimeError( - f"The TTS model cache for {checkpoint} is incomplete and " - "could not be auto-repaired. Open Settings → Models, delete " - "the VoiceStudio TTS model, and install it again." - f"{_manual_cache_delete_hint(checkpoint)}" - ) from e2 + except Exception as e_corrupt: + # safetensors raises SafetensorError from a Rust extension and + # torch raises UnpicklingError — neither is an OSError, so the + # ladder above never saw them and the load 500'd with a raw + # "Error while deserializing header: header too large" (#1406). + # Anything that is not this class re-raises untouched, so no + # unrelated failure is swallowed by the broad clause. + if not _is_corrupt_weights_error(e_corrupt): + raise + _model = _recover_corrupt_weights(e_corrupt) + + if preload_asr: + # Keep ASR outside `from_pretrained`: if its separate HF cache is + # corrupt, it must never be mistaken for the TTS checkpoint and + # trigger a second multi-GB TTS load/re-download (CodeRabbit). + try: + _model.load_asr_model() + except Exception as asr_exc: + if not _is_corrupt_model_file_error(asr_exc): + raise + raise RuntimeError( + "The transcription model's files are damaged. Open " + "Settings → Models, delete the transcription (ASR) model, " + "and install it again; or set OMNIVOICE_PRELOAD_TTS_ASR=0 " + "to stop preloading it alongside TTS." + ) from asr_exc try: # plan-02 (#65): gate on Triton availability (+ user setting), not diff --git a/backend/services/speech_rate.py b/backend/services/speech_rate.py index c2787358..98a43505 100644 --- a/backend/services/speech_rate.py +++ b/backend/services/speech_rate.py @@ -184,9 +184,12 @@ def adjust_for_slot( system=system, user="\n".join(user_lines), temperature=0.2, # pinned like the Fast path — default 1.0 drifts/invents ) - except Exception as e: - logger.warning("speech-rate attempt %d failed: %s", attempt, e) - return {"text": best[0], "rate_ratio": best[1], "attempts": attempt - 1, "error": str(e)} + except Exception: + logger.warning("speech-rate provider attempt %d failed", attempt) + return { + "text": best[0], "rate_ratio": best[1], + "attempts": attempt - 1, "error": "fit-provider-failed", + } if next_text and next_text.strip(): candidate = next_text.strip() diff --git a/backend/services/subprocess_backend.py b/backend/services/subprocess_backend.py index c7dbe026..088b2ae6 100644 --- a/backend/services/subprocess_backend.py +++ b/backend/services/subprocess_backend.py @@ -40,6 +40,7 @@ from __future__ import annotations import atexit import base64 +import contextlib import json import logging import os @@ -263,6 +264,91 @@ def _ensure_reaper_running() -> None: # ── Base class ───────────────────────────────────────────────────────────── +#: How often to prove liveness while an engine's venv is being resolved. +#: Matches the sidecar's own cold-load cadence (#1367) so the guarded waiter +#: sees the same rhythm from both steps. +_RESOLVE_HEARTBEAT_S = 5.0 + + +@contextlib.contextmanager +def _heartbeat_while_resolving(engine_id: str): + """Report progress while a slow engine-venv resolution runs (#1414). + + A probe that spawns interpreters, or a bootstrap that installs torch, can + outlast the generate budget on its own. Both are demonstrably *working* + the whole time, so the deadline should extend rather than expire — which + is what the execution clock's load heartbeat is for. + + Two details that are easy to get wrong: + + * **Pool jobs only.** An off-pool caller never runs on a thread the clock + tracks, and heartbeating from one would credit an ident a pool worker + might later reuse — up to a grace period of unearned extension, which + is the #1379 lesson. + * **The resolving thread's ident, not the beater's.** The heartbeat runs + on a helper thread so it can tick while resolution blocks, but the job + the clock is watching is the caller's. Capturing the ident up front is + what makes the extension land on the right job. + + Never raises: a failed heartbeat must not fail a generation. + """ + try: + from services.model_manager import running_on_gpu_pool + + on_pool = running_on_gpu_pool() + except Exception: # noqa: BLE001 — best-effort by construction + on_pool = False + if not on_pool: + yield + return + + ident = threading.get_ident() + stop = threading.Event() + + def _beat(): + try: + from services.model_manager import ( + MODEL_LOAD_HEARTBEAT_GRACE_S, _MODEL_LOAD_ACTIVITY, + ) + except Exception: # noqa: BLE001 + return + while not stop.wait(_RESOLVE_HEARTBEAT_S): + try: + _MODEL_LOAD_ACTIVITY[ident] = ( + time.monotonic(), MODEL_LOAD_HEARTBEAT_GRACE_S, + ) + except Exception: # noqa: BLE001 + return + + t = threading.Thread( + target=_beat, name=f"{engine_id}-resolve-heartbeat", daemon=True, + ) + t.start() + try: + yield + finally: + stop.set() + # Join, don't just signal. `_beat()` can be past its `stop.wait()` and + # already committed to a write at the moment the flag is set, so + # signalling alone lets that write land at an arbitrary later point. + # + # That matters because of what runs next: `_run_on_gpu_pool`'s `_job` + # pops this ident from `_MODEL_LOAD_ACTIVITY` in its `finally` + # (model_manager.py) precisely so a stale beat cannot vouch for a + # future job — GPU-pool idents are reused. A write arriving after that + # pop resurrects the entry, and the next job scheduled onto this + # worker inherits a heartbeat it never emitted: the wedge detector + # reads it as live progress and keeps extending a job that is stuck. + # + # Joining orders the last write BEFORE the pop, so the pop clears it. + # This must not be bounded: returning while the helper is still alive + # would recreate the late-write race this join closes. The helper's + # only work after ``wait`` is an in-memory mapping assignment guarded + # by its own broad exception handler, so there is no blocking external + # operation to time out here. + t.join() + + class SubprocessBackend(TTSBackend): """Long-lived sidecar-process TTS backend. Subclasses provide ``venv_python()`` and ``sidecar_script()``; the base class owns @@ -379,7 +465,20 @@ class SubprocessBackend(TTSBackend): else: kwargs["start_new_session"] = True - python_path = str(self.venv_python()) + # `venv_python()` resolves the engine's interpreter, and on a cold + # first run that is not cheap: it spawns each candidate to import the + # engine (bounded, but tens of seconds on a slow disk), and if none is + # installed it can run the whole `uv venv` + `uv pip install` + # bootstrap — minutes, by design. + # + # All of that happens on a GPU-pool worker, inside a generate request + # whose execution budget is 300s by default. Nothing along the way + # reported progress, so the budget expired mid-install and the job was + # abandoned and blamed on the machine's compute (#1414). The sidecar's + # own cold load already heartbeats for exactly this reason (#1367); + # resolution is the step before it that never did. + with _heartbeat_while_resolving(self.id): + python_path = str(self.venv_python()) script_path = str(self.sidecar_script()) # #1172 class: validate the interpreter before exec so a broken / # half-installed engine venv (0-byte or truncated python, dangling diff --git a/backend/services/video_context.py b/backend/services/video_context.py index 10b7e43d..92633036 100644 --- a/backend/services/video_context.py +++ b/backend/services/video_context.py @@ -92,7 +92,7 @@ def _analyse_frame_basic(frame_path: str) -> dict: import statistics img = Image.open(frame_path).convert("RGB").resize((320, 240)) - pixels = list(img.getdata()) + pixels = list(img.get_flattened_data()) # Brightness luminances = [0.299 * r + 0.587 * g + 0.114 * b for r, g, b in pixels] @@ -113,7 +113,7 @@ def _analyse_frame_basic(frame_path: str) -> dict: # Edge density → approximates "action" vs "static" try: gray = img.convert("L") - edge_pixels = list(gray.getdata()) + edge_pixels = list(gray.get_flattened_data()) diffs = [ abs(edge_pixels[i] - edge_pixels[i + 1]) for i in range(len(edge_pixels) - 1) diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 0462745e..0666abcc 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -32,7 +32,6 @@ WORKDIR /app # Enable unbuffered logs and optimizations ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 -ENV UV_SYSTEM_PYTHON=1 ENV HF_HOME=/app/omnivoice_data/huggingface # Allow bare imports (from core.config, from services.*, etc.) when # uvicorn is started as `backend.main:app` from WORKDIR /app. @@ -54,9 +53,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* # PEP 668: the ROCm base (Ubuntu 24.04) marks its system Python -# EXTERNALLY-MANAGED, which would refuse `pip install` / `uv pip install -# --system`. Inside a single-purpose container image installing into the -# base env is exactly what we want. No-ops on the conda-based CUDA image. +# EXTERNALLY-MANAGED, which would refuse installing into the selected base +# interpreter. Inside a single-purpose container image that is exactly what +# we want. No-ops on the conda-based CUDA image. ENV PIP_BREAK_SYSTEM_PACKAGES=1 ENV UV_BREAK_SYSTEM_PACKAGES=1 @@ -71,6 +70,11 @@ COPY deploy/torch-constraints.txt ./deploy/torch-constraints.txt # Install the project (non-editable — no need for -e in containers). # Uses `uv` for exponentially faster resolution than plain pip. # +# Target the exact interpreter selected by the base image. The ROCm image has +# both /opt/venv/bin/python3 (ROCm torch) and /usr/bin/python (a CUDA-default +# environment); `--system` used the latter while the build guard used the +# former, so a green image launched a CPU-only backend on AMD (#1274). +# # NOTE: `uv pip install` (without --upgrade) keeps already-installed packages # that satisfy the requirements, so the base image's GPU-built torch/torchaudio # (2.8.0, satisfying our `torch>=2.4`) survive this step instead of being @@ -83,7 +87,8 @@ COPY deploy/torch-constraints.txt ./deploy/torch-constraints.txt # stays put — an ABI mismatch at import (#1357). The pins carry no local # segment, so they match the base image's +cu128 / +rocm6.4 builds rather than # replacing them. -RUN uv pip install --system --no-cache --constraint deploy/torch-constraints.txt . +RUN uv pip install --python "$(command -v python3)" --no-cache \ + --constraint deploy/torch-constraints.txt . # Guard (fails the build, not the user at runtime): assert the dependency # install did NOT replace the base image's GPU torch. A future dep bump that @@ -121,5 +126,6 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=5 \ # Mount points for persistent data (sqlite db, user voices, huggingface cache) VOLUME ["/app/omnivoice_data"] -# Bind to 0.0.0.0 for external access -ENTRYPOINT ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "3900"] +# Bind to 0.0.0.0 for external access. `python3 -m` keeps runtime imports on +# the same interpreter whose torch flavor the build guard validated (#1274). +ENTRYPOINT ["python3", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "3900"] diff --git a/docs/api-auth.md b/docs/api-auth.md index f707e3f1..8d5a9e18 100644 --- a/docs/api-auth.md +++ b/docs/api-auth.md @@ -21,7 +21,8 @@ tools keep working unchanged whichever gate is set. > VoiceStudio separates **consumption** (TTS, dictation, voices) from > **administration** (`/system/*`, `/api/settings/*` — RCE-class). The PIN and > trusted networks are *consumption* credentials; the **admin surface is only -> ever reached from loopback or with the API key** (see [Admin routes](#admin-routes-and-server-mode)). +> ever reached from loopback or with the API key**. Host-path capabilities stay +> desktop-only even with a key (see [Admin routes](#admin-routes-and-server-mode)). > Both gates can be active at once. The PIN and the API key are independent; when > both are set, each is checked on the paths it covers. @@ -206,13 +207,22 @@ origin is unenforceable — NAT rewrites the source and even a requirement is dropped (issue #261, else the operator is 403'd out of their own `/system/*`). It is replaced by a **credential rule**, not removed: -- **No credential configured** (no API key, no PIN) → admin is open. The bare - Docker flow; exposure rests entirely on your port mapping / firewall. +- **No API key configured** → read-only admin discovery remains available for + the bare Docker bootstrap flow, but `POST`/`PUT`/`PATCH`/`DELETE` requests are + denied. Set `OMNIVOICE_API_KEY` before changing settings remotely. - **A credential is configured** → admin requires the **API key** (`Authorization: Bearer` / `?api_key` / `ov_key` cookie), or genuine loopback. The **6-digit share PIN does not gate admin** (it is brute-forceable), and trusted-network - membership never does either. So a **PIN-only** server-mode deployment keeps - admin loopback-only; remote admin requires the long API key. + membership never does either. A **PIN-only** server-mode deployment therefore + allows remote read-only discovery but blocks remote mutations; remote writes + require the long API key. Discovery never returns the share PIN itself; only + loopback or a caller already authenticated with the API key can read it. + +Host paths are never selected through HTTP. The native Tauri process validates +model-cache destinations and custom FFmpeg/FFprobe binaries, writes a private +one-shot capability, and only that opaque authorization reaches the backend. +`/system/set-env` does not accept executable-path keys at all. Server mode and +an API key do not weaken that native boundary. This is the fix for a real escalation (#1213): before it, server mode made the admin gate a no-op, so with an API key set *and* a trusted CIDR configured, a LAN diff --git a/docs/install/docker.md b/docs/install/docker.md index adf64d6e..ec2714c1 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -109,16 +109,26 @@ the CUDA tags exactly. Verify the container sees the GPU: ```bash -docker exec omnivoice python3 -c \ - "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))" +docker exec python3 -c \ + "import torch; ok = torch.cuda.is_available(); print(ok, torch.cuda.get_device_name(0) if ok else 'unavailable')" ``` +Use `omnivoice` for the `docker run` examples above. Docker Compose names the +ROCm container `omnivoice-studio-rocm` (CPU: `omnivoice-studio`, NVIDIA: +`omnivoice-studio-gpu`); `docker compose ps` shows the exact active name. + (ROCm-built PyTorch reports through `torch.cuda.*` — `True` plus your card's name means torch can see the GPU.) That check alone isn't proof the app is using it: **Settings → System** shows the device VoiceStudio actually resolved. If it reads `cpu` while the command above prints `True`, the backend log line starting `Falling back to CPU:` names the architecture mismatch it hit. +The image installs and launches VoiceStudio through that same `python3` +interpreter. To verify this invariant on an older or custom image, compare +`docker exec python3 -c "import sys, torch; print(sys.executable, +torch.version.hip)"` with `docker exec sh -c 'tr "\\0" " " + python3 -c "import importlib.metadata; print(importlib.metadata.version('omnivoice'))"`, or hit the `/health` endpoint — it returns `{"status": "ok", "device": ..., "version": "0.3.x"}`. Use the container name listed by `docker compose ps` (or `omnivoice` for the `docker run` examples). - **"Loopback origin required" errors (and a blank version):** the desktop build restricts the `/system/*` and `/api/settings/*` routes to a loopback origin, but Docker's NAT makes every request look non-loopback, so the gate diff --git a/docs/install/troubleshooting.md b/docs/install/troubleshooting.md index a0eab928..7518abca 100644 --- a/docs/install/troubleshooting.md +++ b/docs/install/troubleshooting.md @@ -247,6 +247,23 @@ faster than app releases, so when video-URL imports start failing, press **Update** there — the new version survives app updates, and **Restore tested version** reverts to the build the app shipped with. +### YouTube asks you to sign in or confirm you are not a bot + +First update yt-dlp under **Settings → Audio tools**. If YouTube still requires +your signed-in session, export its cookies in Netscape `cookies.txt` format, +then choose that file beside the URL field before importing. VoiceStudio uses +the export for that import only and makes two best-effort attempts to delete +its temporary copy. + +Cookie exports are login credentials. VoiceStudio never reads a browser's +cookie database automatically, never saves the export in your project, and +never uploads it anywhere except to your own VoiceStudio backend. Use an export +limited to YouTube where your browser extension supports domain filtering. +For a backend on another machine, the picker is enabled only over HTTPS; plain +HTTP is accepted solely on the desktop app's loopback connection. +Remote backends must also use `OMNIVOICE_API_KEY` as the bearer key and remain +restricted to a private tailnet; see [API authentication](../api-auth.md). + ## 8. Docker LAN access — media preview 404 **Symptom:** VoiceStudio loads on `http://:3900` but the audio preview diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index f177b98e..24471215 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -2947,6 +2947,7 @@ dependencies = [ "dirs-next", "enigo", "fs4", + "getrandom 0.3.4", "libc", "log", "reqwest", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 221bed66..68fb498f 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ tauri-build = { version = "2.6.0", features = [] } [dependencies] serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } +getrandom = "0.3" log = "0.4" tauri = { version = "2.11.0", features = ["macos-private-api", "protocol-asset", "tray-icon", "image-png"] } tauri-plugin-log = "2" diff --git a/frontend/src-tauri/src/commands.rs b/frontend/src-tauri/src/commands.rs index b619c81d..4be42a13 100644 --- a/frontend/src-tauri/src/commands.rs +++ b/frontend/src-tauri/src/commands.rs @@ -5,13 +5,119 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; use std::time::Duration; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tauri::image::Image; use crate::{AppFlags, TrayHandle, DictationShortcutState}; use crate::{TRAY_ICON_DEFAULT, TRAY_ICON_RECORDING}; use crate::config::{load_config, save_config}; +// ── Native host-path authorization ─────────────────────────────────────── + +#[derive(Serialize, Deserialize)] +struct AuthorizedHostPath { + token: String, + kind: String, + path: String, +} + +pub fn path_authorization_dir(app: &tauri::AppHandle) -> PathBuf { + crate::setup::resolved_data_dir(app) + .unwrap_or_else(crate::setup::default_data_dir) + .join(".path-authorizations") +} + +fn validate_host_path(kind: &str, raw: &str) -> Result { + if !matches!(kind, "models_dir" | "ffmpeg" | "ffprobe") { + return Err("Unsupported host-path capability".into()); + } + if raw.chars().any(|c| c.is_control()) { + return Err("Path contains invalid control characters".into()); + } + if kind == "models_dir" && raw.is_empty() { + return Ok(PathBuf::new()); // explicit reset to the platform default + } + let path = PathBuf::from(raw); + if !path.is_absolute() { + return Err("Path must be absolute".into()); + } + if kind == "models_dir" { + fs::create_dir_all(&path).map_err(|e| format!("Directory is not writable: {e}"))?; + let probe = path.join(".voicestudio-write-test"); + fs::write(&probe, b"ok").map_err(|e| format!("Directory is not writable: {e}"))?; + let _ = fs::remove_file(probe); + } else { + if !path.is_file() { + return Err("Selected media tool is not a file".into()); + } + let status = crate::tools::no_window( + std::process::Command::new(&path) + .arg("-version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()), + ) + .status() + .map_err(|e| format!("Selected media tool could not run: {e}"))?; + if !status.success() { + return Err("Selected media tool failed its version check".into()); + } + } + Ok(path) +} + +#[tauri::command] +pub fn authorize_host_path( + app: tauri::AppHandle, + kind: String, + path: String, +) -> Result { + let validated = validate_host_path(&kind, path.trim())?; + let mut random = [0_u8; 32]; + getrandom::fill(&mut random).map_err(|e| format!("Secure randomness unavailable: {e}"))?; + let token: String = random.iter().map(|b| format!("{b:02x}")).collect(); + let dir = path_authorization_dir(&app); + fs::create_dir_all(&dir).map_err(|e| format!("Could not create authorization store: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)) + .map_err(|e| format!("Could not protect authorization store: {e}"))?; + } + let target = dir.join(format!("{token}.json")); + let payload = AuthorizedHostPath { + token: token.clone(), + kind, + path: validated.to_string_lossy().into_owned(), + }; + fs::write(&target, serde_json::to_vec(&payload).map_err(|e| e.to_string())?) + .map_err(|e| format!("Could not authorize path: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("Could not protect authorization: {e}"))?; + } + Ok(token) +} + +#[cfg(test)] +mod host_path_authorization_tests { + use super::validate_host_path; + use std::path::PathBuf; + + #[test] + fn rejects_unknown_relative_and_control_character_paths() { + assert!(validate_host_path("shell", "/tmp/tool").is_err()); + assert!(validate_host_path("models_dir", "relative/models").is_err()); + assert!(validate_host_path("models_dir", "/tmp/bad\npath").is_err()); + } + + #[test] + fn empty_models_path_is_the_authorized_default_reset() { + assert_eq!(validate_host_path("models_dir", "").unwrap(), PathBuf::new()); + } +} + // ── System metrics ──────────────────────────────────────────────────────── #[derive(Serialize, Clone)] diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 4313d44f..71c7897e 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -422,6 +422,7 @@ pub fn run() { updater_channel::install_update, updater_channel::list_releases, commands::get_sysinfo, + commands::authorize_host_path, commands::read_log_tail, commands::hf_cache_scan, commands::simulate_paste, diff --git a/frontend/src-tauri/src/tools.rs b/frontend/src-tauri/src/tools.rs index 974afaf6..4ebab9ef 100644 --- a/frontend/src-tauri/src/tools.rs +++ b/frontend/src-tauri/src/tools.rs @@ -3,7 +3,7 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::{Command, Output, Stdio}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -410,7 +410,7 @@ pub fn resolve_uv( log::info!("Using bundled uv at {}", p.display()); return Ok(p); } - if no_window(Command::new("uv").arg("--version")).output().is_ok() { + if uv_is_usable(Path::new("uv")) { log::info!("Using system uv from PATH"); return Ok(PathBuf::from("uv")); } @@ -427,11 +427,11 @@ pub fn resolve_uv( /// Windows: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/{version}/install.ps1 | iex"` /// /// The installer handles platform detection, checksums, and extraction -/// automatically. We control the install directory via `UV_INSTALL_DIR`. -/// Idempotent: if the binary is already present, returns its path immediately. +/// automatically. `UV_UNMANAGED_INSTALL` keeps this app-private tool out of +/// the user's PATH and shell profiles on every platform. fn install_uv_standalone(dest: &Path, _region: &str) -> io::Result { let uv_bin = dest.join(if cfg!(windows) { "uv.exe" } else { "uv" }); - if uv_bin.is_file() { + if uv_is_usable(&uv_bin) { return Ok(uv_bin); } fs::create_dir_all(dest)?; @@ -439,28 +439,24 @@ fn install_uv_standalone(dest: &Path, _region: &str) -> io::Result { #[cfg(unix)] { - let status = Command::new("sh") - .args([ + let output = configure_uv_installer( + Command::new("sh").args([ "-c", &format!( - "curl -LsSf https://astral.sh/uv/{}/install.sh | sh -s -- --no-modify-path", + "curl -LsSf https://astral.sh/uv/{}/install.sh | sh", UV_VERSION ), - ]) - .env("UV_INSTALL_DIR", dest) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .status() - .map_err(|e| io::Error::new( + ]), + dest, + ) + .output() + .map_err(|e| { + io::Error::new( io::ErrorKind::Other, format!("uv installer launch failed (is curl installed?): {}", e), - ))?; - if !status.success() { - return Err(io::Error::new( - io::ErrorKind::Other, - format!("uv installer exited with code {:?}", status.code()), - )); - } + ) + })?; + return finish_uv_install(dest, &uv_bin, output); } #[cfg(windows)] @@ -472,39 +468,328 @@ fn install_uv_standalone(dest: &Path, _region: &str) -> io::Result { // Windows: `CREATE_NO_WINDOW` so the uv installer's PowerShell doesn't // flash a console window during first-run bootstrap. stdout/stderr are // piped, so nothing is lost. - let status = no_window( - Command::new("powershell") - .args(["-ExecutionPolicy", "ByPass", "-c", &script]) - .env("UV_INSTALL_DIR", dest) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()), - ) - .status() - .map_err(|e| io::Error::new( - io::ErrorKind::Other, - format!("uv PowerShell installer failed: {}", e), - ))?; - if !status.success() { - return Err(io::Error::new( + let mut command = Command::new("powershell"); + command.args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "ByPass", + "-c", + &script, + ]); + configure_uv_installer(&mut command, dest); + let output = no_window(&mut command).output().map_err(|e| { + io::Error::new( io::ErrorKind::Other, - format!("uv installer exited with code {:?}", status.code()), - )); - } + format!("uv PowerShell installer failed: {}", e), + ) + })?; + return finish_uv_install(dest, &uv_bin, output); } - if uv_bin.is_file() { - log::info!("uv installed successfully at {}", uv_bin.display()); - Ok(uv_bin) - } else { - let alt = dest.join("bin").join(if cfg!(windows) { "uv.exe" } else { "uv" }); - if alt.is_file() { - fs::rename(&alt, &uv_bin)?; - log::info!("uv moved from bin/ to {}", uv_bin.display()); - return Ok(uv_bin); + #[allow(unreachable_code)] + Err(io::Error::new( + io::ErrorKind::Unsupported, + "unsupported uv install platform", + )) +} + +fn configure_uv_installer<'a>(command: &'a mut Command, dest: &Path) -> &'a mut Command { + // The official unmanaged mode is designed for app-private/CI installs: it + // selects the destination and disables PATH, profile, and self-update + // mutations. Explicitly remove the legacy variable so a parent shell + // cannot leave the installer in two conflicting modes. + command + .env_remove("UV_INSTALL_DIR") + .env("UV_UNMANAGED_INSTALL", dest) + .env("UV_NO_MODIFY_PATH", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) +} + +fn uv_is_usable(path: &Path) -> bool { + no_window( + Command::new(path) + .arg("--version") + .stdout(Stdio::piped()) + .stderr(Stdio::null()), + ) + .output() + .map(|output| output.status.success() && uv_version_matches(&output.stdout)) + .unwrap_or(false) +} + +fn uv_version_matches(output: &[u8]) -> bool { + let Ok(text) = std::str::from_utf8(output) else { + return false; + }; + let mut fields = text.split_whitespace(); + fields.next() == Some("uv") && fields.next() == Some(UV_VERSION) +} + +fn finish_uv_install(dest: &Path, uv_bin: &Path, output: Output) -> io::Result { + finish_uv_install_with_probe(dest, uv_bin, output, uv_is_usable) +} + +fn finish_uv_install_with_probe( + dest: &Path, + uv_bin: &Path, + output: Output, + is_usable: F, +) -> io::Result +where + F: Fn(&Path) -> bool, +{ + let alt = dest.join("bin").join(if cfg!(windows) { "uv.exe" } else { "uv" }); + if !is_usable(uv_bin) && is_usable(&alt) { + fs::rename(&alt, uv_bin).or_else(|_| fs::copy(&alt, uv_bin).map(|_| ()))?; + } + + // Some installer failures happen after extraction (for example while + // editing a Windows shell profile). The installed executable is the real + // postcondition: accepting a verified binary makes first run self-heal in + // this process instead of requiring a restart. Never accept a partial or + // corrupt file merely because it exists. + if is_usable(uv_bin) { + if output.status.success() { + log::info!("uv installed successfully at {}", uv_bin.display()); + } else { + log::warn!( + "uv installer exited with {:?}, but the installed binary passed validation at {}", + output.status.code(), + uv_bin.display() + ); } - Err(io::Error::new( - io::ErrorKind::NotFound, - format!("uv binary not found at {} after installer completed", uv_bin.display()), - )) + return Ok(uv_bin.to_path_buf()); + } + + let detail = installer_output_detail(&output); + Err(io::Error::new( + io::ErrorKind::Other, + if output.status.success() { + format!( + "uv installer completed but no usable binary was found at {}{}", + uv_bin.display(), + detail + ) + } else { + format!("uv installer exited with code {:?}{}", output.status.code(), detail) + }, + )) +} + +fn installer_output_detail(output: &Output) -> String { + let bytes = if output.stderr.is_empty() { + &output.stdout + } else { + &output.stderr + }; + let text = String::from_utf8_lossy(bytes); + let mut text = text.trim().to_string(); + if text.is_empty() { + return String::new(); + } + for key in ["USERPROFILE", "HOME"] { + if let Some(home) = std::env::var_os(key).and_then(|value| value.into_string().ok()) { + text = redact_home_prefix(&text, &home); + } + } + let start = text + .char_indices() + .rev() + .nth(1999) + .map(|(index, _)| index) + .unwrap_or(0); + format!(": {}", &text[start..]) +} + +fn redact_home_prefix(text: &str, home: &str) -> String { + if home.len() < 3 { + return text.to_string(); + } + let mut redacted = text.replace(home, "~"); + let forward = home.replace('\\', "/"); + let backward = home.replace('/', "\\"); + if forward != home { + redacted = redacted.replace(&forward, "~"); + } + if backward != home { + redacted = redacted.replace(&backward, "~"); + } + redacted +} + +#[cfg(test)] +mod uv_tests { + use super::*; + use std::ffi::OsStr; + + #[test] + fn installer_uses_app_private_unmanaged_mode() { + let mut command = Command::new("installer"); + configure_uv_installer(&mut command, Path::new("private-tools")); + let envs: std::collections::HashMap<_, _> = command.get_envs().collect(); + + assert_eq!(envs.get(OsStr::new("UV_INSTALL_DIR")), Some(&None)); + assert_eq!( + envs.get(OsStr::new("UV_UNMANAGED_INSTALL")).and_then(|value| *value), + Some(OsStr::new("private-tools")) + ); + assert_eq!( + envs.get(OsStr::new("UV_NO_MODIFY_PATH")).and_then(|value| *value), + Some(OsStr::new("1")) + ); + } + + #[test] + fn uv_version_probe_requires_the_pinned_version() { + assert!(uv_version_matches( + format!("uv {} (build-id)\n", UV_VERSION).as_bytes() + )); + assert!(!uv_version_matches(b"uv 0.10.0 (older)\n")); + assert!(!uv_version_matches(b"not-uv 0.11.7\n")); + assert!(!uv_version_matches(b"uv\n")); + assert!(!uv_version_matches(&[0xff, 0xfe])); + } + + #[test] + fn installer_error_includes_captured_stderr() { + let output = Output { + status: failure_status(), + stdout: Vec::new(), + stderr: b"profile update denied".to_vec(), + }; + assert_eq!(installer_output_detail(&output), ": profile update denied"); + } + + #[test] + fn installer_error_redacts_unix_and_windows_home_paths() { + assert_eq!( + redact_home_prefix( + "installed into /Users/alice/.local/bin", + "/Users/alice" + ), + "installed into ~/.local/bin" + ); + assert_eq!( + redact_home_prefix( + r"installed into C:\Users\alice\.local\bin", + r"C:\Users\alice" + ), + r"installed into ~\.local\bin" + ); + assert_eq!( + redact_home_prefix( + "installed into C:/Users/alice/.local/bin", + r"C:\Users\alice" + ), + "installed into ~/.local/bin" + ); + } + + #[test] + fn installer_exit_one_is_accepted_when_downloaded_uv_is_usable() { + let dest = Path::new("private-tools"); + let uv_bin = dest.join(if cfg!(windows) { "uv.exe" } else { "uv" }); + let output = Output { + status: failure_status(), + stdout: Vec::new(), + stderr: b"later installer step failed".to_vec(), + }; + + let result = finish_uv_install_with_probe(dest, &uv_bin, output, |candidate| { + candidate == uv_bin + }); + + assert_eq!(result.unwrap(), uv_bin); + } + + #[test] + fn successful_installer_without_usable_uv_is_rejected() { + let dest = Path::new("private-tools"); + let uv_bin = dest.join(if cfg!(windows) { "uv.exe" } else { "uv" }); + let output = Output { + status: success_status(), + stdout: Vec::new(), + stderr: Vec::new(), + }; + + let error = finish_uv_install_with_probe(dest, &uv_bin, output, |_| false) + .expect_err("installer success is insufficient without a usable binary"); + + assert!(error.to_string().contains("no usable binary was found")); + } + + #[test] + fn failed_installer_with_unusable_uv_reports_captured_error() { + let dest = Path::new("private-tools"); + let uv_bin = dest.join(if cfg!(windows) { "uv.exe" } else { "uv" }); + let output = Output { + status: failure_status(), + stdout: Vec::new(), + stderr: b"downloaded executable was corrupt".to_vec(), + }; + + let error = finish_uv_install_with_probe(dest, &uv_bin, output, |_| false) + .expect_err("an unusable download must not be accepted"); + + assert!(error.to_string().contains("downloaded executable was corrupt")); + } + + #[test] + fn usable_legacy_bin_location_is_relocated() { + let unique = format!( + "voicestudio-uv-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let dest = std::env::temp_dir().join(unique); + let uv_bin = dest.join(if cfg!(windows) { "uv.exe" } else { "uv" }); + let legacy = dest + .join("bin") + .join(if cfg!(windows) { "uv.exe" } else { "uv" }); + fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + fs::write(&legacy, b"verified test executable").unwrap(); + let output = Output { + status: success_status(), + stdout: Vec::new(), + stderr: Vec::new(), + }; + + let result = finish_uv_install_with_probe(&dest, &uv_bin, output, |candidate| { + candidate.is_file() + }); + + assert_eq!(result.unwrap(), uv_bin); + assert!(uv_bin.is_file()); + assert!(!legacy.exists()); + fs::remove_dir_all(dest).unwrap(); + } + + #[cfg(unix)] + fn success_status() -> std::process::ExitStatus { + use std::os::unix::process::ExitStatusExt; + std::process::ExitStatus::from_raw(0) + } + + #[cfg(windows)] + fn success_status() -> std::process::ExitStatus { + use std::os::windows::process::ExitStatusExt; + std::process::ExitStatus::from_raw(0) + } + + #[cfg(unix)] + fn failure_status() -> std::process::ExitStatus { + use std::os::unix::process::ExitStatusExt; + std::process::ExitStatus::from_raw(1 << 8) + } + + #[cfg(windows)] + fn failure_status() -> std::process::ExitStatus { + use std::os::windows::process::ExitStatusExt; + std::process::ExitStatus::from_raw(1) } } diff --git a/frontend/src/api/dub.ts b/frontend/src/api/dub.ts index fb7034c9..53aafd95 100644 --- a/frontend/src/api/dub.ts +++ b/frontend/src/api/dub.ts @@ -19,6 +19,26 @@ export interface IngestUrlOptions { fetchSubs?: boolean; /** Limit caption fetch to specific lang codes; defaults to all available. */ subLangs?: string[]; + /** Explicit cookies.txt export used only for this import. */ + cookieFile?: File; +} + +export const DUB_COOKIE_TRANSPORT_ERROR = 'DUB_COOKIE_TRANSPORT'; +export const DUB_COOKIE_SIZE_ERROR = 'DUB_COOKIE_TOO_LARGE'; +export const MAX_COOKIE_EXPORT_BYTES = 1024 * 1024; + +function cookieSelectionError(code: string): Error & { code: string } { + return Object.assign(new Error(code), { code }); +} + +export function _cookieTransportAllowed(apiBase: string): boolean { + const endpoint = new URL(apiBase, window.location.href); + return ( + endpoint.protocol === 'https:' || + endpoint.hostname === 'localhost' || + endpoint.hostname === '127.0.0.1' || + endpoint.hostname === '[::1]' + ); } export async function dubIngestUrl( @@ -26,7 +46,14 @@ export async function dubIngestUrl( jobId: string, opts: IngestUrlOptions = {}, ): Promise { - const { signal, fetchSubs, subLangs } = opts; + const { signal, fetchSubs, subLangs, cookieFile } = opts; + if (cookieFile && !_cookieTransportAllowed(API)) { + throw cookieSelectionError(DUB_COOKIE_TRANSPORT_ERROR); + } + if (cookieFile && cookieFile.size > MAX_COOKIE_EXPORT_BYTES) { + throw cookieSelectionError(DUB_COOKIE_SIZE_ERROR); + } + const cookieText = cookieFile ? await cookieFile.text() : undefined; return apiPost( '/dub/ingest-url', { @@ -34,6 +61,7 @@ export async function dubIngestUrl( job_id: jobId, fetch_subs: fetchSubs || undefined, sub_langs: subLangs && subLangs.length ? subLangs : undefined, + cookie_file: cookieText, }, { signal }, ); diff --git a/frontend/src/components/dub/IdleSkeleton.jsx b/frontend/src/components/dub/IdleSkeleton.jsx index 22e65412..02a96bd7 100644 --- a/frontend/src/components/dub/IdleSkeleton.jsx +++ b/frontend/src/components/dub/IdleSkeleton.jsx @@ -19,6 +19,7 @@ import { Download, } from 'lucide-react'; import { Button, Badge } from '../../ui'; +import { useEffect, useRef } from 'react'; import WaveformTimeline from '../WaveformTimeline'; import DubbingDemo from '../DubbingDemo'; import DubFailureNotice from './DubFailureNotice'; @@ -62,6 +63,8 @@ export default function IdleSkeleton({ onIngestUrl, fetchYtSubs, setFetchYtSubs, + youtubeCookieFile, + setYoutubeCookieFile, dubLangCode, setDubLangCode, setDubLang, @@ -70,6 +73,12 @@ export default function IdleSkeleton({ dubInstruct, setDubInstruct, }) { + const youtubeCookieInputRef = useRef(null); + useEffect(() => { + if (!youtubeCookieFile && youtubeCookieInputRef.current) { + youtubeCookieInputRef.current.value = ''; + } + }, [youtubeCookieFile]); return (
{/* Header bar */} @@ -376,6 +385,34 @@ export default function IdleSkeleton({ /> {t('dub.pull_captions')} +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + {t('dub.youtube_auth')} + e.stopPropagation()} + onChange={(e) => setYoutubeCookieFile(e.target.files?.[0] || null)} + /> + {youtubeCookieFile && ( + + )} +
{/* One decision up front: the target language. Everything else diff --git a/frontend/src/components/settings/AudioToolsPanel.jsx b/frontend/src/components/settings/AudioToolsPanel.jsx index d7808327..4d1bc522 100644 --- a/frontend/src/components/settings/AudioToolsPanel.jsx +++ b/frontend/src/components/settings/AudioToolsPanel.jsx @@ -267,7 +267,27 @@ export default function AudioToolsPanel() { const onToolAction = useCallback( async (path, body) => { - const ok = await post(path, body); + let requestBody = body; + if (path.endsWith('/custom-path')) { + try { + const { invoke } = await import('@tauri-apps/api/core'); + const tool = path.includes('/ffprobe/') ? 'ffprobe' : 'ffmpeg'; + const authorization = await invoke('authorize_host_path', { + kind: tool, + path: body?.path || '', + }); + requestBody = { authorization }; + } catch (e) { + toast.error( + t('settings.audio_tools_path_failed', { + message: e.message || String(e), + defaultValue: "Couldn't set path: {{message}}", + }), + ); + return; + } + } + const ok = await post(path, requestBody); if (ok && (path.endsWith('/custom-path') || path.endsWith('/use-system'))) { toast.success( t('settings.audio_tools_path_set', { diff --git a/frontend/src/components/settings/AudioToolsPanel.test.jsx b/frontend/src/components/settings/AudioToolsPanel.test.jsx index 34fea7e9..a596fcec 100644 --- a/frontend/src/components/settings/AudioToolsPanel.test.jsx +++ b/frontend/src/components/settings/AudioToolsPanel.test.jsx @@ -11,6 +11,8 @@ vi.mock('../../api/client', () => ({ apiJson: vi.fn(), apiFetch: vi.fn(), })); +const invoke = vi.fn(); +vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args) => invoke(...args) })); import { toast } from 'react-hot-toast'; import { apiJson, apiFetch } from '../../api/client'; @@ -57,6 +59,7 @@ describe('AudioToolsPanel — power-user surface for the media tools', () => { vi.clearAllMocks(); apiJson.mockResolvedValue(JSON.parse(JSON.stringify(STATUS))); apiFetch.mockResolvedValue(okResponse); + invoke.mockResolvedValue('c'.repeat(64)); }); it('renders one row per tool with version, path, and origin badge', async () => { @@ -85,6 +88,26 @@ describe('AudioToolsPanel — power-user surface for the media tools', () => { await waitFor(() => expect(toast.success).toHaveBeenCalled()); }); + it('sends only a native one-shot authorization for a custom executable', async () => { + render(); + fireEvent.click(await screen.findByLabelText('FFmpeg: Choose file…')); + const input = await screen.findByLabelText('FFmpeg binary path'); + fireEvent.change(input, { target: { value: '/opt/tools/ffmpeg' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('authorize_host_path', { + kind: 'ffmpeg', + path: '/opt/tools/ffmpeg', + }), + ); + expect(apiFetch).toHaveBeenCalledWith( + '/media-tools/ffmpeg/custom-path', + expect.objectContaining({ body: JSON.stringify({ authorization: 'c'.repeat(64) }) }), + ); + expect(apiFetch.mock.calls.flat().join(' ')).not.toContain('/opt/tools/ffmpeg'); + }); + it('Restore bundled is per-tool and always available (safe revert)', async () => { render(); fireEvent.click(await screen.findByLabelText('FFprobe: Restore bundled')); diff --git a/frontend/src/components/settings/StoragePanel.jsx b/frontend/src/components/settings/StoragePanel.jsx index 64d08e20..7cf29dec 100644 --- a/frontend/src/components/settings/StoragePanel.jsx +++ b/frontend/src/components/settings/StoragePanel.jsx @@ -9,7 +9,7 @@ * Endpoints: * GET /api/settings/storage/models-dir * → {configured, effective, default, restart_required} - * PUT /api/settings/storage/models-dir body {path} (empty path clears) + * Native IPC authorizes the path, then PUT sends only the one-shot token. */ import React, { useCallback, useEffect, useState } from 'react'; import { HardDrive } from 'lucide-react'; @@ -52,10 +52,12 @@ export default function StoragePanel() { setSaving(true); setError(null); try { + const { invoke } = await import('@tauri-apps/api/core'); + const authorization = await invoke('authorize_host_path', { kind: 'models_dir', path }); const res = await apiFetch('/api/settings/storage/models-dir', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path }), + body: JSON.stringify({ authorization }), }); if (!res.ok) { const b = await res.json().catch(() => ({})); diff --git a/frontend/src/components/settings/StoragePanel.test.jsx b/frontend/src/components/settings/StoragePanel.test.jsx new file mode 100644 index 00000000..262b9ce1 --- /dev/null +++ b/frontend/src/components/settings/StoragePanel.test.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +vi.mock('react-hot-toast', () => ({ default: { success: vi.fn() } })); +const apiJson = vi.fn(); +const apiFetch = vi.fn(); +vi.mock('../../api/client', () => ({ + apiJson: (...args) => apiJson(...args), + apiFetch: (...args) => apiFetch(...args), +})); +const invoke = vi.fn(); +vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args) => invoke(...args) })); + +import StoragePanel from './StoragePanel'; + +describe('StoragePanel native path boundary', () => { + it('never sends the selected models directory through HTTP', async () => { + apiJson.mockResolvedValue({ configured: '', effective: '/cache', default: '/default' }); + apiFetch.mockResolvedValue({ + ok: true, + json: async () => ({ configured: '/private/models', restart_required: true }), + }); + invoke.mockResolvedValue('d'.repeat(64)); + render(); + + const input = await screen.findByTestId('models-dir-input'); + fireEvent.change(input, { target: { value: '/private/models' } }); + fireEvent.click(screen.getByTestId('models-dir-save')); + + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('authorize_host_path', { + kind: 'models_dir', + path: '/private/models', + }), + ); + expect(apiFetch).toHaveBeenCalledWith( + '/api/settings/storage/models-dir', + expect.objectContaining({ body: JSON.stringify({ authorization: 'd'.repeat(64) }) }), + ); + expect(apiFetch.mock.calls.flat().join(' ')).not.toContain('/private/models'); + }); +}); diff --git a/frontend/src/hooks/useDubWorkflow.js b/frontend/src/hooks/useDubWorkflow.js index 1c487d41..030873ec 100644 --- a/frontend/src/hooks/useDubWorkflow.js +++ b/frontend/src/hooks/useDubWorkflow.js @@ -11,6 +11,8 @@ import { tasksCancel, transcribeStreamUrl, dubImportSrt, + DUB_COOKIE_TRANSPORT_ERROR, + DUB_COOKIE_SIZE_ERROR, } from '../api/dub'; import { dialectMatchesLang } from '../api/dialects'; import { segmentGenInputs, applySpeakerCloneDefaults } from '../utils/segments'; @@ -553,6 +555,7 @@ export default function useDubWorkflow({ signal: ctrl.signal, fetchSubs: !!opts.fetchSubs, subLangs: opts.subLangs, + cookieFile: opts.cookieFile, }); setDubJobId(data.job_id); setDubTaskId(data.task_id); @@ -592,10 +595,17 @@ export default function useDubWorkflow({ toastAsrModelMissing(asrMissingPayload(err)); useAppStore.getState().errorPill(t('asr_missing.message')); } else { - setDubError(err.message); + const cookieErrorKey = + err?.code === DUB_COOKIE_TRANSPORT_ERROR + ? 'dub.cookie_transport_error' + : err?.code === DUB_COOKIE_SIZE_ERROR + ? 'dub.cookie_size_error' + : null; + const message = cookieErrorKey ? t(cookieErrorKey) : err.message; + setDubError(message); setDubStep('idle'); - toastErrorWithReport(t('dub_workflow.ingest_failed', { message: err.message }), err); - useAppStore.getState().errorPill(err.message); + toastErrorWithReport(t('dub_workflow.ingest_failed', { message }), err); + useAppStore.getState().errorPill(message); } setTranscribeStart(null); } finally { diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json index eacefbbf..94e3a8b9 100644 --- a/frontend/src/i18n/locales/ar.json +++ b/frontend/src/i18n/locales/ar.json @@ -751,6 +751,11 @@ "paste_url": "... أو الصق عنوان URL للفيديو/YouTube", "ingest": "استيعاب", "pull_captions": "سحب التسميات التوضيحية على YouTube + الترجمات التلقائية", + "youtube_auth": "تسجيل الدخول إلى YouTube (اختياري)", + "youtube_cookie_file": "اختيار ملف cookies.txt مُصدَّر", + "remove_cookie_file": "إزالة ملف تعريف الارتباط", + "cookie_transport_error": "يتطلب تصدير ملفات تعريف الارتباط HTTPS أو تطبيق سطح المكتب المحلي.", + "cookie_size_error": "يجب ألا يتجاوز تصدير ملفات تعريف الارتباط 1 ميغابايت.", "save": "حفظ", "reset": "إعادة تعيين", "save_project": "حفظ المشروع", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 0a51a785..da9dc976 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -751,6 +751,11 @@ "paste_url": "…oder fügen Sie die YouTube-/Video-URL ein", "ingest": "Verschlucken", "pull_captions": "Rufen Sie YouTube-Untertitel und automatische Übersetzungen ab", + "youtube_auth": "YouTube-Anmeldung (optional)", + "youtube_cookie_file": "cookies.txt-Export auswählen", + "remove_cookie_file": "Cookie-Datei entfernen", + "cookie_transport_error": "Cookie-Exporte erfordern HTTPS oder die lokale Desktop-App.", + "cookie_size_error": "Der Cookie-Export darf höchstens 1 MB groß sein.", "save": "Speichern", "reset": "Zurücksetzen", "save_project": "Projekt speichern", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index cacc85e0..2ecdf470 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1031,6 +1031,11 @@ "paste_url": "…or paste YouTube / video URL", "ingest": "Ingest", "pull_captions": "Pull YouTube captions + auto-translations", + "youtube_auth": "YouTube sign-in (optional)", + "youtube_cookie_file": "Choose a cookies.txt export", + "remove_cookie_file": "Remove cookie file", + "cookie_transport_error": "Cookie exports require HTTPS or the local desktop app.", + "cookie_size_error": "The cookie export must be 1 MB or smaller.", "save": "Save", "reset": "Reset", "save_project": "Save project", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 80fe742f..f9dff902 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -751,6 +751,11 @@ "paste_url": "…o pegue la URL de YouTube/vídeo", "ingest": "Ingerir", "pull_captions": "Extraiga subtítulos de YouTube + traducciones automáticas", + "youtube_auth": "Inicio de sesión en YouTube (opcional)", + "youtube_cookie_file": "Elegir una exportación cookies.txt", + "remove_cookie_file": "Quitar archivo de cookies", + "cookie_transport_error": "Las cookies exportadas requieren HTTPS o la aplicación de escritorio local.", + "cookie_size_error": "El archivo de cookies debe tener 1 MB o menos.", "save": "Guardar", "reset": "Reiniciar", "save_project": "Guardar proyecto", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 90a72c4d..9c51f63f 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -751,6 +751,11 @@ "paste_url": "…ou collez l’URL YouTube/vidéo", "ingest": "Ingérer", "pull_captions": "Extrayez les sous-titres YouTube + les traductions automatiques", + "youtube_auth": "Connexion YouTube (facultative)", + "youtube_cookie_file": "Choisir un export cookies.txt", + "remove_cookie_file": "Supprimer le fichier de cookies", + "cookie_transport_error": "L’export de cookies nécessite HTTPS ou l’application de bureau locale.", + "cookie_size_error": "L’export de cookies doit faire 1 Mo maximum.", "save": "Enregistrer", "reset": "Réinitialiser", "save_project": "Enregistrer le projet", diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 743251fc..b278ba5d 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -751,6 +751,11 @@ "paste_url": "...या यूट्यूब/वीडियो यूआरएल पेस्ट करें", "ingest": "निगलना", "pull_captions": "YouTube कैप्शन + ऑटो-अनुवाद खींचें", + "youtube_auth": "YouTube साइन-इन (वैकल्पिक)", + "youtube_cookie_file": "cookies.txt निर्यात चुनें", + "remove_cookie_file": "कुकी फ़ाइल हटाएँ", + "cookie_transport_error": "कुकी निर्यात के लिए HTTPS या स्थानीय डेस्कटॉप ऐप आवश्यक है।", + "cookie_size_error": "कुकी निर्यात 1 MB या उससे छोटा होना चाहिए।", "save": "सहेजें", "reset": "रीसेट करें", "save_project": "प्रोजेक्ट सहेजें", diff --git a/frontend/src/i18n/locales/id.json b/frontend/src/i18n/locales/id.json index 5364daf1..cf1ebfe4 100644 --- a/frontend/src/i18n/locales/id.json +++ b/frontend/src/i18n/locales/id.json @@ -751,6 +751,11 @@ "paste_url": "…atau tempel URL YouTube/video", "ingest": "Menelan", "pull_captions": "Tarik teks YouTube + terjemahan otomatis", + "youtube_auth": "Masuk YouTube (opsional)", + "youtube_cookie_file": "Pilih ekspor cookies.txt", + "remove_cookie_file": "Hapus berkas kuki", + "cookie_transport_error": "Ekspor kuki memerlukan HTTPS atau aplikasi desktop lokal.", + "cookie_size_error": "Ekspor kuki harus berukuran 1 MB atau kurang.", "save": "Simpan", "reset": "Setel ulang", "save_project": "Simpan proyek", diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index c1ad72fd..8a95d3ce 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -751,6 +751,11 @@ "paste_url": "...o incolla l'URL di YouTube/video", "ingest": "Ingerire", "pull_captions": "Estrai sottotitoli YouTube + traduzioni automatiche", + "youtube_auth": "Accesso a YouTube (facoltativo)", + "youtube_cookie_file": "Scegli un'esportazione cookies.txt", + "remove_cookie_file": "Rimuovi file dei cookie", + "cookie_transport_error": "L’esportazione dei cookie richiede HTTPS o l’app desktop locale.", + "cookie_size_error": "L’esportazione dei cookie deve essere al massimo di 1 MB.", "save": "Salva", "reset": "Ripristina", "save_project": "Salva progetto", diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index 8425db15..be832f11 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -751,6 +751,11 @@ "paste_url": "…または YouTube / ビデオの URL を貼り付けます", "ingest": "摂取する", "pull_captions": "YouTube のキャプションと自動翻訳を取得します", + "youtube_auth": "YouTube ログイン(任意)", + "youtube_cookie_file": "cookies.txt のエクスポートを選択", + "remove_cookie_file": "Cookie ファイルを削除", + "cookie_transport_error": "Cookie のエクスポートには HTTPS またはローカルのデスクトップアプリが必要です。", + "cookie_size_error": "Cookie のエクスポートは 1 MB 以下にしてください。", "save": "保存", "reset": "リセット", "save_project": "プロジェクトの保存", diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index c54e8a2f..51addff6 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -751,6 +751,11 @@ "paste_url": "...또는 YouTube/동영상 URL을 붙여넣으세요.", "ingest": "섭취", "pull_captions": "YouTube 캡션 및 자동 번역 가져오기", + "youtube_auth": "YouTube 로그인(선택 사항)", + "youtube_cookie_file": "cookies.txt 내보내기 선택", + "remove_cookie_file": "쿠키 파일 제거", + "cookie_transport_error": "쿠키 내보내기에는 HTTPS 또는 로컬 데스크톱 앱이 필요합니다.", + "cookie_size_error": "쿠키 내보내기 파일은 1MB 이하여야 합니다.", "save": "저장", "reset": "재설정", "save_project": "프로젝트 저장", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 62989efe..363907a4 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -751,6 +751,11 @@ "paste_url": "…of plak de YouTube-/video-URL", "ingest": "Innemen", "pull_captions": "Haal YouTube-ondertitels en automatische vertalingen op", + "youtube_auth": "YouTube-aanmelding (optioneel)", + "youtube_cookie_file": "Een cookies.txt-export kiezen", + "remove_cookie_file": "Cookiebestand verwijderen", + "cookie_transport_error": "Cookie-exports vereisen HTTPS of de lokale desktop-app.", + "cookie_size_error": "De cookie-export mag maximaal 1 MB zijn.", "save": "Opslaan", "reset": "Opnieuw instellen", "save_project": "Project opslaan", diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index fdc44b9d..b01fc2ff 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -751,6 +751,11 @@ "paste_url": "…lub wklej adres URL YouTube/wideo", "ingest": "Połknąć", "pull_captions": "Pobieraj napisy z YouTube + automatyczne tłumaczenia", + "youtube_auth": "Logowanie do YouTube (opcjonalne)", + "youtube_cookie_file": "Wybierz eksport cookies.txt", + "remove_cookie_file": "Usuń plik cookie", + "cookie_transport_error": "Eksport plików cookie wymaga HTTPS lub lokalnej aplikacji komputerowej.", + "cookie_size_error": "Eksport plików cookie może mieć najwyżej 1 MB.", "save": "Zapisz", "reset": "Zresetuj", "save_project": "Zapisz projekt", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 2e0749d8..d534c34c 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -751,6 +751,11 @@ "paste_url": "…ou cole o URL do YouTube/vídeo", "ingest": "Ingerir", "pull_captions": "Obtenha legendas + traduções automáticas do YouTube", + "youtube_auth": "Login no YouTube (opcional)", + "youtube_cookie_file": "Escolher uma exportação cookies.txt", + "remove_cookie_file": "Remover arquivo de cookies", + "cookie_transport_error": "A exportação de cookies requer HTTPS ou o aplicativo local.", + "cookie_size_error": "A exportação de cookies deve ter no máximo 1 MB.", "save": "Salvar", "reset": "Redefinir", "save_project": "Salvar projeto", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index c37b91a4..500b1c1f 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -751,6 +751,11 @@ "paste_url": "…или вставьте URL-адрес YouTube/видео", "ingest": "Заглотить", "pull_captions": "Получение титров YouTube + автопереводы", + "youtube_auth": "Вход в YouTube (необязательно)", + "youtube_cookie_file": "Выбрать экспорт cookies.txt", + "remove_cookie_file": "Удалить файл cookie", + "cookie_transport_error": "Для экспорта cookie требуется HTTPS или локальное приложение.", + "cookie_size_error": "Размер экспорта cookie не должен превышать 1 МБ.", "save": "Сохранять", "reset": "Перезагрузить", "save_project": "Сохранить проект", diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index ef442ffd..0f3e0075 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -751,6 +751,11 @@ "paste_url": "…eller klistra in YouTube/videons URL", "ingest": "Inta", "pull_captions": "Dra YouTube-textning + automatiska översättningar", + "youtube_auth": "YouTube-inloggning (valfritt)", + "youtube_cookie_file": "Välj en cookies.txt-export", + "remove_cookie_file": "Ta bort cookie-filen", + "cookie_transport_error": "Cookie-exporter kräver HTTPS eller den lokala skrivbordsappen.", + "cookie_size_error": "Cookie-exporten får vara högst 1 MB.", "save": "Spara", "reset": "Återställ", "save_project": "Spara projekt", diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json index 1b60a2c8..e6cae4f4 100644 --- a/frontend/src/i18n/locales/th.json +++ b/frontend/src/i18n/locales/th.json @@ -751,6 +751,11 @@ "paste_url": "…หรือวาง URL ของ YouTube / วิดีโอ", "ingest": "นำเข้า", "pull_captions": "ดึงคำบรรยาย YouTube + การแปลอัตโนมัติ", + "youtube_auth": "ลงชื่อเข้าใช้ YouTube (ไม่บังคับ)", + "youtube_cookie_file": "เลือกไฟล์ส่งออก cookies.txt", + "remove_cookie_file": "ลบไฟล์คุกกี้", + "cookie_transport_error": "การส่งออกคุกกี้ต้องใช้ HTTPS หรือแอปเดสก์ท็อปในเครื่อง", + "cookie_size_error": "ไฟล์ส่งออกคุกกี้ต้องมีขนาดไม่เกิน 1 MB", "save": "บันทึก", "reset": "รีเซ็ต", "save_project": "บันทึกโครงการ", diff --git a/frontend/src/i18n/locales/tr.json b/frontend/src/i18n/locales/tr.json index ed0cb13a..a9ad5c28 100644 --- a/frontend/src/i18n/locales/tr.json +++ b/frontend/src/i18n/locales/tr.json @@ -751,6 +751,11 @@ "paste_url": "…veya YouTube / video URL'sini yapıştırın", "ingest": "Al", "pull_captions": "YouTube altyazılarını + otomatik çevirileri çekin", + "youtube_auth": "YouTube oturumu (isteğe bağlı)", + "youtube_cookie_file": "Bir cookies.txt dışa aktarımı seçin", + "remove_cookie_file": "Çerez dosyasını kaldır", + "cookie_transport_error": "Çerez dışa aktarımları HTTPS veya yerel masaüstü uygulamasını gerektirir.", + "cookie_size_error": "Çerez dışa aktarımı en fazla 1 MB olmalıdır.", "save": "Kaydet", "reset": "Sıfırla", "save_project": "Projeyi kaydet", diff --git a/frontend/src/i18n/locales/uk.json b/frontend/src/i18n/locales/uk.json index e597d5d2..4c6cdb1d 100644 --- a/frontend/src/i18n/locales/uk.json +++ b/frontend/src/i18n/locales/uk.json @@ -751,6 +751,11 @@ "paste_url": "…або вставте URL-адресу YouTube/відео", "ingest": "Проковтнути", "pull_captions": "Витягніть субтитри YouTube + автоматичний переклад", + "youtube_auth": "Вхід у YouTube (необов’язково)", + "youtube_cookie_file": "Вибрати експорт cookies.txt", + "remove_cookie_file": "Видалити файл cookie", + "cookie_transport_error": "Для експорту cookie потрібен HTTPS або локальний застосунок.", + "cookie_size_error": "Розмір експорту cookie не повинен перевищувати 1 МБ.", "save": "зберегти", "reset": "Скинути", "save_project": "Зберегти проект", diff --git a/frontend/src/i18n/locales/vi.json b/frontend/src/i18n/locales/vi.json index 83c35ec4..3f96f9bf 100644 --- a/frontend/src/i18n/locales/vi.json +++ b/frontend/src/i18n/locales/vi.json @@ -751,6 +751,11 @@ "paste_url": "…hoặc dán URL YouTube/video", "ingest": "Nhập", "pull_captions": "Kéo phụ đề YouTube + bản dịch tự động", + "youtube_auth": "Đăng nhập YouTube (tùy chọn)", + "youtube_cookie_file": "Chọn bản xuất cookies.txt", + "remove_cookie_file": "Xóa tệp cookie", + "cookie_transport_error": "Tệp cookie xuất yêu cầu HTTPS hoặc ứng dụng máy tính cục bộ.", + "cookie_size_error": "Tệp cookie xuất phải có dung lượng không quá 1 MB.", "save": "Lưu", "reset": "Đặt lại", "save_project": "Lưu dự án", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 65613efc..ce813b87 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -710,6 +710,11 @@ "paste_url": "…或粘贴 YouTube / 视频链接", "ingest": "导入", "pull_captions": "拉取 YouTube 字幕 + 自动翻译", + "youtube_auth": "YouTube 登录(可选)", + "youtube_cookie_file": "选择导出的 cookies.txt", + "remove_cookie_file": "移除 Cookie 文件", + "cookie_transport_error": "Cookie 导出文件只能通过 HTTPS 或本地桌面应用发送。", + "cookie_size_error": "Cookie 导出文件必须小于或等于 1 MB。", "save": "保存", "reset": "重置", "save_project": "保存项目", diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index cd7a5523..edff5cd5 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -751,6 +751,11 @@ "paste_url": "…或貼上 YouTube/影片 URL", "ingest": "攝取", "pull_captions": "擷取 YouTube 字幕 + 自動翻譯", + "youtube_auth": "YouTube 登入(選用)", + "youtube_cookie_file": "選擇匯出的 cookies.txt", + "remove_cookie_file": "移除 Cookie 檔案", + "cookie_transport_error": "Cookie 匯出檔只能透過 HTTPS 或本機桌面應用程式傳送。", + "cookie_size_error": "Cookie 匯出檔必須小於或等於 1 MB。", "save": "儲存", "reset": "重置", "save_project": "保存項目", diff --git a/frontend/src/pages/DubTab.jsx b/frontend/src/pages/DubTab.jsx index 42afa813..7555747a 100644 --- a/frontend/src/pages/DubTab.jsx +++ b/frontend/src/pages/DubTab.jsx @@ -415,13 +415,20 @@ export default function DubTab(props) { // component instead of the global store to avoid polluting cross-project // prefs with what's really a per-ingest choice. const [fetchYtSubs, setFetchYtSubs] = useState(false); + const [youtubeCookieFile, setYoutubeCookieFile] = useState(null); + const resetDubAndCredentials = useCallback(() => { + setYoutubeCookieFile(null); + resetDub?.(); + }, [resetDub]); const onIngestUrl = () => { if (!ingestUrl.trim() || !handleDubIngestUrl) return; handleDubIngestUrl(ingestUrl.trim(), { fetchSubs: fetchYtSubs, subLangs: undefined, + cookieFile: youtubeCookieFile || undefined, }); setIngestUrl(''); + setYoutubeCookieFile(null); }; // Track-switcher visibility is keyed to the persisted tracks ONLY — not the // language dropdown. Restored projects can carry finished tracks while @@ -558,6 +565,8 @@ export default function DubTab(props) { onIngestUrl={onIngestUrl} fetchYtSubs={fetchYtSubs} setFetchYtSubs={setFetchYtSubs} + youtubeCookieFile={youtubeCookieFile} + setYoutubeCookieFile={setYoutubeCookieFile} dubLangCode={dubLangCode} setDubLangCode={switchDubLangCode} setDubLang={setDubLang} @@ -578,7 +587,7 @@ export default function DubTab(props) { dubSegments={dubSegments} activeProjectName={activeProjectName} saveProject={saveProject} - resetDub={resetDub} + resetDub={resetDubAndCredentials} dubStep={dubStep} handleDubStop={handleDubStop} dubProgress={dubProgress} diff --git a/frontend/src/test/DubIdleSkeleton.test.jsx b/frontend/src/test/DubIdleSkeleton.test.jsx index d2263fbd..7b009f7c 100644 --- a/frontend/src/test/DubIdleSkeleton.test.jsx +++ b/frontend/src/test/DubIdleSkeleton.test.jsx @@ -1,10 +1,16 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { I18nextProvider } from 'react-i18next'; import i18n from '../i18n'; import IdleSkeleton from '../components/dub/IdleSkeleton'; +import { + _cookieTransportAllowed, + dubIngestUrl, + DUB_COOKIE_SIZE_ERROR, + MAX_COOKIE_EXPORT_BYTES, +} from '../api/dub'; // Regression guard for the Dub "transcribe-idle-desync" bug: on the // URL-ingest (and restored-job) path there is no local `dubVideoFile`, so the @@ -53,6 +59,8 @@ function baseProps(overrides = {}) { onIngestUrl: noop, fetchYtSubs: false, setFetchYtSubs: noop, + youtubeCookieFile: null, + setYoutubeCookieFile: noop, dubLangCode: 'en', setDubLangCode: noop, setDubLang: noop, @@ -73,11 +81,46 @@ function renderIdle(overrides) { } describe('IdleSkeleton — pipeline-stage vs idle dropzone', () => { + it('never sends cookie credentials over remote plaintext HTTP', () => { + expect(_cookieTransportAllowed('http://127.0.0.1:3900')).toBe(true); + expect(_cookieTransportAllowed('https://studio.example.test')).toBe(true); + expect(_cookieTransportAllowed('http://studio.example.test')).toBe(false); + }); + it('rejects an oversized cookie export before reading it', async () => { + const cookieFile = { + size: MAX_COOKIE_EXPORT_BYTES + 1, + text: vi.fn(), + }; + await expect( + dubIngestUrl('https://youtube.com/watch?v=abc', 'job', { cookieFile }), + ).rejects.toMatchObject({ + code: DUB_COOKIE_SIZE_ERROR, + }); + expect(cookieFile.text).not.toHaveBeenCalled(); + }); it('shows the idle dropzone only when the pipeline is truly idle (no job)', () => { const { container } = renderIdle({ dubStep: 'idle', dubJobId: null }); expect(container.querySelector('.dub-idle-drop')).not.toBeNull(); expect(screen.getByText(DROP_HINT)).toBeInTheDocument(); expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toBeInTheDocument(); + expect(screen.getByLabelText('Choose a cookies.txt export')).toBeInTheDocument(); + }); + + it('clears the native cookie picker when the selection is removed', () => { + const selected = new File(['# Netscape HTTP Cookie File\n'], 'cookies.txt', { + type: 'text/plain', + }); + const { rerender } = renderIdle({ youtubeCookieFile: selected }); + const input = screen.getByLabelText('Choose a cookies.txt export'); + fireEvent.change(input, { target: { files: [selected] } }); + expect(input.files).toHaveLength(1); + + rerender( + + + , + ); + expect(input.value).toBe(''); }); it('does NOT show the idle dropzone while transcribing a URL-ingested job', () => { diff --git a/frontend/src/test/DubTrackTabsRestore.test.jsx b/frontend/src/test/DubTrackTabsRestore.test.jsx index 73107957..84bd15cf 100644 --- a/frontend/src/test/DubTrackTabsRestore.test.jsx +++ b/frontend/src/test/DubTrackTabsRestore.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { useAppStore } from '../store'; // Regression guard for the "completed dub tracks' tabs hidden until the @@ -23,11 +23,29 @@ vi.mock('../components/dub/DubLeftColumn', () => ({ return
; }, })); -vi.mock('../components/dub/DubHeader', () => ({ default: () => null })); +vi.mock('../components/dub/DubHeader', () => ({ + default: ({ resetDub }) => ( + + ), +})); vi.mock('../components/dub/DubRightColumn', () => ({ default: () => null })); vi.mock('../components/dub/DubFooter', () => ({ default: () => null })); vi.mock('../components/dub/DubPipelineStepper', () => ({ default: () => null })); -vi.mock('../components/dub/IdleSkeleton', () => ({ default: () => null })); +vi.mock('../components/dub/IdleSkeleton', () => ({ + default: ({ youtubeCookieFile, setYoutubeCookieFile }) => ( +
+ {youtubeCookieFile?.name || 'none'} + +
+ ), +})); vi.mock('../components/ExportModal', () => ({ default: () => null })); vi.mock('../hooks/useTimelineOnsets', () => ({ default: () => ({ onsets: [] }) })); vi.mock('../api/dub', () => ({ @@ -146,4 +164,20 @@ describe('DubTab — completed tracks always show their tabs (restore P0)', () = expect(left.hasDubbedTrack).toBe(false); expect(left.previewMode).toBe('original'); }); + + it('clears a selected cookie export when a completed dub is reset', () => { + const resetDub = vi.fn(() => + useAppStore.setState({ dubJobId: null, dubStep: 'idle', dubTracks: [] }), + ); + useAppStore.setState({ dubJobId: null, dubStep: 'idle', dubTracks: [] }); + render(); + + fireEvent.click(screen.getByTestId('select-cookie')); + expect(screen.getByTestId('cookie-name')).toHaveTextContent('cookies.txt'); + act(() => useAppStore.setState({ dubJobId: 'job1', dubStep: 'done' })); + fireEvent.click(screen.getByTestId('reset-dub')); + + expect(resetDub).toHaveBeenCalledOnce(); + expect(screen.getByTestId('cookie-name')).toHaveTextContent('none'); + }); }); diff --git a/omnivoice/models/omnivoice.py b/omnivoice/models/omnivoice.py index b93913c2..08210139 100644 --- a/omnivoice/models/omnivoice.py +++ b/omnivoice/models/omnivoice.py @@ -75,6 +75,16 @@ from omnivoice.utils.voice_design import ( logger = logging.getLogger(__name__) +_AUDIO_TOKENIZER_FALLBACK_REPO = "eustlb/higgs-audio-v2-tokenizer" + + +class OmniVoiceModelAssetError(RuntimeError): + """A fixed nested model repository failed while OmniVoice was loading.""" + + def __init__(self, repository_id: str): + super().__init__(f"Failed to load OmniVoice model asset: {repository_id}") + self.repository_id = repository_id + # --------------------------------------------------------------------------- # Dataclasses @@ -345,18 +355,25 @@ class OmniVoice(PreTrainedModel): if not os.path.isdir(audio_tokenizer_path): # Fallback to the HuggingFace Hub path of transformers' # HiggsAudioV2Tokenizer if the local subdirectory doesn't exist. - audio_tokenizer_path = "eustlb/higgs-audio-v2-tokenizer" + audio_tokenizer_path = _AUDIO_TOKENIZER_FALLBACK_REPO # higgs-audio-v2-tokenizer does not support MPS (output channels > 65536) tokenizer_device = ( "cpu" if str(model.device).startswith("mps") else model.device ) - model.audio_tokenizer = _audio_tokenizer_cls().from_pretrained( - audio_tokenizer_path, device_map=tokenizer_device - ) - model.feature_extractor = AutoFeatureExtractor.from_pretrained( - audio_tokenizer_path - ) + try: + model.audio_tokenizer = _audio_tokenizer_cls().from_pretrained( + audio_tokenizer_path, device_map=tokenizer_device + ) + model.feature_extractor = AutoFeatureExtractor.from_pretrained( + audio_tokenizer_path + ) + except Exception as exc: + if audio_tokenizer_path != _AUDIO_TOKENIZER_FALLBACK_REPO: + raise + raise OmniVoiceModelAssetError( + _AUDIO_TOKENIZER_FALLBACK_REPO + ) from exc model.sampling_rate = model.feature_extractor.sampling_rate diff --git a/omnivoice/utils/voice_design.py b/omnivoice/utils/voice_design.py index e7800229..aeaf4dd1 100644 --- a/omnivoice/utils/voice_design.py +++ b/omnivoice/utils/voice_design.py @@ -108,7 +108,12 @@ def sanitize_instruct(raw): """ if not raw: return "" - return _valid_instruct_from_items(re.split(r"\s*[,,]\s*", str(raw).strip())) + # Strip each comma-delimited item after splitting. A pattern with ``\s*`` + # on both sides of the delimiter backtracks quadratically when a poisoned + # stored value contains a long whitespace run without a comma (GHAS #778). + return _valid_instruct_from_items( + item.strip() for item in re.split(r"[,,]", str(raw).strip()) + ) def instruct_from_vd_states(vd_states): diff --git a/pyproject.toml b/pyproject.toml index 432ab929..3a7651db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,9 @@ dependencies = [ "pyannote-audio>=3.3.2,<4.0", "pyinstaller>=6.19.0", "imageio-ffmpeg>=0.6.0", + # Directly used by video_context; 12.1 adds get_flattened_data(), the + # replacement for getdata() ahead of its Pillow 14 removal. + "pillow>=12.1.0", "pedalboard>=0.9.14", # Primary ASR — cross-platform, CTranslate2-based under the hood. WhisperX # adds wav2vec2 forced alignment (±10-30 ms word timing vs Whisper's own diff --git a/tests/backend/api/test_engines_route_shape.py b/tests/backend/api/test_engines_route_shape.py index ba41d4e8..97094ebc 100644 --- a/tests/backend/api/test_engines_route_shape.py +++ b/tests/backend/api/test_engines_route_shape.py @@ -20,6 +20,7 @@ from __future__ import annotations import re import sys +from time import perf_counter import pytest @@ -420,6 +421,45 @@ def test_select_mlx_audio_raw_repo_id_accepted(fresh_app, monkeypatch): assert _prefs.get("mlx_audio_model_id") == "mlx-community/Some-Other-Model-4bit" +def test_select_mlx_audio_repo_id_accepts_underscore_prefixes(fresh_app, monkeypatch): + _make_mlx_audio_available(monkeypatch) + r = _client(fresh_app).post( + "/engines/select", + json={ + "family": "tts", "backend_id": "mlx-audio", + "model_id": "_owner/_repo", + }, + ) + assert r.status_code == 200, r.text + + +@pytest.mark.parametrize( + "model_id", + [ + "owner/repo/extra", + "-owner/repo", + "owner/.repo", + "owner/repo--name", + "owner/repo..name", + "owner/repo.", + "owner/repo.git", + f"owner/{'a' * 97}", + "-" * 100_000, + ], +) +def test_select_mlx_audio_rejects_malformed_repo_ids(fresh_app, monkeypatch, model_id): + _make_mlx_audio_available(monkeypatch) + started = perf_counter() + r = _client(fresh_app).post( + "/engines/select", + json={"family": "tts", "backend_id": "mlx-audio", "model_id": model_id}, + ) + assert r.status_code == 400 + assert perf_counter() - started < 0.5 + assert len(r.content) < 256 + assert model_id[:100] not in r.text + + def test_select_mlx_audio_without_model_id_does_not_touch_pref(fresh_app, monkeypatch): """Selecting mlx-audio without a model_id (e.g. an older frontend) must leave any existing mlx_audio_model_id pref untouched.""" diff --git a/tests/test_api.py b/tests/test_api.py index 182b971b..fe12b1e0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -600,6 +600,55 @@ def test_set_env_allows_loopback(): os.environ["HF_TOKEN"] = original +def test_server_mode_remote_without_api_key_cannot_set_executable_path(monkeypatch, tmp_path): + """GHAS #506: bare Docker exposure must not become an executable setter.""" + from fastapi.testclient import TestClient + from main import app + + executable = tmp_path / "ffmpeg" + executable.write_bytes(b"not actually executable") + original = os.environ.get("FFMPEG_PATH") + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) + try: + response = TestClient(app, client=("172.17.0.1", 50000)).post( + "/system/set-env", + json={"key": "FFMPEG_PATH", "value": str(executable)}, + ) + assert response.status_code == 403 + assert os.environ.get("FFMPEG_PATH") == original + finally: + if original is None: + os.environ.pop("FFMPEG_PATH", None) + else: + os.environ["FFMPEG_PATH"] = original + + +def test_set_env_never_accepts_executable_path_even_with_admin_key(monkeypatch, tmp_path): + """Executable selection exists only behind native IPC, never this API.""" + from fastapi.testclient import TestClient + from main import app + + executable = tmp_path / "ffmpeg" + executable.write_bytes(b"not actually executable") + original = os.environ.get("FFMPEG_PATH") + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret") + try: + response = TestClient(app, client=("172.17.0.1", 50000)).post( + "/system/set-env", + headers={"authorization": "Bearer s3cret"}, + json={"key": "FFMPEG_PATH", "value": str(executable)}, + ) + assert response.status_code == 400 + assert os.environ.get("FFMPEG_PATH") == original + finally: + if original is None: + os.environ.pop("FFMPEG_PATH", None) + else: + os.environ["FFMPEG_PATH"] = original + + def test_set_env_loopback_still_validates_allowlist(): """Even on the loopback path, keys outside the allow-list must return 400 — the new guard must NOT bypass the existing allow-list enforcement.""" @@ -686,4 +735,3 @@ def test_static_audio_served_with_canonical_mime(): ) finally: tmp_wav.unlink(missing_ok=True) - diff --git a/tests/test_asr_model_missing.py b/tests/test_asr_model_missing.py index 5f2b6efa..e47b8abc 100644 --- a/tests/test_asr_model_missing.py +++ b/tests/test_asr_model_missing.py @@ -366,7 +366,8 @@ class TestEndpoints: _offline_asr_missing(cached=True): r = client.get("/dub/transcribe-stream/j4") assert r.status_code == 200 - assert "audio load failed" in r.text + assert "Transcription failed. Check the selected ASR engine and try again." in r.text + assert str(wav) not in r.text backend.unload.assert_called_once() diff --git a/tests/test_classify_401_substring.py b/tests/test_classify_401_substring.py new file mode 100644 index 00000000..3c1360a1 --- /dev/null +++ b/tests/test_classify_401_substring.py @@ -0,0 +1,126 @@ +"""Three digits in a path are not an authentication failure. + +`classify()` matched a bare ``"401"`` substring, and used it to satisfy *both* +halves of the HF-auth condition — so any message containing those digits +anywhere classified as ``HF_AUTH_FAILED`` on its own. Paths, byte counts, job +ids and durations all qualify. + +It surfaced in CI when pytest's numbered temp directory reached +``pytest-401``: an audio-save failure came back telling the user to set a +valid ``HF_TOKEN``. That is worse than an unclassified error — it is a +confident wrong instruction, attached to a docs deeplink, in an auto-filed bug +report. And because it depends on a counter that changes between runs, it is +the kind of bug that passes locally forever. + +Two independent guards now: the digits must be a standalone token, and they +are no longer sufficient evidence by themselves. +""" +from __future__ import annotations + +import pytest + + +@pytest.fixture +def classify(): + from core.failure import classify as _classify + + return _classify + + +# ── the digits alone must never classify ────────────────────────────────── + +NOT_AUTH = [ + # The exact CI failure. + "Error opening '/tmp/pytest-of-runner/pytest-401/t0/speech.wav': System error.", + "wrote 4012 bytes to disk", + "job 1401 failed to start", + "sample rate 44100, offset 401", + "/home/user/Music/401 tracks/out.wav could not be written", + "took 2401ms", +] + + +@pytest.mark.parametrize("text", NOT_AUTH) +def test_a_number_containing_401_is_not_an_auth_failure(classify, text): + assert classify(text) != "HF_AUTH_FAILED" + + +def test_the_ci_failure_keeps_its_own_class(classify): + """The scratch counter must not steal the exact audio-write class.""" + ordinary = ( + "Writing the audio file failed: RuntimeError: System error. — target " + "/tmp/pytest-of-runner/pytest-3/t0/speech.wav" + ) + unlucky = ( + "Writing the audio file failed: RuntimeError: System error. — target " + "/tmp/pytest-of-runner/pytest-401/t0/speech.wav" + ) + assert classify(ordinary) == "AUDIO_IO_FAILED" + assert classify(unlucky) == "AUDIO_IO_FAILED" + + +# ── real auth failures still classify ───────────────────────────────────── + +IS_AUTH = [ + "401 Client Error: Unauthorized for url: https://huggingface.co/api/models/x", + "Invalid credentials in Authorization header (huggingface.co)", + "huggingface.co returned 401", + "hf_token is invalid or expired", + "Unauthorized: your token does not have access to this repo", +] + + +@pytest.mark.parametrize("text", IS_AUTH) +def test_a_real_auth_failure_still_classifies(classify, text): + assert classify(text) == "HF_AUTH_FAILED" + + +# ── the token boundary, independently ───────────────────────────────────── + +@pytest.mark.parametrize( + "text,expected", + [ + ("huggingface.co status 401", True), + ("huggingface.co status 4012", False), + ("huggingface.co status 1401", False), + ("huggingface.co (401)", True), + ("huggingface.co HTTP/1.1 401", True), + # A trailing sentence full stop is punctuation, not a decimal point. + ("huggingface.co status 401.", True), + # Digit-only boundaries accepted all of these (CodeRabbit, #1427). + # Each is a different family, and each needs its own guard: + # identifier context, hyphenated context, and dotted numerics. + ("huggingface.co x401y", False), + ("huggingface.co pytest-401", False), + ("huggingface.co 401.0", False), + ("huggingface.co 401.25", False), + ("huggingface.co v1.401", False), + ("huggingface.co 401k", False), + ("huggingface.co run-401-retry", False), + ("huggingface.co /tmp/pytest-of-runner/pytest-401/speech.wav", False), + ("huggingface.co clip401.wav", False), + ], +) +def test_401_is_matched_as_a_whole_number(text, expected): + from core.failure import _HTTP_401 + + assert bool(_HTTP_401.search(text)) is expected + + +@pytest.mark.parametrize( + "text", + [ + # The residual the digit-only boundary left open: an HF marker in the + # same message meant any of these still reached HF_AUTH_FAILED, because + # the subject half was satisfied by "huggingface" and the symptom half + # by the stray digits. + "huggingface.co upload failed writing /tmp/pytest-401/chunk.bin", + "huggingface.co cache entry clip401.wav could not be opened", + "huggingface.co model v1.401 is not available", + ], +) +def test_an_hf_marker_does_not_make_stray_digits_an_auth_failure(classify, text): + assert classify(text) != "HF_AUTH_FAILED", ( + "a message that merely mentions Hugging Face and happens to contain " + "401 elsewhere was given the 'set a valid HF_TOKEN' remedy" + ) diff --git a/tests/test_corrupt_weights_recovery_1406.py b/tests/test_corrupt_weights_recovery_1406.py new file mode 100644 index 00000000..86c98852 --- /dev/null +++ b/tests/test_corrupt_weights_recovery_1406.py @@ -0,0 +1,355 @@ +"""A weight file that is present but unparseable is repairable (#1406). + +Two failure shapes come out of an interrupted or mangled model download, and +only one of them was handled: + +* the shard is **missing** — transformers says "does not appear to have a file + named …", and a whole recovery ladder repairs it; and +* the shard is **present with wrong bytes** — a download that stopped + mid-file, an antivirus that truncated it, a proxy that saved an HTML error + page under its name. transformers opens it happily and safetensors then + fails parsing its header-length prefix. + +The second reached the user as a raw 500 — "Error while deserializing header: +header too large" — on every generation, from voice design and gallery +previews alike, with no repair attempted. It could not reach the ladder for +two independent reasons: the wording is not the missing-shard wording, and +``SafetensorError`` is a Rust-extension exception, not an ``OSError``. + +It also needs the *opposite* repair. The ladder resumes a download, and a +resume trusts a blob that is already the expected size — so it would never +re-fetch the one file that is actually wrong. +""" +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def failure(): + """Resolved at run time: other suites reset `sys.modules` for app modules, + and a module-level binding here could assert against a stale phrase table.""" + import core.failure as _failure + + return _failure + + +# ── classification ───────────────────────────────────────────────────────── + +REPORTED = "Error while deserializing header: header too large" + +CORRUPT_WORDINGS = [ + REPORTED, + "SafetensorError: Error while deserializing header: HeaderTooLarge", + "safetensors_rust.SafetensorError: MetadataIncompleteBuffer", + "InvalidHeaderDeserialization", + "UnpicklingError: invalid load key, '<'.", + "RuntimeError: unexpected end of file while loading model.safetensors", + "It looks like the config file at 'models/snapshots/rev/config.json' " + "is not a valid JSON file.", +] + + +@pytest.mark.parametrize("text", CORRUPT_WORDINGS) +def test_corrupt_wordings_are_recognised(failure, text): + assert failure.is_corrupt_weights_message(text) + + +@pytest.mark.parametrize("text", CORRUPT_WORDINGS) +def test_corrupt_wordings_classify_as_a_damaged_cache(failure, text): + """Same taxonomy class as the missing-shard half: same cause, same remedy, + same docs deeplink. Before the fix these classified as "" and shipped with + no hint and no docs link.""" + assert failure.classify(text) == "MODEL_CACHE_CORRUPT" + + +def test_the_two_halves_stay_distinct(failure): + """They are one class to the user and two repairs to the code — a resume + for the missing half, a forced re-download for the damaged half. If these + ever start matching each other's wording, the wrong repair runs.""" + missing = "repo does not appear to have a file named model.safetensors" + assert failure.is_incomplete_cache_message(missing) + assert not failure.is_corrupt_weights_message(missing) + assert failure.is_corrupt_weights_message(REPORTED) + assert not failure.is_incomplete_cache_message(REPORTED) + + +@pytest.mark.parametrize( + "text", + [ + "connection reset by peer", + "CUDA out of memory", + "No such file or directory", + "", + # Generic enough that zipfile, tarfile, gzip and a JSON parser all say + # it — on its own it must NOT trigger a multi-GB re-download. + "BadZipFile: unexpected end of file", + ], +) +def test_unrelated_failures_are_not_swallowed(failure, text): + """The load's new clause is `except Exception`, so a false positive here + would divert an unrelated failure into a multi-GB re-download.""" + assert not failure.is_corrupt_weights_message(text) + + +# ── the load path ────────────────────────────────────────────────────────── + +class _SafetensorError(Exception): + """Stands in for safetensors_rust.SafetensorError — the point being that + it is NOT an OSError, which is why the ladder never saw the real one.""" + + +@pytest.fixture +def mm(monkeypatch): + import services.model_manager as mm + + monkeypatch.setattr(mm, "_set_loading", lambda *a, **kw: None) + monkeypatch.setattr(mm, "_manual_cache_delete_hint", lambda *a, **kw: "") + monkeypatch.setattr(mm, "_repair_failure_detail", lambda *a, **kw: "") + # Per-process guards must not leak between cases. + monkeypatch.setattr(mm, "_FORCED_REDOWNLOAD_ATTEMPTED", set(), raising=False) + return mm + + +def _drive_load(mm, monkeypatch, raise_first, repair_ok=True): + """Run `_load_model_sync` with a checkpoint load that fails once.""" + calls = {"load": 0, "repair": []} + + def _fake_from_pretrained(*a, **kw): + calls["load"] += 1 + if calls["load"] == 1: + raise raise_first + return object() + + class _FakeModelClass: + from_pretrained = staticmethod(_fake_from_pretrained) + + def _fake_repair(checkpoint, force=False): + calls["repair"].append(force) + return repair_ok + + monkeypatch.setattr(mm, "_lazy_omnivoice", lambda: _FakeModelClass) + monkeypatch.setattr(mm, "_lazy_torch", lambda: __import__("types").SimpleNamespace(float16="f16")) + monkeypatch.setattr(mm, "get_best_device", lambda: "cpu") + monkeypatch.setattr(mm, "resolve_omnivoice_checkpoint", lambda: "org/model") + monkeypatch.setattr(mm, "should_preload_tts_asr", lambda: False) + monkeypatch.setattr(mm, "_repair_model_cache", _fake_repair) + monkeypatch.setattr(mm, "_selfheal_broken_snapshot_links", lambda *a, **kw: False) + return calls + + +def test_a_corrupt_shard_is_re_downloaded_and_the_load_retried(mm, monkeypatch): + """The reported bug. Before the fix this propagated as a raw 500.""" + calls = _drive_load(mm, monkeypatch, _SafetensorError(REPORTED)) + mm._load_model_sync() + assert calls["load"] == 2, "the load was not retried after the repair" + assert calls["repair"] == [True], ( + "the repair must be FORCED — a resume trusts the corrupt blob, which " + "is already the size it expects, and would never re-fetch it" + ) + + +def test_the_same_shape_wrapped_in_an_oserror_is_also_repaired(mm, monkeypatch): + """transformers wraps tensor-library failures in OSError, where the + missing-shard check would drop it as unrecognised and re-raise.""" + calls = _drive_load(mm, monkeypatch, OSError(f"Unable to load weights: {REPORTED}")) + mm._load_model_sync() + assert calls["load"] == 2 + assert calls["repair"] == [True] + + +def test_corrupt_fallback_tokenizer_repairs_its_own_repository(mm, monkeypatch): + """A nested tokenizer failure must not re-download the TTS checkpoint.""" + from omnivoice.models.omnivoice import OmniVoiceModelAssetError + + corrupt = _SafetensorError(REPORTED) + nested = OmniVoiceModelAssetError("eustlb/higgs-audio-v2-tokenizer") + nested.__cause__ = corrupt + calls = _drive_load(mm, monkeypatch, nested) + + repaired = [] + + def _repair(repository_id, force=False): + repaired.append((repository_id, force)) + return True + + monkeypatch.setattr(mm, "_repair_model_cache", _repair) + mm._load_model_sync() + + assert calls["load"] == 2 + assert repaired == [("eustlb/higgs-audio-v2-tokenizer", True)] + + +def test_unrecognized_nested_repository_cannot_redirect_repair(mm, monkeypatch): + from omnivoice.models.omnivoice import OmniVoiceModelAssetError + + nested = OmniVoiceModelAssetError("attacker/unreviewed") + nested.__cause__ = _SafetensorError(REPORTED) + calls = _drive_load(mm, monkeypatch, nested) + repaired = [] + monkeypatch.setattr( + mm, + "_repair_model_cache", + lambda repository_id, force=False: repaired.append( + (repository_id, force) + ) or True, + ) + + mm._load_model_sync() + + assert calls["load"] == 2 + assert repaired == [("org/model", True)] + + +def test_fallback_tokenizer_failure_identifies_its_repository(monkeypatch, tmp_path): + from types import SimpleNamespace + + from omnivoice.models import omnivoice as model_module + + model = SimpleNamespace(device="cpu") + monkeypatch.setattr( + model_module.PreTrainedModel, + "from_pretrained", + classmethod(lambda cls, *args, **kwargs: model), + ) + monkeypatch.setattr( + model_module.AutoTokenizer, + "from_pretrained", + lambda *args, **kwargs: object(), + ) + monkeypatch.setattr( + model_module, + "_resolve_snapshot_dir", + lambda _checkpoint: str(tmp_path), + ) + + corrupt = _SafetensorError(REPORTED) + + class BrokenTokenizer: + @classmethod + def from_pretrained(cls, *args, **kwargs): + raise corrupt + + monkeypatch.setattr(model_module, "_audio_tokenizer_cls", lambda: BrokenTokenizer) + + with pytest.raises(model_module.OmniVoiceModelAssetError) as exc_info: + model_module.OmniVoice.from_pretrained("org/model") + + assert exc_info.value.repository_id == "eustlb/higgs-audio-v2-tokenizer" + assert exc_info.value.__cause__ is corrupt + + +def test_resume_that_exposes_corruption_switches_to_forced_repair(mm, monkeypatch): + """A missing shard can mask a corrupt one until resume fills the gap.""" + calls = _drive_load( + mm, + monkeypatch, + OSError("repo does not appear to have a file named model.safetensors"), + ) + + def _load_sequence(*a, **kw): + calls["load"] += 1 + if calls["load"] == 1: + raise OSError("repo does not appear to have a file named model.safetensors") + if calls["load"] == 2: + raise OSError(f"Unable to load weights: {REPORTED}") + return object() + + monkeypatch.setattr(mm, "_lazy_omnivoice", lambda: type( + "C", (), {"from_pretrained": staticmethod(_load_sequence)} + )) + + mm._load_model_sync() + + assert calls["load"] == 3 + assert calls["repair"] == [False, True] + + +def test_the_cause_is_matched_through_the_exception_chain(mm, monkeypatch): + """transformers re-raises with the tensor error as __cause__; matching only + the outermost message would miss every wrapped case.""" + inner = _SafetensorError(REPORTED) + outer = RuntimeError("could not load the checkpoint") + outer.__cause__ = inner + calls = _drive_load(mm, monkeypatch, outer) + mm._load_model_sync() + assert calls["load"] == 2 + + +def test_an_unrepairable_shard_says_what_to_do(mm, monkeypatch): + calls = _drive_load(mm, monkeypatch, _SafetensorError(REPORTED), repair_ok=False) + with pytest.raises(RuntimeError, match="damaged"): + mm._load_model_sync() + assert calls["load"] == 1, "no point retrying a load whose repair failed" + + +def test_an_unrelated_exception_still_propagates(mm, monkeypatch): + """The new clause is broad; this is what stops it becoming a catch-all.""" + _drive_load(mm, monkeypatch, ValueError("something else entirely")) + with pytest.raises(ValueError, match="something else entirely"): + mm._load_model_sync() + + +def test_a_second_failure_does_not_re_download_again(mm, monkeypatch): + """One bad shard must not turn into a full re-download per generate + request. After one forced re-fetch that did not help, say so and stop + (CodeRabbit).""" + calls = _drive_load(mm, monkeypatch, _SafetensorError(REPORTED)) + + # First attempt: repair runs, but the reloaded weights are still bad. + def _always_bad(*a, **kw): + calls["load"] += 1 + raise _SafetensorError(REPORTED) + + monkeypatch.setattr(mm, "_lazy_omnivoice", lambda: type( + "C", (), {"from_pretrained": staticmethod(_always_bad)} + )) + with pytest.raises(RuntimeError, match="still damaged"): + mm._load_model_sync() + assert calls["repair"] == [True] + + # Second attempt: no further download, straight to the manual remedy. + with pytest.raises(RuntimeError, match="did not fix them"): + mm._load_model_sync() + assert calls["repair"] == [True], "the model was re-downloaded a second time" + + +def test_a_damaged_asr_shard_does_not_re_download_the_tts_model(mm, monkeypatch): + """With OMNIVOICE_PRELOAD_TTS_ASR on, `_load()` also pulls the Whisper + checkpoint — a different repo. Blaming (and re-downloading) the TTS model + for its damage is gigabytes that fix nothing (CodeRabbit).""" + calls = _drive_load(mm, monkeypatch, _SafetensorError(REPORTED)) + monkeypatch.setattr(mm, "should_preload_tts_asr", lambda: True) + + loaded = object() + + class _Model: + llm = loaded + + def load_asr_model(self): + raise _SafetensorError(REPORTED) + + def _load_tts_once(*a, **kw): + calls["load"] += 1 + assert kw.get("load_asr") is False + return _Model() + + monkeypatch.setattr(mm, "_lazy_omnivoice", lambda: type( + "C", (), {"from_pretrained": staticmethod(_load_tts_once)} + )) + with pytest.raises(RuntimeError, match="transcription model"): + mm._load_model_sync() + assert calls["load"] == 1, "ASR diagnosis loaded the multi-GB TTS model twice" + assert calls["repair"] == [], "the TTS checkpoint was re-downloaded for an ASR fault" + + +def test_a_corrupt_config_is_force_repaired_and_retried(mm, monkeypatch): + """#1437: a truncated config.json is the same corrupt-cache class.""" + error = OSError( + "It looks like the config file at 'models/snapshots/rev/config.json' " + "is not a valid JSON file." + ) + calls = _drive_load(mm, monkeypatch, error) + mm._load_model_sync() + assert calls["load"] == 2 + assert calls["repair"] == [True] diff --git a/tests/test_dub_transcribe.py b/tests/test_dub_transcribe.py index 94985da3..f170eb36 100644 --- a/tests/test_dub_transcribe.py +++ b/tests/test_dub_transcribe.py @@ -160,7 +160,9 @@ def test_transcribe_stream_surfaces_model_load_failure(tmp_path, monkeypatch): assert "CUDA driver init failed: simulated" in body, body -def test_transcribe_stream_never_closes_without_terminal_event(tmp_path, monkeypatch): +def test_transcribe_stream_never_closes_without_terminal_event( + tmp_path, monkeypatch, caplog +): """Regression #516: an unanticipated exception INSIDE the stream body (one that escapes the per-chunk handler, e.g. segmentation blowing up) must still end the stream with a terminal `error` then `done` — never a silent @@ -203,7 +205,7 @@ def test_transcribe_stream_never_closes_without_terminal_event(tmp_path, monkeyp # Make the post-chunk segmentation (outside the per-chunk try/except) blow # up — the exact class of "unanticipated escape" the guard must catch. def _boom_segment(*a, **k): - raise RuntimeError("segmentation exploded: simulated") + raise RuntimeError("API_KEY=dub-secret /home/alice/private-video.mp4") monkeypatch.setattr(dc, "segment_transcript", _boom_segment) # Don't touch the GPU/TTS during the test. monkeypatch.setattr(dc, "offload_tts_for_asr", lambda *a, **k: None) @@ -222,12 +224,79 @@ def test_transcribe_stream_never_closes_without_terminal_event(tmp_path, monkeyp # The stream must end with a terminal error followed by done. assert "event: error" in body, body - assert "segmentation exploded: simulated" in body, body + assert "transcription_failed" in body, body + assert "Transcription failed. Check the selected ASR engine and try again." in body, body + assert "dub-secret" not in body, body + assert "Traceback" not in body, body + assert "dub-secret" not in caplog.text + assert "/home/alice/private-video.mp4" not in caplog.text err_idx = body.rfind("event: error") done_idx = body.rfind("event: done") assert done_idx > err_idx >= 0, f"error must precede the terminal done: {body}" +def test_transcribe_chunk_failure_uses_stable_public_metadata( + tmp_path, monkeypatch, caplog +): + """Inner ASR failures must not serialize provider secrets or local paths.""" + import asyncio + from api.routers import dub_core as dc + + job_id = "t_chunk_secret" + audio = tmp_path / "a.wav" + _make_wav(audio, seconds=1.0) + dc._dub_jobs[job_id] = { + "audio_path": str(audio), "vocals_path": None, "scene_cuts": [], + } + + fake_model = MagicMock() + fake_model._asr_pipe = MagicMock() + + async def _ok_model(): + return fake_model + + class _FailingASR: + id = "fake" + + def ensure_loaded(self): + pass + + def transcribe(self, path, *, word_timestamps=True): + raise RuntimeError("TOKEN=chunk-secret /home/alice/private-audio.wav") + + def unload(self): + pass + + monkeypatch.setattr(dc, "get_model", _ok_model) + monkeypatch.setattr(dc, "_CHUNK_TRANSCRIBE_ATTEMPTS", 1) + monkeypatch.setattr(dc, "offload_tts_for_asr", lambda *a, **k: None) + monkeypatch.setattr( + "services.asr_backend.get_active_asr_backend", + lambda *a, **k: _FailingASR(), + ) + + async def _collect(): + resp = await dc.dub_transcribe_stream(job_id) + parts = [] + async for chunk in resp.body_iterator: + parts.append( + chunk.decode() if isinstance(chunk, (bytes, bytearray)) else str(chunk) + ) + return "".join(parts) + + try: + body = asyncio.run(_collect()) + finally: + dc._dub_jobs.pop(job_id, None) + + assert "transcription_failed" in body, body + assert "Transcription failed. Check the selected ASR engine and try again." in body + assert "chunk-secret" not in body + assert "/home/alice/private-audio.wav" not in body + assert "chunk-secret" not in caplog.text + assert "/home/alice/private-audio.wav" not in caplog.text + + def test_transcribe_stream_surfaces_asr_load_failure_at_preflight(tmp_path, monkeypatch): """Regression #578: the reported failure mode is the *ASR model* failing to load (WhisperX: faster-whisper weights / CTranslate2-cuDNN mismatch / the @@ -334,7 +403,10 @@ def test_transcribe_stream_preflight_crash_is_a_structured_error(monkeypatch): body = asyncio.run(_collect()) assert "event: error" in body, body - assert "job store exploded: simulated" in body, body + assert "transcription_failed" in body, body + assert "Transcription failed. Check the selected ASR engine and try again." in body, body + assert "job store exploded: simulated" not in body, body + assert "Traceback" not in body, body err_idx = body.rfind("event: error") done_idx = body.rfind("event: done") assert done_idx > err_idx >= 0, f"error must precede the terminal done: {body}" diff --git a/tests/test_generate_streaming.py b/tests/test_generate_streaming.py index 57fcb3cc..696558b9 100644 --- a/tests/test_generate_streaming.py +++ b/tests/test_generate_streaming.py @@ -111,7 +111,9 @@ def _make_deterministic_engine(engine_id="stream-fake", *, delay_s=0.0, def generate(self, text, **kw) -> torch.Tensor: type(self).calls.append((text, time.monotonic())) if fail_on_call is not None and len(type(self).calls) == fail_on_call: - raise RuntimeError("engine exploded mid-stream (test)") + raise RuntimeError( + "TOKEN=stream-secret /home/alice/private-reference.wav" + ) if delay_s: time.sleep(delay_s) # Deterministic, text-dependent waveform (crc-seeded sine-ish ramp). @@ -277,8 +279,9 @@ def test_stream_final_file_identical_to_classic_output(client, monkeypatch, assert done["duration"] > 0 -def test_stream_midstream_error_yields_error_event(client, monkeypatch, - no_omnivoice_model): +def test_stream_midstream_error_yields_error_event( + client, monkeypatch, no_omnivoice_model, caplog +): """Chunk 2 blowing up must surface as an in-band error event AFTER the already-delivered chunk — no done, no history row, no saved file.""" fake = _make_deterministic_engine(fail_on_call=2) @@ -294,7 +297,13 @@ def test_stream_midstream_error_yields_error_event(client, monkeypatch, assert "chunk" in types # chunk 0 was delivered before the crash assert types[-1] == "error" assert "done" not in types - assert events[-1][0]["detail"] # actionable message for the fallback log + error = events[-1][0] + assert error["code"] == "generation_failed" + assert error["detail"] == "Generation failed. Check the selected engine and try again." + assert "stream-secret" not in repr(error) + assert "Traceback" not in repr(error) + assert "stream-secret" not in caplog.text + assert "/home/alice/private-reference.wav" not in caplog.text after_ids = {h["id"] for h in client.get("/history").json()} assert after_ids == before_ids # nothing was recorded for the failure diff --git a/tests/test_ghas_false_positive_invariants.py b/tests/test_ghas_false_positive_invariants.py new file mode 100644 index 00000000..c5610b5b --- /dev/null +++ b/tests/test_ghas_false_positive_invariants.py @@ -0,0 +1,161 @@ +"""Regression evidence for reviewed GHAS false positives. + +These tests intentionally inspect the narrow security invariants at the +reported sinks. They keep future refactors from making a dismissed alert +silently become exploitable while avoiding heavyweight model imports. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _source(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def _function(path: str, name: str) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, str]: + source = _source(path) + tree = ast.parse(source) + node = next( + item + for item in ast.walk(tree) + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == name + ) + return node, ast.get_source_segment(source, node) or "" + + +def test_dynamic_updates_only_interpolate_allowlisted_columns_and_placeholders(): + cases = ( + ( + "backend/api/routers/glossary.py", + "update_term", + '("source", "target", "note")', + "UPDATE glossary_terms", + ), + ( + "backend/api/routers/profiles.py", + "update_profile", + '("name", "ref_text", "instruct", "language", "personality")', + "UPDATE voice_profiles", + ), + ) + for path, function, allowlist, table in cases: + _, body = _function(path, function) + assert allowlist in body + assert 'fields.append(f"{col} = ?")' in body + assert table in body + assert "params" in body + # User values and IDs are never formatted into the statement. + assert "{val}" not in body + assert "{profile_id}" not in body + assert "{term_id}" not in body + assert "{project_id}" not in body + + +def test_history_reference_query_varies_only_placeholder_arity(): + _, body = _function( + "backend/api/routers/generation.py", "_remove_wav_if_unreferenced" + ) + assert 'placeholders = ",".join("?" for _ in exclude_ids)' in body + assert 'f" AND id NOT IN ({placeholders})"' in body + assert "(audio_path, *exclude_ids)" in body + assert "{audio_path}" not in body + assert "{exclude_ids}" not in body + + +def test_media_download_sink_requires_https_size_and_sha256(): + _, body = _function("backend/services/media_tools.py", "_download") + assert 'if not url.startswith("https://")' in body + assert "done != expected_size" in body + assert "digest != expected_sha256" in body + assert body.index('url.startswith("https://")') < body.index("urlopen(") + + +def test_pypi_metadata_and_wheel_digest_reach_the_verified_download_sink(): + source = _source("backend/services/media_tools.py") + assert '_PYPI_YTDLP_URL = "https://pypi.org/pypi/yt-dlp/json"' in source + _, fetch = _function("backend/services/media_tools.py", "_fetch_pypi_ytdlp") + assert 'artifact["url"]' in fetch + assert 'artifact["digests"]["sha256"]' in fetch + _, update = _function("backend/services/media_tools.py", "_do_update_ytdlp") + assert "version, url, sha = _fetch_pypi_ytdlp()" in update + assert '_download(url, whl, sha, None, op="ytdlp_update")' in update + + +def test_diagnostic_probe_is_a_guarded_constant_https_head_request(): + source = _source("backend/core/diagnose.py") + assert '_HUB_URL = "https://huggingface.co"' in source + _, body = _function("backend/core/diagnose.py", "_check_network") + assert 'if not _HUB_URL.startswith("https://")' in body + assert 'Request(_HUB_URL, method="HEAD")' in body + assert body.index('_HUB_URL.startswith("https://")') < body.index("urlopen(") + + +def test_health_check_url_and_server_are_both_pinned_to_loopback(): + source = _source("backend/main.py") + assert 'HEALTH_URL = f"http://127.0.0.1:{_port}/health"' in source + assert 'uvicorn.run(app, host="127.0.0.1", port=_port' in source + assert "_port = network_share.backend_port()" in source + + +def test_huggingface_cache_probe_is_forced_offline(): + _, body = _function("backend/services/model_manager.py", "_checkpoint_in_local_cache") + assert "snapshot_download(checkpoint, local_files_only=True)" in body + assert "local_files_only=False" not in body + + +def test_pep562_exports_are_backed_by_lazy_attribute_resolvers(): + package = _source("omnivoice/__init__.py") + assert '__all__ = ["OmniVoice", "OmniVoiceConfig", "OmniVoiceGenerationConfig"]' in package + assert "def __getattr__(name):" in package + assert "if name in __all__:" in package + assert "return getattr(_m, name)" in package + + backend = _source("backend/engines/omnivoice_gguf/backend.py") + assert '"OmniVoiceGGUFBackend",' in backend + assert 'if name == "OmniVoiceGGUFBackend":' in backend + assert "return _make_backend_class()" in backend + + +def test_secret_error_logs_never_include_plaintext_or_ciphertext_variables(): + _, body = _function("backend/services/settings_store.py", "get_secret") + tree = ast.parse(body) + log_calls = [ + call + for call in ast.walk(tree) + if isinstance(call, ast.Call) + and isinstance(call.func, ast.Attribute) + and call.func.attr in {"error", "warning", "exception"} + ] + assert len(log_calls) == 3 + for call in log_calls: + argument_names = { + node.id for arg in call.args[1:] for node in ast.walk(arg) if isinstance(node, ast.Name) + } + assert argument_names <= {"name"} + rendered = ast.unparse(call) + assert "row" not in rendered + assert "key" not in rendered + + +def test_dataset_script_handles_are_closed_by_outer_finally_blocks(): + cases = ( + ("omnivoice/scripts/denoise_audio.py", "main"), + ("omnivoice/scripts/extract_audio_tokens.py", "main"), + ("omnivoice/scripts/extract_audio_tokens_add_noise.py", "main"), + ) + for path, function in cases: + _, body = _function(path, function) + assert "tar_writer = None" in body + assert "jsonl_file = None" in body + assert "finally:" in body + finally_body = body.rsplit("finally:", 1)[1] + assert "if tar_writer is not None:" in finally_body + assert "tar_writer.close()" in finally_body + assert "if jsonl_file is not None:" in finally_body + assert "jsonl_file.close()" in finally_body diff --git a/tests/test_hf_cache_repair.py b/tests/test_hf_cache_repair.py index 3d1c43b9..a72f8e5a 100644 --- a/tests/test_hf_cache_repair.py +++ b/tests/test_hf_cache_repair.py @@ -489,8 +489,13 @@ def test_classify_missing_weights_signature(): evt = failure.build_failure(_SIGNATURE, stage="model-load", include_diagnostic=False) assert evt["docs_topic"] == "MODEL_CACHE_CORRUPT" - assert "broken file links" in evt["hint"] - assert "repairs this automatically" in evt["hint"] + # The hint covers both halves of the class since #1406 — a file that is + # missing and one that arrived damaged — so it no longer names only the + # broken-link cause. What must survive is that it promises the automatic + # repair and names the manual fallback. + assert "missing or damaged" in evt["hint"] + assert "repairs it automatically" in evt["hint"] + assert "models----" in evt["hint"] def test_classify_repair_messages(): @@ -526,7 +531,7 @@ def test_classify_local_directory_missing_weights_signature(): evt = failure.build_failure(_SIGNATURE_LOCAL_DIR, stage="model-load", include_diagnostic=False) assert evt["docs_topic"] == "MODEL_CACHE_CORRUPT" - assert "repairs this automatically" in evt["hint"] + assert "repairs it automatically" in evt["hint"] def test_self_heal_recognises_both_wordings(): diff --git a/tests/test_llm_providers_router.py b/tests/test_llm_providers_router.py index efe29ea4..738a67a6 100644 --- a/tests/test_llm_providers_router.py +++ b/tests/test_llm_providers_router.py @@ -179,7 +179,9 @@ def test_probe_failure_detail_is_scrubbed(settings_mod, monkeypatch): "boom key=gsk-test-123 at /Users/someone/secret")) body = settings_mod.test_llm_provider("groq") assert body["ok"] is False - assert "gsk-test-123" not in body["detail"] + assert body["detail"] == "The provider request failed. Try again." + assert "gsk-test-123" not in repr(body) + assert "/Users/someone" not in repr(body) # ── /models discovery ─────────────────────────────────────────────────────── @@ -204,6 +206,17 @@ def test_models_failure_is_classified(settings_mod, monkeypatch): _fake_openai(monkeypatch, raise_exc=exc) body = settings_mod.list_llm_provider_models("groq") assert body["ok"] is False and body["kind"] == "auth" and body["models"] == [] + assert body["detail"] == "Authentication failed. Check the provider API key." + + +def test_models_failure_omits_trace_path_and_secret(settings_mod, monkeypatch): + _configure_groq(settings_mod) + private = "Traceback: key=gsk-test-123 at /home/alice/provider.py" + _fake_openai(monkeypatch, raise_exc=RuntimeError(private)) + body = settings_mod.list_llm_provider_models("groq") + assert body["kind"] == "error" + assert body["detail"] == "The provider request failed. Try again." + assert private not in repr(body) def test_models_not_truncated_under_cap(settings_mod, monkeypatch): diff --git a/tests/test_llm_skills.py b/tests/test_llm_skills.py index 903359ed..1db2e038 100644 --- a/tests/test_llm_skills.py +++ b/tests/test_llm_skills.py @@ -268,6 +268,24 @@ def test_disabled_direction_parse_uses_heuristic(skills, store, monkeypatch): assert d.tokens.get("energy") == ["urgent"] # heuristic still delivers +def test_direction_failure_returns_stable_error(skills, store, monkeypatch, caplog): + _activate_groq(store) + from services import director + + private = "Traceback: token=private-value at /home/alice/director.py" + + class _Fake: + id = "openai-compat" + def chat(self, **kw): + raise RuntimeError(private) + + monkeypatch.setattr(director, "get_active_llm_backend", lambda: _Fake()) + d = director.parse("urgent and surprised") + assert d.error == "llm-parse-failed" + assert private not in repr(d) + assert private not in caplog.text + + def test_disabled_slot_fitting_returns_no_llm_marker(skills, store, monkeypatch): _activate_groq(store) from services import speech_rate @@ -287,6 +305,24 @@ def test_disabled_slot_fitting_returns_no_llm_marker(skills, store, monkeypatch) assert res["error"] == "no-llm" and res["text"] == long_text +def test_slot_fit_failure_returns_stable_error(skills, store, monkeypatch, caplog): + _activate_groq(store) + from services import speech_rate + + private = "Traceback: token=private-value at /home/alice/rate.py" + + class _Fake: + id = "openai-compat" + def chat(self, **kw): + raise RuntimeError(private) + + monkeypatch.setattr(speech_rate, "get_active_llm_backend", lambda: _Fake()) + res = speech_rate.adjust_for_slot("x" * 30, slot_seconds=1.0, target_lang="en") + assert res["error"] == "fit-provider-failed" + assert private not in repr(res) + assert private not in caplog.text + + def test_disabled_glossary_extract_503s(skills, store): _activate_groq(store) from fastapi import HTTPException diff --git a/tests/test_loopback_server_mode.py b/tests/test_loopback_server_mode.py index 8145e248..3369f0e1 100644 --- a/tests/test_loopback_server_mode.py +++ b/tests/test_loopback_server_mode.py @@ -10,7 +10,36 @@ from types import SimpleNamespace import pytest from fastapi import HTTPException -from api.dependencies import is_loopback, is_local_host, require_local, require_loopback +def _dependency(name): + # Resolve at test execution time: other suites intentionally replace + # ``api.*`` modules in sys.modules while probing cold-start behavior. + from api import dependencies + + return getattr(dependencies, name) + + +def is_loopback(*args, **kwargs): + return _dependency("is_loopback")(*args, **kwargs) + + +def is_local_host(*args, **kwargs): + return _dependency("is_local_host")(*args, **kwargs) + + +def require_admin(*args, **kwargs): + return _dependency("require_admin")(*args, **kwargs) + + +def require_desktop(*args, **kwargs): + return _dependency("require_desktop")(*args, **kwargs) + + +def require_local(*args, **kwargs): + return _dependency("require_local")(*args, **kwargs) + + +def require_loopback(*args, **kwargs): + return _dependency("require_loopback")(*args, **kwargs) def _req(host): @@ -134,7 +163,7 @@ def test_require_local_rejects_untrusted_non_loopback(monkeypatch): assert exc.value.status_code == 403 -def _req_full(host, *, headers=None, query=None, cookies=None, pin=None): +def _req_full(host, *, headers=None, query=None, cookies=None, pin=None, method="GET"): """Richer stub carrying the channels the admin-credential check reads: headers, query params, cookies, and app.state.network_share.pin.""" ns = SimpleNamespace(pin=pin) if pin is not None else None @@ -145,6 +174,7 @@ def _req_full(host, *, headers=None, query=None, cookies=None, pin=None): query_params=query or {}, cookies=cookies or {}, app=app, + method=method, ) @@ -231,6 +261,62 @@ def test_server_mode_loopback_admin_never_needs_credential(monkeypatch): require_loopback(_req_full("127.0.0.1")) # must not raise +# GHAS #506/#440/#441: require_loopback permits an unconfigured bare Docker +# server for compatibility. RCE/filesystem-capable routers use the stricter, +# method-aware admin gate instead. + + +@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"]) +def test_server_mode_admin_mutation_requires_api_key_when_unconfigured(monkeypatch, method): + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) + with pytest.raises(HTTPException) as exc: + require_admin(_req_full("172.17.0.1", method=method)) + assert exc.value.status_code == 403 + + +def test_server_mode_admin_read_keeps_bare_docker_bootstrap(monkeypatch): + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) + require_admin(_req_full("172.17.0.1", method="GET")) + + +def test_server_mode_admin_read_keeps_pin_only_discovery(monkeypatch): + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) + monkeypatch.setenv("OMNIVOICE_SHARE_PIN", "123456") + require_admin(_req_full("172.17.0.1", method="GET")) + + +def test_server_mode_admin_mutation_allows_api_key(monkeypatch): + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret") + require_admin(_req_full( + "172.17.0.1", + method="POST", + headers={"authorization": "Bearer s3cret"}, + )) + + +def test_server_mode_desktop_capability_rejects_remote_api_key(monkeypatch): + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret") + with pytest.raises(HTTPException) as exc: + require_desktop(_req_full( + "172.17.0.1", + method="POST", + headers={"authorization": "Bearer s3cret"}, + )) + assert exc.value.status_code == 403 + + +@pytest.mark.parametrize("method", ["GET", "POST", "PUT", "DELETE"]) +def test_loopback_admin_never_needs_api_key(monkeypatch, method): + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) + require_admin(_req_full("127.0.0.1", method=method)) + + def test_is_local_host_unwraps_ipv4_mapped_ipv6(monkeypatch): # Dual-stack proxies (Caddy, Node.js) pass ::ffff:192.168.1.5 — should # match an IPv4 CIDR after unwrapping the mapped address. diff --git a/tests/test_media_tools.py b/tests/test_media_tools.py index bee4309a..94cd8f1e 100644 --- a/tests/test_media_tools.py +++ b/tests/test_media_tools.py @@ -10,6 +10,7 @@ from __future__ import annotations import hashlib import io +import json import os import zipfile from unittest.mock import patch @@ -27,6 +28,10 @@ def mt(monkeypatch, tmp_path): monkeypatch.setattr(prefs, "_PREFS_PATH", str(tmp_path / "prefs.json")) monkeypatch.setattr(mt_mod, "media_tools_dir", lambda: str(tmp_path / "media_tools")) + auth_dir = tmp_path / "path-authorizations" + auth_dir.mkdir() + from core import path_authorization + monkeypatch.setattr(path_authorization, "_AUTH_DIR", str(auth_dir)) for op in mt_mod._ops.values(): op.update(state="idle", progress=0.0, error=None) mt_mod._version_cache.clear() @@ -382,11 +387,28 @@ def test_router_status_and_acquire_endpoints(mt, monkeypatch): assert r.json()["state"] == "running" -def test_router_custom_path_maps_validation_to_400(mt): +def test_router_custom_path_rejects_raw_http_path(mt): c = _client() r = c.post("/media-tools/ffmpeg/custom-path", json={"path": "/no/such/binary"}) - assert r.status_code == 400 - assert "not found" in r.json()["detail"].lower() + assert r.status_code == 422 + + +def test_router_custom_path_consumes_native_authorization(mt, monkeypatch, tmp_path): + binary = tmp_path / "ffmpeg" + binary.write_bytes(b"native-authorized") + monkeypatch.setattr(mt, "_binary_runs", lambda _path: True) + token = "b" * 64 + from core import path_authorization + auth_file = os.path.join(path_authorization._AUTH_DIR, f"{token}.json") + with open(auth_file, "w", encoding="utf-8") as handle: + json.dump({"token": token, "kind": "ffmpeg", "path": str(binary)}, handle) + c = _client() + response = c.post( + "/media-tools/ffmpeg/custom-path", json={"authorization": token} + ) + assert response.status_code == 200 + assert os.environ["FFMPEG_PATH"] == str(binary) + assert not os.path.exists(auth_file) def test_router_use_system_maps_lookup_to_404(mt, monkeypatch): diff --git a/tests/test_model_manager_preload.py b/tests/test_model_manager_preload.py index 6b6f6c75..105d4426 100644 --- a/tests/test_model_manager_preload.py +++ b/tests/test_model_manager_preload.py @@ -61,12 +61,19 @@ def test_load_model_skips_pytorch_whisper_by_default(model_manager, monkeypatch) def test_load_model_can_preload_pytorch_whisper_when_requested(model_manager, monkeypatch): calls = [] + asr_loads = [] + + class DummyModel: + llm = object() + + def load_asr_model(self): + asr_loads.append(True) class DummyOmniVoice: @staticmethod def from_pretrained(*args, **kwargs): calls.append((args, kwargs)) - return SimpleNamespace(llm=object()) + return DummyModel() monkeypatch.setenv("OMNIVOICE_PRELOAD_TTS_ASR", "1") monkeypatch.setattr(model_manager, "_lazy_torch", lambda: SimpleNamespace(float16="float16")) @@ -75,7 +82,8 @@ def test_load_model_can_preload_pytorch_whisper_when_requested(model_manager, mo model_manager._load_model_sync() - assert calls[0][1]["load_asr"] is True + assert calls[0][1]["load_asr"] is False + assert asr_loads == [True] def test_resolve_checkpoint_honors_test_sentinel(model_manager, monkeypatch): diff --git a/tests/test_models_dir_setting.py b/tests/test_models_dir_setting.py index 6ac7532c..d0983482 100644 --- a/tests/test_models_dir_setting.py +++ b/tests/test_models_dir_setting.py @@ -8,6 +8,7 @@ second store to diverge from. from __future__ import annotations import os +import json import fastapi import pytest @@ -24,12 +25,25 @@ def env(tmp_path, monkeypatch): # module object — a setattr monkeypatch wouldn't reach the endpoint's copy. envfile = str(tmp_path / "env") monkeypatch.setenv("OMNIVOICE_ENV_FILE", envfile) + auth_dir = tmp_path / "authorizations" + auth_dir.mkdir() + from core import path_authorization + monkeypatch.setattr(path_authorization, "_AUTH_DIR", str(auth_dir)) return envfile +def _body(path, kind="models_dir"): + from core import path_authorization + auth_dir = path_authorization._AUTH_DIR + token = "a" * 64 + with open(os.path.join(auth_dir, f"{token}.json"), "w", encoding="utf-8") as f: + json.dump({"token": token, "kind": kind, "path": path}, f) + return s._ModelsDirBody(authorization=token) + + def test_set_persists_and_writes_durable_env(env, tmp_path): target = str(tmp_path / "models") - res = s.set_models_dir(s._ModelsDirBody(path=target)) + res = s.set_models_dir(_body(target)) abs_target = os.path.abspath(target) assert res["configured"] == abs_target assert res["restart_required"] is True @@ -47,7 +61,7 @@ def test_rejects_unwritable_dir(env, monkeypatch, tmp_path): monkeypatch.setattr(os, "makedirs", boom) with pytest.raises(fastapi.HTTPException) as ei: - s.set_models_dir(s._ModelsDirBody(path=str(tmp_path / "ro"))) + s.set_models_dir(_body(str(tmp_path / "ro"))) assert ei.value.status_code == 400 @@ -55,13 +69,69 @@ def test_rejects_path_with_null_byte(env): # An embedded NUL would otherwise blow up os.makedirs with a ValueError # (→ 500). Validate up front and return a clean 400 instead. with pytest.raises(fastapi.HTTPException) as ei: - s.set_models_dir(s._ModelsDirBody(path="/tmp/mo\x00dels")) + s.set_models_dir(_body("/tmp/mo\x00dels")) assert ei.value.status_code == 400 +def test_server_mode_remote_without_api_key_cannot_create_models_dir(env, monkeypatch, tmp_path): + """GHAS #440/#441: a published bare Docker port is not filesystem auth.""" + from fastapi.testclient import TestClient + + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) + target = tmp_path / "must-not-exist" + app = fastapi.FastAPI() + app.include_router(s.router) + response = TestClient(app, client=("172.17.0.1", 50000)).put( + "/api/settings/storage/models-dir", + json={"path": str(target)}, + ) + assert response.status_code == 403 + assert not target.exists() + + +def test_server_mode_admin_key_cannot_supply_raw_models_path(env, monkeypatch, tmp_path): + """An admin key is not a native path authorization.""" + from fastapi.testclient import TestClient + + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret") + target = tmp_path / "must-not-exist" + app = fastapi.FastAPI() + app.include_router(s.router) + response = TestClient(app, client=("172.17.0.1", 50000)).put( + "/api/settings/storage/models-dir", + headers={"authorization": "Bearer s3cret"}, + json={"path": str(target)}, + ) + assert response.status_code == 422 + assert not target.exists() + + +def test_loopback_raw_path_is_not_a_models_directory_authorization(env, tmp_path): + from fastapi.testclient import TestClient + + target = tmp_path / "must-not-exist" + app = fastapi.FastAPI() + app.include_router(s.router) + response = TestClient(app, client=("127.0.0.1", 50000)).put( + "/api/settings/storage/models-dir", json={"path": str(target)} + ) + assert response.status_code == 422 + assert not target.exists() + + +def test_models_directory_authorization_is_one_shot(env, tmp_path): + body = _body(str(tmp_path / "models")) + assert s.set_models_dir(body)["configured"] + with pytest.raises(fastapi.HTTPException) as exc: + s.set_models_dir(body) + assert exc.value.status_code == 403 + + def test_clear_reverts_to_default(env): user_env.set_user_env("OMNIVOICE_CACHE_DIR", "/old") - res = s.set_models_dir(s._ModelsDirBody(path="")) + res = s.set_models_dir(_body("")) assert res["configured"] is None assert res["restart_required"] is True assert user_env.get_user_env("OMNIVOICE_CACHE_DIR") is None @@ -84,7 +154,7 @@ def test_path_with_spaces_survives_the_full_persistence_chain(env, tmp_path, mon still shows the chosen folder. Pin the whole chain byte-for-byte: endpoint → env file → load_into_environ → os.environ → GET.""" target = str(tmp_path / "Program Data" / "OmniVoice" / "Model Cache") - res = s.set_models_dir(s._ModelsDirBody(path=target)) + res = s.set_models_dir(_body(target)) abs_target = os.path.abspath(target) assert res["configured"] == abs_target assert user_env.get_user_env("OMNIVOICE_CACHE_DIR") == abs_target diff --git a/tests/test_network_share.py b/tests/test_network_share.py index 9cd32090..a2f05184 100644 --- a/tests/test_network_share.py +++ b/tests/test_network_share.py @@ -75,6 +75,35 @@ def test_network_state_endpoint_defaults_disabled(): assert r.json()["enabled"] is False +def test_pin_only_remote_discovery_never_returns_share_pin(monkeypatch): + import importlib + + from main import app + + # Resolve the exact module instance held by the live router. The full suite + # deliberately replaces app modules in sys.modules, so the module-level + # ``ns`` test helper may no longer be the endpoint's dependency. + live_network_share = importlib.import_module("api.routers.system").network_share + + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) + monkeypatch.setattr( + live_network_share, + "_state", + live_network_share.ShareState(True, 3901, "123456", ["192.168.1.10"]), + ) + # Keep the consumption middleware inert: this endpoint is testing the + # intentional admin read-only exception itself, before a PIN is supplied. + monkeypatch.setattr(app.state, "network_share", None, raising=False) + response = TestClient(app, client=("172.17.0.1", 50000)).get( + "/system/network/state" + ) + assert response.status_code == 200 + assert response.json()["pin"] is None + assert response.json()["pin_required"] is True + assert "123456" not in response.text + + def test_network_control_rejects_non_loopback(): from main import app c = TestClient(app, client=("10.0.0.5", 9999)) diff --git a/tests/test_resolve_heartbeat_1414.py b/tests/test_resolve_heartbeat_1414.py new file mode 100644 index 00000000..a52f44b1 --- /dev/null +++ b/tests/test_resolve_heartbeat_1414.py @@ -0,0 +1,286 @@ +"""Resolving an engine's venv is work, and work must report progress (#1414). + +`SubprocessBackend._spawn()` calls `venv_python()`, and on a cold first run +that is not cheap: the probe spawns each candidate interpreter to import the +engine (tens of seconds on a slow disk), and if none is installed it can run +the whole `uv venv` + `uv pip install` bootstrap — which is bounded at 900 s +*by design*, because installing torch takes minutes. + +All of that happens on a GPU-pool worker, inside a generate request whose +execution budget defaults to 300 s. Nothing along the way reported progress, +so the budget expired part-way through and the job was abandoned — and, until +#1424, blamed on the machine's compute. The first generation that triggers a +bootstrap could therefore never succeed, no matter how good the hardware. + +The sidecar's own cold model load already heartbeats for exactly this reason +(#1367). Resolution is the step immediately before it that never did. +""" +from __future__ import annotations + +import threading +from contextlib import contextmanager +from pathlib import Path + +import pytest + + +@pytest.fixture +def sb(): + """Resolved at run time, not import time.""" + import services.subprocess_backend as _sb + + return _sb + + +@pytest.fixture +def mm(): + import services.model_manager as _mm + + return _mm + + +def test_a_slow_resolution_extends_the_deadline(sb, mm, monkeypatch): + """The whole point: a resolution that outlives one heartbeat interval + leaves proof of life on the execution clock.""" + monkeypatch.setattr(sb, "_RESOLVE_HEARTBEAT_S", 0) + monkeypatch.setattr(mm, "running_on_gpu_pool", lambda: True) + ident = threading.get_ident() + mm._MODEL_LOAD_ACTIVITY.pop(ident, None) + + wrote = threading.Event() + + class _SignallingMap(dict): + def __setitem__(self, key, value): + super().__setitem__(key, value) + wrote.set() + + monkeypatch.setattr(mm, "_MODEL_LOAD_ACTIVITY", _SignallingMap()) + with sb._heartbeat_while_resolving("indextts2"): + assert wrote.wait(2), "the heartbeat thread never reported progress" + + assert ident in mm._MODEL_LOAD_ACTIVITY, ( + "a slow venv resolution reported no progress — the generate budget " + "expires part-way through the install it is waiting for" + ) + mm._MODEL_LOAD_ACTIVITY.pop(ident, None) + + +def test_it_credits_the_resolving_thread_not_the_beater(sb, mm, monkeypatch): + """The heartbeat runs on a helper thread so it can tick while resolution + blocks — but the job the clock is watching is the caller's. Crediting the + helper's ident would extend nothing and would poison an ident a pool + worker may later reuse. + + Asserted against the beater's OWN ident rather than a before/after diff of + the activity map: other tests in the suite have live pool workers, so the + diff is not this test's to own. + """ + monkeypatch.setattr(sb, "_RESOLVE_HEARTBEAT_S", 0) + monkeypatch.setattr(mm, "running_on_gpu_pool", lambda: True) + ident = threading.get_ident() + mm._MODEL_LOAD_ACTIVITY.pop(ident, None) + beater_idents: set[int] = set() + wrote = threading.Event() + + class _SignallingMap(dict): + def __setitem__(self, key, value): + super().__setitem__(key, value) + wrote.set() + + monkeypatch.setattr(mm, "_MODEL_LOAD_ACTIVITY", _SignallingMap()) + + real_thread = threading.Thread + + class _Recording(real_thread): + def run(self): + beater_idents.add(threading.get_ident()) + super().run() + + monkeypatch.setattr(sb.threading, "Thread", _Recording) + + with sb._heartbeat_while_resolving("indextts2"): + assert wrote.wait(2), "the heartbeat thread never reported progress" + + assert ident in mm._MODEL_LOAD_ACTIVITY, "the caller's job was never credited" + assert beater_idents, "no heartbeat thread ran" + assert not (beater_idents & set(mm._MODEL_LOAD_ACTIVITY)), ( + "the heartbeat thread credited its own ident — that extends nothing " + "and poisons an ident a pool worker may later reuse" + ) + mm._MODEL_LOAD_ACTIVITY.pop(ident, None) + + +def test_an_off_pool_caller_never_heartbeats(sb, mm, monkeypatch): + """#1379's lesson: an off-pool thread's ident is not tracked by the clock, + and a pool worker that later reuses it would inherit unearned extension.""" + monkeypatch.setattr(mm, "running_on_gpu_pool", lambda: False) + ident = threading.get_ident() + mm._MODEL_LOAD_ACTIVITY.pop(ident, None) + + class _UnexpectedThread: + def __init__(self, *args, **kwargs): + raise AssertionError("an off-pool call started a heartbeat helper") + + monkeypatch.setattr(sb.threading, "Thread", _UnexpectedThread) + with sb._heartbeat_while_resolving("indextts2"): + pass + + assert ident not in mm._MODEL_LOAD_ACTIVITY + + +def test_the_beater_stops_when_resolution_finishes(sb, mm, monkeypatch): + """A thread per spawn that never exits would accumulate one per generate.""" + monkeypatch.setattr(sb, "_RESOLVE_HEARTBEAT_S", 0) + monkeypatch.setattr(mm, "running_on_gpu_pool", lambda: True) + exited = threading.Event() + real_thread = threading.Thread + + class _Recording(real_thread): + def run(self): + try: + super().run() + finally: + exited.set() + + monkeypatch.setattr(sb.threading, "Thread", _Recording) + + with sb._heartbeat_while_resolving("indextts2"): + pass + + assert exited.wait(2), "heartbeat helper survived context exit" + mm._MODEL_LOAD_ACTIVITY.pop(threading.get_ident(), None) + + +def test_a_broken_heartbeat_does_not_break_the_spawn(sb, monkeypatch): + """Never raises: a generation must not fail because progress reporting + could not import or could not write.""" + monkeypatch.setattr(sb, "_RESOLVE_HEARTBEAT_S", 0.01) + + import services.model_manager as mm + + monkeypatch.setattr( + mm, "running_on_gpu_pool", + lambda: (_ for _ in ()).throw(RuntimeError("clock is broken")), + ) + with sb._heartbeat_while_resolving("indextts2"): + pass # the point is that this block is reached and exits cleanly + + +def test_spawn_wraps_the_resolution(sb, monkeypatch): + """A guard against the wrapper being dropped in a later refactor: the + heartbeat is worthless if `venv_python()` is called outside it.""" + active = False + resolved_inside = threading.Event() + + @contextmanager + def _recording_heartbeat(_engine_id): + nonlocal active + active = True + try: + yield + finally: + active = False + + class _StopAfterResolution(RuntimeError): + pass + + class _Backend(sb.SubprocessBackend): + id = "test" + + @property + def sample_rate(self): + return 24_000 + + @property + def supported_languages(self): + return ["en"] + + @classmethod + def is_available(cls): + return True, "ready" + + @classmethod + def venv_python(cls): + assert active, "venv_python() ran outside the heartbeat context" + resolved_inside.set() + return Path("python") + + @classmethod + def sidecar_script(cls): + raise _StopAfterResolution + + monkeypatch.setattr(sb, "_heartbeat_while_resolving", _recording_heartbeat) + backend = _Backend.__new__(_Backend) + backend._proc = None + with pytest.raises(_StopAfterResolution): + backend._spawn() + assert resolved_inside.is_set() + + +def test_no_heartbeat_write_escapes_the_context(sb, mm, monkeypatch): + """The late-write race (CodeRabbit, #1426). + + `_beat()` can be past its `stop.wait()` and already committed to a write + at the moment the context exits. Signalling the stop flag without joining + lets that write land afterwards — and `_run_on_gpu_pool`'s `_job` pops + this ident right after, precisely so a stale beat cannot vouch for a later + job on the same (reused) worker ident. A write that arrives after the pop + resurrects the entry, and the next job inherits a heartbeat it never sent: + the wedge detector reads it as progress and keeps extending a stuck job. + + The interleaving is forced rather than waited for. A patched writer parks + inside the write until the test releases it, so the exit path must be the + thing that waits — if it only signals, the write lands after the context + and the assertion catches it deterministically, on every run and every + scheduler. + """ + monkeypatch.setattr(sb, "_RESOLVE_HEARTBEAT_S", 0) + monkeypatch.setattr(mm, "running_on_gpu_pool", lambda: True) + ident = threading.get_ident() + mm._MODEL_LOAD_ACTIVITY.pop(ident, None) + + in_write = threading.Event() + release = threading.Event() + write_finished = threading.Event() + + class _ParkingMap(dict): + """Stalls the heartbeat mid-write so the exit path has to wait.""" + + def __setitem__(self, key, value): + in_write.set() + assert release.wait(5), "test did not release the parked write" + super().__setitem__(key, value) + write_finished.set() + + context_exited = threading.Event() + monkeypatch.setattr(mm, "_MODEL_LOAD_ACTIVITY", _ParkingMap()) + + joined = threading.Event() + real_thread = threading.Thread + + class _JoinRecordingThread(real_thread): + def join(self, *args, **kwargs): + joined.set() + return super().join(*args, **kwargs) + + monkeypatch.setattr(sb.threading, "Thread", _JoinRecordingThread) + + def _run_context(): + with sb._heartbeat_while_resolving("indextts2"): + assert in_write.wait(5), "heartbeat never attempted a write" + context_exited.set() + + runner = real_thread(target=_run_context) + runner.start() + try: + assert in_write.wait(5), "heartbeat never reached the parked write" + assert joined.wait(2), "context exit did not wait for the heartbeat helper" + assert not context_exited.is_set(), "context exited before the write finished" + finally: + release.set() + runner.join(5) + + assert write_finished.is_set(), "parked heartbeat write did not finish" + assert not runner.is_alive(), "resolve context did not exit after the write" + assert context_exited.is_set() + mm._MODEL_LOAD_ACTIVITY.pop(ident, None) diff --git a/tests/test_torch_constraints_are_applied.py b/tests/test_torch_constraints_are_applied.py index 21dd3a77..82f9cdfb 100644 --- a/tests/test_torch_constraints_are_applied.py +++ b/tests/test_torch_constraints_are_applied.py @@ -6,7 +6,7 @@ The coupling lives in `[tool.uv] constraint-dependencies`. That setting is part of the **project** API — `uv sync`, `uv lock`, `uv run`. `uv pip install` is the pip-compatible interface and ignores it. Both install -paths that use `uv pip install --system` (the Colab notebook and the Docker +paths that use `uv pip install` (the Colab notebook and the Docker image) therefore resolved the trio on its bare lower bounds, free to upgrade torch while leaving a torchvision built against an older ABI in place: @@ -99,13 +99,33 @@ def test_the_pins_carry_no_local_version(file_constraints): def test_the_dockerfile_passes_the_constraint(): text = _DOCKERFILE.read_text(encoding="utf-8") - install = [ln for ln in text.splitlines() if "uv pip install" in ln and "--system" in ln] - assert install, "no `uv pip install --system` line found in the Dockerfile" - for line in install: - assert "--constraint" in line and "torch-constraints.txt" in line, ( - f"Docker installs without the torch constraint, so the trio can " - f"drift again:\n {line.strip()}" - ) + install_start = text.index("RUN uv pip install") + install_end = text.index("\n\n", install_start) + install = text[install_start:install_end] + assert "--constraint" in install and "torch-constraints.txt" in install, ( + "Docker installs without the torch constraint, so the trio can " + f"drift again:\n{install}" + ) + + +def test_docker_install_and_runtime_use_the_guarded_python(): + """#1274: ROCm's `python3` had HIP torch, while `--system` installed and + bare `uvicorn` launched through `/usr/bin/python` with CUDA torch.""" + text = _DOCKERFILE.read_text(encoding="utf-8") + install_start = text.index("RUN uv pip install") + install_end = text.index("\n\n", install_start) + install = text[install_start:install_end] + assert '--python "$(command -v python3)"' in install + assert "--system" not in install + assert 'ENTRYPOINT ["python3", "-m", "uvicorn"' in text + + +def test_docker_docs_do_not_assume_the_run_name_for_compose(): + docs = (_ROOT / "docs" / "install" / "docker.md").read_text(encoding="utf-8") + assert "docker exec python3" in docs + assert "torch.cuda.get_device_name(0) if ok else 'unavailable'" in docs + for compose_name in ("omnivoice-studio", "omnivoice-studio-gpu", "omnivoice-studio-rocm"): + assert compose_name in docs def test_the_dockerfile_copies_the_constraints_file(): diff --git a/tests/test_video_context.py b/tests/test_video_context.py new file mode 100644 index 00000000..d0ac3a44 --- /dev/null +++ b/tests/test_video_context.py @@ -0,0 +1,74 @@ +"""Pillow-backed video-context analysis stays deterministic across upgrades.""" +from __future__ import annotations + +import importlib +import tomllib +from pathlib import Path + +from packaging.requirements import Requirement +from PIL import Image + + +def _analyse(frame_path): + module = importlib.import_module("services.video_context") + return module._analyse_frame_basic(str(frame_path)) + + +def test_pillow_runtime_floor_is_declared(): + project = tomllib.loads( + (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text() + ) + requirements = [Requirement(item) for item in project["project"]["dependencies"]] + pillow = next(req for req in requirements if req.name.lower() == "pillow") + assert any( + spec.operator == ">=" and spec.version == "12.1.0" + for spec in pillow.specifier + ) + assert pillow.specifier.contains("12.1.0") + assert not pillow.specifier.contains("12.0.99") + + +def _save_jpeg(tmp_path, name: str, image: Image.Image): + path = tmp_path / name + image.save(path, format="JPEG", quality=100, subsampling=0) + return path + + +def test_basic_analysis_decodes_and_resizes_real_jpegs(tmp_path): + dark = _save_jpeg(tmp_path, "dark.jpg", Image.new("RGB", (16, 12), (20, 20, 20))) + bright = _save_jpeg(tmp_path, "bright.jpg", Image.new("RGB", (640, 480), (230, 230, 230))) + + dark_result = _analyse(dark) + bright_result = _analyse(bright) + + assert dark_result == { + "brightness": "dark", "mood": "calm", "complexity": "simple", + "avg_luminance": 20.0, "avg_saturation": 0.0, + } + assert bright_result == { + "brightness": "bright", "mood": "calm", "complexity": "simple", + "avg_luminance": 230.0, "avg_saturation": 0.0, + } + + +def test_basic_analysis_preserves_color_and_edge_classes(tmp_path): + vivid = _save_jpeg(tmp_path, "vivid.jpg", Image.new("RGB", (320, 240), (255, 0, 0))) + stripes = Image.new("RGB", (320, 240)) + stripes.putdata([ + (255, 255, 255) if x % 2 else (0, 0, 0) + for _y in range(240) + for x in range(320) + ]) + action = _save_jpeg(tmp_path, "action.jpg", stripes) + + assert _analyse(vivid)["mood"] == "vivid" + assert _analyse(action)["complexity"] == "action" + + +def test_basic_analysis_degrades_cleanly_for_malformed_image(tmp_path): + malformed = tmp_path / "frame.jpg" + malformed.write_bytes(b"not an image") + + assert _analyse(malformed) == { + "brightness": "unknown", "mood": "unknown", "complexity": "unknown", + } diff --git a/tests/test_voice_design_instruct_heal.py b/tests/test_voice_design_instruct_heal.py index bafbc53c..989bb72c 100644 --- a/tests/test_voice_design_instruct_heal.py +++ b/tests/test_voice_design_instruct_heal.py @@ -7,6 +7,8 @@ strip poison ("[object Object]", freeform prose) down to whitelist tags, recovering a design from ``vd_states`` when the stored value is unusable. """ import json +from statistics import median +import time import pytest @@ -57,6 +59,27 @@ def test_sanitize_handles_empty_and_poison(bad): assert sanitize_instruct(bad) == "" +def test_sanitize_long_whitespace_run_is_linear_time(): + sizes = (2_000, 8_000, 32_000) + timings = [] + for size in sizes: + poisoned = "female" + (" " * size) + "not-a-tag" + assert sanitize_instruct(poisoned) == "" # warm timer/cache paths + samples = [] + for _ in range(5): + started = time.perf_counter_ns() + assert sanitize_instruct(poisoned) == "" + samples.append(time.perf_counter_ns() - started) + timings.append(median(samples)) + + # Input grows 4x per step and 16x overall. Generous multipliers and a + # 20 ms scheduling floor tolerate noisy shared runners while still + # separating linear split/strip behavior from the former quadratic regex. + for smaller, larger in zip(timings, timings[1:]): + assert larger <= max(smaller * 10, 20_000_000) + assert timings[-1] <= max(timings[0] * 48, 20_000_000) + + def test_instruct_from_vd_states_dict_drops_auto(): vd = {"gender": "female", "age": "Auto", "pitch": "high pitch", "accent": "british accent"} assert instruct_from_vd_states(vd) == "female, high pitch, british accent" diff --git a/tests/test_youtube_cookie_import.py b/tests/test_youtube_cookie_import.py new file mode 100644 index 00000000..40037ee2 --- /dev/null +++ b/tests/test_youtube_cookie_import.py @@ -0,0 +1,255 @@ +"""Explicit, ephemeral YouTube authentication for URL ingest (#1429/#1432).""" +import asyncio +import importlib +import os +import stat +import sys + +import pytest +from fastapi import HTTPException + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), "backend")) + +COOKIE_TEXT = "# Netscape HTTP Cookie File\n.youtube.com\tTRUE\t/\tTRUE\t0\tSID\tsecret\n" + + +@pytest.fixture +def dub_core(): + """Import the application router only when a test needs it.""" + return importlib.import_module("api.routers.dub_core") + + +@pytest.fixture +def dub_pipeline(): + """Import the application pipeline only when a test needs it.""" + return importlib.import_module("services.dub_pipeline") + + +def test_cookie_export_requires_deliberate_netscape_file_and_is_private(dub_core): + with pytest.raises(HTTPException) as exc: + dub_core._stage_cookie_export('{"cookies": []}') + assert exc.value.status_code == 400 + + path = dub_core._stage_cookie_export(COOKIE_TEXT) + try: + with open(path, encoding="utf-8") as cookie_file: + assert cookie_file.read() == COOKIE_TEXT + if os.name != "nt": + assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 + finally: + os.unlink(path) + + +def test_cookie_export_accepts_a_bom_and_rejects_empty_or_oversized_files(dub_core): + path = dub_core._stage_cookie_export("\ufeff" + COOKIE_TEXT) + try: + assert os.path.exists(path) + finally: + os.unlink(path) + + for contents in ("", "# Netscape HTTP Cookie File\n" + "x" * (1024 * 1024)): + with pytest.raises(HTTPException) as exc: + dub_core._stage_cookie_export(contents) + assert exc.value.status_code == 400 + + +@pytest.mark.parametrize( + ("scheme", "host", "origin", "allowed"), + [ + ("http", "127.0.0.1", "http://tauri.localhost", True), + ("http", "::1", "http://localhost:3901", True), + ("https", "192.0.2.20", "https://studio.example", True), + ("http", "192.0.2.20", "http://localhost", False), + ("http", "127.0.0.1", "http://studio.example", False), + ("http", "127.0.0.1", None, False), + ], +) +def test_cookie_credentials_only_cross_https_or_local_ui( + dub_core, scheme, host, origin, allowed +): + assert dub_core._cookie_transport_allowed(scheme, host, origin) is allowed + + +def test_cookie_export_is_forwarded_to_ytdlp(dub_pipeline, tmp_path, monkeypatch): + import yt_dlp + + captured = {} + + class FakeYDL: + def __init__(self, opts): + captured.update(opts) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def extract_info(self, _url, download=True): + raise RuntimeError("stop after capturing options") + + monkeypatch.setattr(yt_dlp, "YoutubeDL", FakeYDL) + cookie_path = str(tmp_path / "cookies.txt") + with pytest.raises(RuntimeError): + dub_pipeline.yt_download_sync( + "https://youtube.com/watch?v=abc", + str(tmp_path), + cookie_file=cookie_path, + ) + + assert captured["cookiefile"] == cookie_path + + +def test_pipeline_deletes_cookie_export_after_download_failure( + dub_pipeline, tmp_path, monkeypatch +): + cookie_path = tmp_path / "session.cookies.txt" + cookie_path.write_text(COOKIE_TEXT, encoding="utf-8") + + def fail_download(*_args, **_kwargs): + raise RuntimeError("download failed") + + monkeypatch.setattr(dub_pipeline, "yt_download_sync", fail_download) + async def collect_events(): + events = [] + async for event in dub_pipeline.ingest_pipeline( + "cookie-cleanup", + str(tmp_path / "job"), + { + "kind": "url", + "url": "https://youtube.com/watch?v=abc", + "cookie_file": str(cookie_path), + }, + ): + events.append(event) + return events + + events = asyncio.run(collect_events()) + + assert any('"type": "error"' in event for event in events) + assert "secret" not in "".join(events) + assert not cookie_path.exists() + + +def test_cookie_cleanup_is_idempotent(dub_pipeline, tmp_path): + cookie_path = tmp_path / "session.cookies.txt" + cookie_path.write_text(COOKIE_TEXT, encoding="utf-8") + dub_pipeline._delete_cookie_export(str(cookie_path)) + dub_pipeline._delete_cookie_export(str(cookie_path)) + assert not cookie_path.exists() + + +def test_pipeline_cancellation_deletes_cookie_before_download(dub_pipeline, tmp_path): + cookie_path = tmp_path / "cancel.cookies.txt" + cookie_path.write_text(COOKIE_TEXT, encoding="utf-8") + + async def start_then_cancel(): + pipeline = dub_pipeline.ingest_pipeline( + "cookie-cancel", + str(tmp_path / "job-cancel"), + { + "kind": "url", + "url": "https://youtube.com/watch?v=abc", + "cookie_file": str(cookie_path), + }, + ) + await anext(pipeline) + await pipeline.aclose() + + asyncio.run(start_then_cancel()) + assert not cookie_path.exists() + + +def test_enqueue_failure_deletes_staged_cookie(dub_core, tmp_path, monkeypatch): + from schemas.requests import DubIngestUrlRequest + from starlette.requests import Request + + cookie_path = tmp_path / "queued.cookies.txt" + monkeypatch.setattr(dub_core, "_stage_cookie_export", lambda _text: str(cookie_path)) + cookie_path.write_text(COOKIE_TEXT, encoding="utf-8") + monkeypatch.setattr(dub_core, "_safe_job_dir", lambda _job_id: str(tmp_path / "job")) + + async def fail_add(*_args, **_kwargs): + raise RuntimeError("queue closed") + + monkeypatch.setattr(dub_core.task_manager, "add_task", fail_add) + request = Request( + {"type": "http", "scheme": "http", "server": ("127.0.0.1", 80), + "client": ("127.0.0.1", 1234), "path": "/dub/ingest-url", + "headers": [(b"origin", b"http://tauri.localhost")]} + ) + with pytest.raises(RuntimeError, match="queue closed"): + asyncio.run( + dub_core.dub_ingest_url( + DubIngestUrlRequest( + url="https://youtube.com/watch?v=abc", cookie_file=COOKIE_TEXT + ), + request, + ) + ) + assert not cookie_path.exists() + + +def test_job_directory_failure_happens_before_cookie_staging( + dub_core, tmp_path, monkeypatch +): + from schemas.requests import DubIngestUrlRequest + from starlette.requests import Request + + staged = False + + def stage_cookie(_text): + nonlocal staged + staged = True + return str(tmp_path / "should-not-exist.cookies.txt") + + monkeypatch.setattr(dub_core, "_stage_cookie_export", stage_cookie) + monkeypatch.setattr( + dub_core, "_safe_job_dir", lambda _job_id: str(tmp_path / "job") + ) + + def fail_makedirs(*_args, **_kwargs): + raise OSError("disk full") + + monkeypatch.setattr(dub_core.os, "makedirs", fail_makedirs) + request = Request( + { + "type": "http", + "scheme": "http", + "server": ("127.0.0.1", 80), + "client": ("127.0.0.1", 1234), + "path": "/dub/ingest-url", + "headers": [(b"origin", b"http://tauri.localhost")], + } + ) + + with pytest.raises(OSError, match="disk full"): + asyncio.run( + dub_core.dub_ingest_url( + DubIngestUrlRequest( + url="https://youtube.com/watch?v=abc", cookie_file=COOKIE_TEXT + ), + request, + ) + ) + assert staged is False + + +def test_failed_cookie_unlink_can_be_retried(dub_pipeline, tmp_path, monkeypatch): + cookie_path = tmp_path / "retry.cookies.txt" + cookie_path.write_text(COOKIE_TEXT, encoding="utf-8") + real_unlink = os.unlink + attempts = 0 + + def flaky_unlink(path): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise PermissionError("temporarily busy") + real_unlink(path) + + monkeypatch.setattr(dub_pipeline.os, "unlink", flaky_unlink) + assert dub_pipeline._delete_cookie_export(str(cookie_path)) is False + assert cookie_path.exists() + assert dub_pipeline._delete_cookie_export(str(cookie_path)) is True + assert not cookie_path.exists() diff --git a/uv.lock b/uv.lock index 152c019f..c846ed69 100644 --- a/uv.lock +++ b/uv.lock @@ -70,7 +70,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -82,108 +82,108 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, - { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, - { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, - { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, - { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, - { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, - { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, - { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, - { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, - { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, - { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, - { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, - { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, - { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, - { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, - { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, - { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, - { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] @@ -977,61 +977,58 @@ sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c [[package]] name = "cryptography" -version = "48.0.0" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, - { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, - { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, - { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, - { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, - { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, - { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, - { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, - { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, - { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, - { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, - { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, - { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, - { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, - { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, - { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, - { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, - { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, - { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, - { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, - { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, - { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, - { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, - { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, - { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, ] [[package]] @@ -1193,6 +1190,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "demucs" version = "4.0.1" @@ -2962,17 +2968,18 @@ wheels = [ [[package]] name = "nltk" -version = "3.9.4" +version = "3.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, + { name = "defusedxml" }, { name = "joblib" }, { name = "regex" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864, upload-time = "2026-03-24T06:13:40.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/02/df4f105b28a7c16b0e41423bc09cf0f1b8a305df4ef0b10ca74a2e4c648c/nltk-3.10.0.tar.gz", hash = "sha256:4fbac1d98203cbcd1b5d94a2877fb822300072d80604a5e7fae49d2c5f84e8c1", size = 3089244, upload-time = "2026-07-08T02:39:13.562Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" }, + { url = "https://files.pythonhosted.org/packages/6e/89/a0b0f35e2820d6a99d75ea1c11977ee6d5c9e6658eceb45b0c7620881faa/nltk-3.10.0-py3-none-any.whl", hash = "sha256:54ff84d4916d3ef127e8953bee0023f6a6b320b75d634a19e06ef056d3d244bf", size = 1716144, upload-time = "2026-07-08T02:39:09.753Z" }, ] [[package]] @@ -3260,6 +3267,7 @@ dependencies = [ { name = "openai" }, { name = "parakeet-mlx", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, { name = "pedalboard" }, + { name = "pillow" }, { name = "pip" }, { name = "posthog" }, { name = "psutil" }, @@ -3346,6 +3354,7 @@ requires-dist = [ { name = "openai", specifier = ">=1.40" }, { name = "parakeet-mlx", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'", specifier = ">=0.5.2" }, { name = "pedalboard", specifier = ">=0.9.14" }, + { name = "pillow", specifier = ">=12.1.0" }, { name = "pip", specifier = ">=26.1.2" }, { name = "posthog", specifier = ">=3.7" }, { name = "psutil", specifier = ">=7.2.2" }, @@ -3716,89 +3725,87 @@ wheels = [ [[package]] name = "pillow" -version = "12.2.0" +version = "12.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, - { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, - { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, - { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, - { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, - { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, - { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, - { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, - { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] [[package]] @@ -4440,11 +4447,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.13.2" +version = "6.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/0a/48fe05c6bb3aa4bb4d2a4079a383d33c0dfec1edf613a642f07d8b8b5c2e/pypdf-6.13.2.tar.gz", hash = "sha256:5a96a17dbdfbf9c2ab24c0a13fa0aba182be22ba6f283098712c16fc242f509f", size = 6479250, upload-time = "2026-06-10T16:42:34.5Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/17/ee75a92718ec7212de831e71454d702225aa5e474a805cce169806044453/pypdf-6.15.0.tar.gz", hash = "sha256:d39c4d955a76409284a905e2d65b40076d77ab76129e0faaeeb6612403ecfc79", size = 6993794, upload-time = "2026-08-06T13:06:49.929Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/17/378943705992f74e451a06de3401ce68e3213763c81e44d0614559c45599/pypdf-6.13.2-py3-none-any.whl", hash = "sha256:6eeb9e57693f29d41bd01255d02660cbbb41fd7fc818a982677389a35e4f2083", size = 346555, upload-time = "2026-06-10T16:42:32.37Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/ce3067ac31e214a66388159f8462ddb8c13dd00170f24d555a1f1ae8ee91/pypdf-6.15.0-py3-none-any.whl", hash = "sha256:14e001d6504822cb1ca9c7ed9a69bccb320f59b320730f55af804361abe4d5ee", size = 378123, upload-time = "2026-08-06T13:06:47.709Z" }, ] [[package]] @@ -6805,11 +6812,11 @@ wheels = [ [[package]] name = "yt-dlp" -version = "2026.6.9" +version = "2026.7.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/a4/1b0979d28f87774bb67fbbc66bce44f9dd1aa0e547a99e22985fac945c33/yt_dlp-2026.6.9.tar.gz", hash = "sha256:d50fcb95f48d61bedde33e408c1881d4c279e51c31354a599ce09e96ba0f4b86", size = 3030590, upload-time = "2026-06-09T23:27:14.831Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c5/9972af4b472b0d55badf841ebafd2f98944cb0ae0f46e11d01f363ea5b91/yt_dlp-2026.7.4.tar.gz", hash = "sha256:b094813404f87a9dd2186f00815231df32e5fd8a5403be0f807b3bb2d21a4432", size = 3049326, upload-time = "2026-07-04T22:42:14.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/ee/188a3dadf9dfdac713243521f919feca1cd091d4358c9ea7e8ebb710a7cc/yt_dlp-2026.6.9-py3-none-any.whl", hash = "sha256:442ba4c75724b9496144c8434b617962ee08d0ee7c26ec663848fe9b78d5a3e4", size = 3169035, upload-time = "2026-06-09T23:27:12.58Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8a/cd4c9b02c10c563adfe78118310129641900e1cd6de888cfae2452072696/yt_dlp-2026.7.4-py3-none-any.whl", hash = "sha256:f11f2b11d5a8ac4059f9bdf29fa4407dc7c6bb00c5097e95ca22a7a9db518266", size = 3184705, upload-time = "2026-07-04T22:42:12.989Z" }, ] [[package]]