Merge remote-tracking branch 'origin/main' into codex/pr1442

This commit is contained in:
debpalash
2026-08-10 07:21:53 +00:00
83 changed files with 3757 additions and 554 deletions
+9
View File
@@ -42,10 +42,19 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
### Fixed
- 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)
+39
View File
@@ -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`:
+117 -20
View File
@@ -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,
+15 -4
View File
@@ -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)
+13 -11
View File
@@ -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).
+7 -2
View File
@@ -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))
+24 -46
View File
@@ -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")
+18 -28
View File
@@ -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,
}
+106 -4
View File
@@ -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:
#: `(?<![\w.-])` identifier / path / dotted-version prefixes — `x401y`,
#: `pytest-401`, `v1.401`
#: `(?![\w-])` identifier and hyphen suffixes — `401k`, `401-retry`
#: `(?!\.\d)` dotted numerics — `401.0`, `401.25` (a trailing sentence
#: full stop is still fine: `status 401.`)
#:
#: Matches what a real status code looks like: `401`, `(401)`, `status 401:`,
#: `HTTP/1.1 401`.
_HTTP_401 = re.compile(r"(?<![\w.-])401(?![\w-])(?!\.\d)")
_REDACTED_VALUE = "***REDACTED***"
# One-line "what to do" per docs-taxonomy key. Keys mirror error_docs_map's
@@ -73,7 +91,7 @@ _HINTS: dict[str, str] = {
# wins over the symptom.
"MODEL_DOWNLOAD_INTERRUPTED": "A model download was cut off mid-request, and the component it was fetching then failed to load. Nothing is wrong with your install — reinstalling won't help, and the partial download is resumed rather than restarted. Just retry. If it keeps happening, check your connection (and any VPN, proxy or HF mirror setting); if only transcription is affected, switching ASR to faster-whisper in Settings → Models avoids the pipeline that downloads this component.",
"BROKEN_VENV": "The Python backend environment was moved or damaged. VoiceStudio rebuilds it automatically on the next launch; if it keeps failing, use Clean & Retry on the setup screen.",
"MODEL_CACHE_CORRUPT": "The model cache had broken file links — snapshot entries that no longer point at their downloaded data (interrupted renames or antivirus interference can cause this). VoiceStudio repairs this automatically and retries the load once. If the error persists, quit VoiceStudio, delete the model's models--<org>--<name> 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--<org>--<name> 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.
}
@@ -341,7 +359,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
@@ -412,8 +439,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
@@ -712,6 +748,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
+88
View File
@@ -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"]
+53
View File
@@ -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"]))
+4
View File
@@ -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
+3 -3
View File
@@ -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()
+26
View File
@@ -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)
+202 -67
View File
@@ -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
+6 -3
View File
@@ -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()
+100 -1
View File
@@ -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
+2 -2
View File
@@ -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)
+13 -7
View File
@@ -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"]
+15 -5
View File
@@ -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
+13 -3
View File
@@ -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 <container> 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 <container> python3 -c "import sys, torch; print(sys.executable,
torch.version.hip)"` with `docker exec <container> sh -c 'tr "\\0" " "
</proc/1/cmdline'`; PID 1 must begin with `python3 -m uvicorn`.
If the command prints `False`, **Settings → System** now says why, and the
three answers need different fixes:
@@ -203,7 +213,7 @@ Two paths are worth persisting across container restarts:
pushes. Pull the image again after the fix is merged: `docker pull ghcr.io/debpalash/omnivoice-studio:latest`.
The running version is now shown in **Settings → About → Version** (read live
from the backend), so the web UI no longer displays a dash in Docker.
- **Checking which version is running:** `docker exec omnivoice python -c "import importlib.metadata; print(importlib.metadata.version('omnivoice'))"`, or hit the `/health` endpoint — it returns `{"status": "ok", "device": ..., "version": "0.3.x"}`.
- **Checking which version is running:** `docker exec <container> 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
+17
View File
@@ -265,6 +265,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://<lan-ip>:3900` but the audio preview
+1
View File
@@ -2947,6 +2947,7 @@ dependencies = [
"dirs-next",
"enigo",
"fs4",
"getrandom 0.3.4",
"libc",
"log",
"reqwest",
+1
View File
@@ -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"
+107 -1
View File
@@ -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<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> 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<PathBuf, String> {
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<String, String> {
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)]
+1
View File
@@ -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,
+336 -51
View File
@@ -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<R: tauri::Runtime>(
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<R: tauri::Runtime>(
/// 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<PathBuf> {
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<PathBuf> {
#[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<PathBuf> {
// 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<PathBuf> {
finish_uv_install_with_probe(dest, uv_bin, output, uv_is_usable)
}
fn finish_uv_install_with_probe<F>(
dest: &Path,
uv_bin: &Path,
output: Output,
is_usable: F,
) -> io::Result<PathBuf>
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)
}
}
+29 -1
View File
@@ -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<unknown> {
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 },
);
@@ -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 (
<div className="flex-1 flex flex-col min-h-0">
{/* Header bar */}
@@ -376,6 +385,34 @@ export default function IdleSkeleton({
/>
<span>{t('dub.pull_captions')}</span>
</label>
<div
className="flex items-center gap-[6px] mt-[4px] text-[0.62rem] text-fg-muted"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<span>{t('dub.youtube_auth')}</span>
<input
ref={youtubeCookieInputRef}
type="file"
accept=".txt,text/plain"
aria-label={t('dub.youtube_cookie_file')}
className="max-w-[230px] text-[0.6rem] file:mr-[6px] file:rounded-[4px] file:border-0 file:px-[7px] file:py-[3px] file:bg-[rgba(255,255,255,0.08)] file:text-fg file:cursor-pointer"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setYoutubeCookieFile(e.target.files?.[0] || null)}
/>
{youtubeCookieFile && (
<button
type="button"
className="text-fg-muted hover:text-fg"
onClick={() => setYoutubeCookieFile(null)}
aria-label={t('dub.remove_cookie_file')}
>
×
</button>
)}
</div>
</label>
{/* One decision up front: the target language. Everything else
@@ -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', {
@@ -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(<AudioToolsPanel />);
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(<AudioToolsPanel />);
fireEvent.click(await screen.findByLabelText('FFprobe: Restore bundled'));
@@ -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(() => ({}));
@@ -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(<StoragePanel />);
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');
});
});
+13 -3
View File
@@ -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 {
+5
View File
@@ -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": "حفظ المشروع",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -751,6 +751,11 @@
"paste_url": "…ou collez lURL 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": "Lexport de cookies nécessite HTTPS ou lapplication de bureau locale.",
"cookie_size_error": "Lexport de cookies doit faire 1 Mo maximum.",
"save": "Enregistrer",
"reset": "Réinitialiser",
"save_project": "Enregistrer le projet",
+5
View File
@@ -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": "प्रोजेक्ट सहेजें",
+5
View File
@@ -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",
+5
View File
@@ -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": "Lesportazione dei cookie richiede HTTPS o lapp desktop locale.",
"cookie_size_error": "Lesportazione dei cookie deve essere al massimo di 1 MB.",
"save": "Salva",
"reset": "Ripristina",
"save_project": "Salva progetto",
+5
View File
@@ -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": "プロジェクトの保存",
+5
View File
@@ -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": "프로젝트 저장",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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": "Сохранить проект",
+5
View File
@@ -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",
+5
View File
@@ -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": "บันทึกโครงการ",
+5
View File
@@ -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",
+5
View File
@@ -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": "Зберегти проект",
+5
View File
@@ -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",
+5
View File
@@ -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": "保存项目",
+5
View File
@@ -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": "保存項目",
+10 -1
View File
@@ -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}
+44 -1
View File
@@ -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(
<I18nextProvider i18n={i18n}>
<IdleSkeleton {...baseProps({ youtubeCookieFile: null })} />
</I18nextProvider>,
);
expect(input.value).toBe('');
});
it('does NOT show the idle dropzone while transcribing a URL-ingested job', () => {
+37 -3
View File
@@ -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 <div data-testid="left-col" />;
},
}));
vi.mock('../components/dub/DubHeader', () => ({ default: () => null }));
vi.mock('../components/dub/DubHeader', () => ({
default: ({ resetDub }) => (
<button data-testid="reset-dub" onClick={resetDub}>
reset
</button>
),
}));
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 }) => (
<div>
<span data-testid="cookie-name">{youtubeCookieFile?.name || 'none'}</span>
<button
data-testid="select-cookie"
onClick={() => setYoutubeCookieFile(new File(['secret'], 'cookies.txt'))}
>
select
</button>
</div>
),
}));
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(<DubTab {...makeProps()} resetDub={resetDub} />);
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');
});
});
+24 -7
View File
@@ -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
+6 -1
View File
@@ -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):
+3
View File
@@ -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
@@ -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."""
+49 -1
View File
@@ -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)
+2 -1
View File
@@ -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()
+126
View File
@@ -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"
)
+355
View File
@@ -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]
+76 -4
View File
@@ -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}"
+13 -4
View File
@@ -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
@@ -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
+8 -3
View File
@@ -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--<org>--<name>" 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():
+14 -1
View File
@@ -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):
+36
View File
@@ -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
+88 -2
View File
@@ -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.
+25 -3
View File
@@ -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):
+10 -2
View File
@@ -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):
+75 -5
View File
@@ -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
+29
View File
@@ -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))
+286
View File
@@ -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)
+28 -8
View File
@@ -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 <container> 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():
+74
View File
@@ -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",
}
+23
View File
@@ -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"
+255
View File
@@ -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()
Generated
+249 -242
View File
@@ -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]]
@@ -986,61 +986,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]]
@@ -1202,6 +1199,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"
@@ -2971,17 +2977,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]]
@@ -3269,6 +3276,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" },
@@ -3358,6 +3366,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 = "pocket-tts", marker = "(platform_machine != 'x86_64' and extra == 'pockettts') or (sys_platform != 'darwin' and extra == 'pockettts')", specifier = "==2.1.0" },
{ name = "posthog", specifier = ">=3.7" },
@@ -3729,89 +3738,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]]
@@ -4480,11 +4487,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]]
@@ -6845,11 +6852,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]]