From b4bf4b9df1636c1a13cd1416289c19804e22837a Mon Sep 17 00:00:00 2001 From: DaBao Date: Sat, 3 Oct 2026 22:50:20 +0800 Subject: [PATCH 01/12] refactor: add portable config and fingerprinted stage state --- autoup/config.example.yaml | 16 ++ autoup/config.py | 105 ++++++++-- autoup/config.yaml | 67 +++--- autoup/run.py | 409 +++++++++++++++++++++++++------------ autoup/state.py | 107 ++++++++++ autoup/utils.py | 239 ++++++++++++++-------- 6 files changed, 665 insertions(+), 278 deletions(-) create mode 100644 autoup/config.example.yaml create mode 100644 autoup/state.py diff --git a/autoup/config.example.yaml b/autoup/config.example.yaml new file mode 100644 index 0000000..510b653 --- /dev/null +++ b/autoup/config.example.yaml @@ -0,0 +1,16 @@ +# 复制为 autoup/config.local.yaml 后按本机环境修改。 +paths: + ffmpeg: "D:/tools/ffmpeg/ffmpeg.exe" + ffprobe: "D:/tools/ffmpeg/ffprobe.exe" + gpt_sovits_root: "D:/tools/GPT-SoVITS" + output_root: "D:/AutoUP" + +voice: + gpt_sovits: + voices: + default: + ref_audio_path: "D:/voices/narrator.wav" + prompt_text: "参考音频对应的原文" + +# API Key 不要写进配置文件: +# PowerShell: $env:GLM_API_KEY = "..." diff --git a/autoup/config.py b/autoup/config.py index 3215d95..7daf343 100644 --- a/autoup/config.py +++ b/autoup/config.py @@ -1,37 +1,101 @@ -"""配置加载: config.yaml 为唯一事实来源, 提供点路径访问。""" +"""配置加载:仓库默认配置 + 可选本机覆盖,提供点路径访问。""" from __future__ import annotations import os from pathlib import Path +from typing import Any import yaml _REPO_ROOT = Path(__file__).resolve().parent.parent -_cfg_cache: dict | None = None +_CONFIG_PATH = _REPO_ROOT / "autoup" / "config.yaml" +_LOCAL_CONFIG_PATH = _REPO_ROOT / "autoup" / "config.local.yaml" +_cfg_cache: dict[str, Any] | None = None def repo_root() -> Path: return _REPO_ROOT -def load() -> dict: +def reset_cache() -> None: + """清空配置缓存,主要用于测试和运行期切换配置。""" global _cfg_cache - if _cfg_cache is None: - path = _REPO_ROOT / "autoup" / "config.yaml" - with open(path, encoding="utf-8") as f: - _cfg_cache = yaml.safe_load(f) or {} - _cfg_cache.setdefault("llm", {}) - if _cfg_cache["llm"].get("api_key") in (None, ""): - _cfg_cache["llm"]["api_key"] = _find_glm_key() - return _cfg_cache + _cfg_cache = None + + +def _read_yaml(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + with path.open(encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + if not isinstance(data, dict): + raise ValueError(f"配置根节点必须是 mapping: {path}") + return data + + +def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + for key, value in override.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + _deep_merge(base[key], value) + else: + base[key] = value + return base + + +def _set_dotted(data: dict[str, Any], dotted: str, value: Any) -> None: + node = data + parts = dotted.split(".") + for part in parts[:-1]: + child = node.get(part) + if not isinstance(child, dict): + child = {} + node[part] = child + node = child + node[parts[-1]] = value + + +def load() -> dict[str, Any]: + global _cfg_cache + if _cfg_cache is not None: + return _cfg_cache + + cfg = _read_yaml(_CONFIG_PATH) + local_path = Path(os.environ.get("AUTOUP_CONFIG", "")).expanduser() if os.environ.get("AUTOUP_CONFIG") else _LOCAL_CONFIG_PATH + if local_path.exists(): + _deep_merge(cfg, _read_yaml(local_path)) + + env_map = { + "AUTOUP_FFMPEG": "paths.ffmpeg", + "AUTOUP_FFPROBE": "paths.ffprobe", + "AUTOUP_OUTPUT_ROOT": "paths.output_root", + "AUTOUP_GPT_SOVITS_ROOT": "paths.gpt_sovits_root", + "AUTOUP_LLM_BASE_URL": "llm.base_url", + "AUTOUP_LLM_MODEL": "llm.model", + } + for env_name, dotted in env_map.items(): + value = os.environ.get(env_name) + if value: + _set_dotted(cfg, dotted, value) + + cfg.setdefault("llm", {}) + if not cfg["llm"].get("api_key"): + cfg["llm"]["api_key"] = _find_glm_key() + + _cfg_cache = cfg + return cfg def _find_glm_key() -> str: - """GLM key: 环境变量优先, 其次 Hermes 的 .env。""" - key = os.environ.get("GLM_API_KEY", "").strip() - if key: - return key - env_file = Path(os.environ.get("LOCALAPPDATA", "")) / "hermes" / ".env" + """GLM key:环境变量优先,兼容 Hermes 的本机 .env。""" + for name in ("GLM_API_KEY", "ZHIPUAI_API_KEY"): + key = os.environ.get(name, "").strip() + if key: + return key + + local_appdata = os.environ.get("LOCALAPPDATA") + if not local_appdata: + return "" + env_file = Path(local_appdata) / "hermes" / ".env" if env_file.exists(): for line in env_file.read_text(encoding="utf-8", errors="replace").splitlines(): if line.startswith("GLM_API_KEY="): @@ -39,7 +103,7 @@ def _find_glm_key() -> str: return "" -def get(dotted: str, default=None): +def get(dotted: str, default: Any = None) -> Any: node: object = load() for part in dotted.split("."): if not isinstance(node, dict) or part not in node: @@ -57,6 +121,7 @@ def ffprobe() -> str: def output_root() -> Path: - p = Path(str(get("paths.output_root", "D:/AutoUP"))) - p.mkdir(parents=True, exist_ok=True) - return p + raw = Path(str(get("paths.output_root", "output"))).expanduser() + path = raw if raw.is_absolute() else _REPO_ROOT / raw + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/autoup/config.yaml b/autoup/config.yaml index d69b99d..da61f06 100644 --- a/autoup/config.yaml +++ b/autoup/config.yaml @@ -1,9 +1,10 @@ -# AutoUP 全局配置(唯一事实来源) +# AutoUP 默认配置(可跨机器直接提交)。 +# 本机路径请写到 autoup/config.local.yaml(已加入 .gitignore)或使用 AUTOUP_* 环境变量。 paths: - ffmpeg: "D:/@佳康顺矩阵/@工具/ffmpeg/ffmpeg.exe" - ffprobe: "D:/@佳康顺矩阵/@工具/ffmpeg/ffprobe.exe" - gpt_sovits_root: "D:/@佳康顺矩阵/@工具/GPT-SoVITS" - output_root: "D:/AutoUP" + ffmpeg: "ffmpeg" + ffprobe: "ffprobe" + gpt_sovits_root: "" + output_root: "output" video: width: 1920 @@ -11,12 +12,12 @@ video: fps: 30 crf: 18 preset: "medium" - prefer_gpu: true # NVENC 自动探测, 失败回退 libx264 + prefer_gpu: true voice: - engine: "gpt_sovits" # gpt_sovits | edge_tts + engine: "gpt_sovits" fallback_engine: "edge_tts" - gap_seconds: 0.3 # 句间自然停顿 + gap_seconds: 0.3 gpt_sovits: api: "http://127.0.0.1:9880/tts" ping: "http://127.0.0.1:9880/ping" @@ -24,14 +25,10 @@ voice: prompt_lang: "zh" speed_factor: 1.0 startup_timeout: 180 - # config_yaml: "" # 可选: api_v2 -c 参数(角色配置), 留空用默认 voices: default: - ref_audio_path: "" # 参考音频 wav 绝对路径 - prompt_text: "" # 参考音频对应文本 - # documentary_male: - # ref_audio_path: "D:/voices/narrator/demo.wav" - # prompt_text: "参考音频的原文" + ref_audio_path: "" + prompt_text: "" edge_tts: voice: "zh-CN-YunjianNeural" rate: "+0%" @@ -39,39 +36,45 @@ voice: bgm: enabled: true - volume: 0.12 # 解说期间 BGM 音量(线性) - duck_ratio: 0.35 # 闪避深度: 压到原音量的 35% + volume: 0.12 + duck_ratio: 0.35 fade_out_seconds: 3.0 - directory: null # null = 仓库 assets/bgm - pick: "random" # random | 具体文件名 + directory: null + pick: "random" subtitle: - font_path: "D:/@kaifa/AutoUP/assets/fonts/05_江西拙楷.ttf" # 与封面同款手写体 - font_size: 85 # ASS PlayResY=1080 坐标系; 15字≈画宽2/3 - primary_colour: "&H00FFFFFF" # ASS 颜色为 &HAABBGGRR + font_path: "assets/fonts/05_江西拙楷.ttf" + font_size: 85 + primary_colour: "&H00FFFFFF" outline_colour: "&H00000000" outline: 2.0 shadow: 1.0 - margin_v: 60 # 距底边像素(底部偏上) - max_chars_per_line: 15 # 单行最多字数, 超长句自动分段轮显 + margin_v: 60 + max_chars_per_line: 15 llm: base_url: "https://open.bigmodel.cn/api/paas/v4" model: "glm-4.6" temperature: 0.7 max_retries: 3 - # api_key 从环境变量 GLM_API_KEY 或 %LOCALAPPDATA%/hermes/.env 读取, 不写入本文件 script: - target_chars: 4500 # 成品解说词目标字数(AutoYY 标准 4500-5500) - tolerance: 0.12 # 字数验收容差 - section_chars: 850 # 分段生成每段目标字数 + target_chars: 4500 + tolerance: 0.12 + section_chars: 850 + supplement_rounds: 2 + +match: + batch_sentences: 12 + window_overlap: 0.12 + repair_attempts: 2 download: - min_duration: 1800 # S1 门禁: 时长 ≥ 30 分钟 - min_height: 720 # S1 门禁: 最高分辨率 ≥ 720p - max_height: 1080 # 下载上限(成片统一 1080p) - proxy: "" # 留空用环境变量; 或填 "http://127.0.0.1:15715" + min_duration: 1800 + min_height: 720 + max_height: 1080 + proxy: "" + verify_workers: 4 cover: - font_path: "D:/@kaifa/AutoUP/assets/fonts/05_江西拙楷.ttf" # 手写广告体(免费商用), 备选清单见 assets/fonts/LICENSES.txt + font_path: "assets/fonts/05_江西拙楷.ttf" diff --git a/autoup/run.py b/autoup/run.py index e246a92..e4d50ea 100644 --- a/autoup/run.py +++ b/autoup/run.py @@ -1,12 +1,4 @@ -"""一键全链路编排: manifest → S1核验 → S2下载 → S3文案 → S4配音 → S5匹配 → S6合成 → S7发布 → S8封面 → S9验收。 - -断点续传: 每个选题目录 state.json 记录阶段状态; 重跑自动跳过已完成阶段 -(阶段成功即标记, 产物存在且 state 标记 done 就不再重做; --force 重跑指定阶段)。 -用法: - python -m autoup.run --manifest urls.txt --batch 20260908-A - python -m autoup.run --manifest urls.txt --batch B --only s3,s4 --voice default - python -m autoup.run --batch B --set download.max_height=360 --set script.target_chars=400 -""" +"""AutoUP 一键流水线编排,支持可验证的断点续跑与精确阶段重跑。""" from __future__ import annotations import argparse @@ -16,17 +8,17 @@ import time import traceback from pathlib import Path +from time import perf_counter -from . import config, utils +from . import config, state as stage_state, utils from .stages import cover, download, dub, match, publish, render, script, validate log = logging.getLogger("autoup.run") STAGES = ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9"] +TOPIC_STAGES = ["s3", "s4", "s5", "s6", "s7", "s8", "s9"] -# ---------- state ---------- - def _load_state(topic_dir: Path) -> dict: return utils.read_json(topic_dir / "state.json", default={}) or {} @@ -37,21 +29,26 @@ def _save_state(topic_dir: Path, state: dict) -> None: def _stage_artifacts_ok(topic_dir: Path, stage: str) -> bool: - """检查阶段的最小产物,避免 state.json 与磁盘脱节后错误跳过。""" checks = { "s2": lambda: (topic_dir / "字幕" / "字幕.srt").exists() - and any(f.suffix.lower() in {".mp4", ".mkv", ".webm"} - for f in (topic_dir / "素材").glob("高清源视频.*")), + and any( + file.suffix.lower() in {".mp4", ".mkv", ".webm"} + for file in (topic_dir / "素材").glob("高清源视频.*") + ), "s3": lambda: (topic_dir / "文案" / "爆款口播稿.txt").exists() and (topic_dir / "文案" / "爆款口播稿.txt").stat().st_size > 0, "s4": lambda: _timing_artifacts_ok(topic_dir), "s5": lambda: bool((utils.read_json(topic_dir / "edit_decision.json") or {}).get("items")), "s6": lambda: (topic_dir / "成片.mp4").exists() and (topic_dir / "成片.mp4").stat().st_size > 1_000_000, - "s7": lambda: all((topic_dir / "发布" / name).exists() - for name in ("国内平台.txt", "海外平台.txt")), - "s8": lambda: all((topic_dir / "封面" / f"封面-{name}.png").exists() - for name in ("9x16", "16x9", "1x1")), + "s7": lambda: all( + (topic_dir / "发布" / name).exists() + for name in ("国内平台.txt", "海外平台.txt", "封面文案.txt") + ), + "s8": lambda: all( + (topic_dir / "封面" / f"封面-{name}.png").exists() + for name in ("9x16", "16x9", "1x1") + ), "s9": lambda: (topic_dir / "验收报告.json").exists(), } try: @@ -70,114 +67,218 @@ def _timing_artifacts_ok(topic_dir: Path) -> bool: ) -def _stage_done(topic_dir: Path, state: dict, stage: str, force: bool) -> bool: - if force or state.get("stages", {}).get(stage) != "done": +def _stage_done( + topic_dir: Path, + state: dict, + stage: str, + force: bool, + *, + voice: str = "default", + url: str = "", +) -> bool: + if force: + return False + entry = state.get("stages", {}).get(stage) + if not isinstance(entry, dict) or entry.get("status") != "done": return False if not _stage_artifacts_ok(topic_dir, stage): log.warning("%s 已标记完成但产物不完整,将重新执行", stage.upper()) return False + current = stage_state.stage_fingerprint(topic_dir, stage, voice=voice, url=url) + if entry.get("fingerprint") != current: + log.info("%s 输入或配置已变化,旧断点失效", stage.upper()) + return False return True -def _mark(topic_dir: Path, state: dict, stage: str, status: str = "done") -> None: - state.setdefault("stages", {})[stage] = status +def _mark( + topic_dir: Path, + state: dict, + stage: str, + status: str = "done", + *, + voice: str = "default", + url: str = "", + error: str | None = None, +) -> None: + entry = { + "status": status, + "completed_at": time.strftime("%Y-%m-%d %H:%M:%S"), + } + if status == "done": + entry["fingerprint"] = stage_state.stage_fingerprint(topic_dir, stage, voice=voice, url=url) + if error: + entry["error"] = error[:500] + state.setdefault("stages", {})[stage] = entry _save_state(topic_dir, state) -# ---------- 选题命名 ---------- +def _record_metric(topic_dir: Path, state: dict, stage: str, seconds: float) -> None: + state.setdefault("metrics", {})[stage] = {"seconds": round(seconds, 3)} + _save_state(topic_dir, state) + def _topic_name(url: str, meta: dict, idx: int) -> str: - """LLM 把英文标题缩成 ≤10 字中文选题名, 失败退回 选题NN。""" + del url try: from . import llm + data = llm.chat_json( - f"把以下纪录片标题浓缩为不超过10个汉字的中文选题名(用于文件夹名, 无标点空格):\n" - f"{meta.get('title', '')}\n输出 JSON: {{\"name\": \"...\"}}", temperature=0.3) + "把以下纪录片标题浓缩为不超过10个汉字的中文选题名(用于文件夹名, 无标点空格):\n" + f"{meta.get('title', '')}\n输出 JSON: {{\"name\": \"...\"}}", + temperature=0.3, + ) name = re.sub(r"[^\u4e00-\u9fff]", "", str(data.get("name", "")))[:10] if name: return f"{idx:02d}-{name}" - except Exception as e: # noqa: BLE001 - log.warning("选题命名失败: %s", e) + except Exception as exc: # noqa: BLE001 + log.warning("选题命名失败: %s", exc) return f"{idx:02d}-选题" -# ---------- 主流程 ---------- +def _run_topic_stage( + topic_dir: Path, + state: dict, + stage: str, + action, + *, + force: bool, + voice: str, +) -> None: + if _stage_done(topic_dir, state, stage, force, voice=voice): + return + started = perf_counter() + try: + action() + except Exception as exc: + _mark(topic_dir, state, stage, "failed", voice=voice, error=str(exc)) + raise + finally: + _record_metric(topic_dir, state, stage, perf_counter() - started) + _mark(topic_dir, state, stage, voice=voice) + def process_topic(topic_dir: Path, only: list[str] | None, force: bool, voice: str) -> dict: - """对单个选题目录跑完剩余阶段, 返回最终验收报告(或抛异常)。""" + """执行 S3-S9;部分阶段运行成功不等价于完整验收通过。""" state = _load_state(topic_dir) - stages = only or STAGES - - def todo(stage: str) -> bool: - return stage in stages and not _stage_done(topic_dir, state, stage, force) - - if todo("s3"): - script.run(topic_dir) - _mark(topic_dir, state, "s3") - if todo("s4"): - dub.run(topic_dir, voice=voice) - _mark(topic_dir, state, "s4") - if todo("s5"): - match.run(topic_dir) - _mark(topic_dir, state, "s5") - if todo("s6"): - render.run(topic_dir) - _mark(topic_dir, state, "s6") - if todo("s7"): - publish.run(topic_dir) - _mark(topic_dir, state, "s7") - if todo("s8"): - cover.run(topic_dir) - _mark(topic_dir, state, "s8") - report = validate.run(topic_dir) - _mark(topic_dir, state, "s9", "done" if report["passed"] else "failed") + selected = set(only or TOPIC_STAGES) + + actions = { + "s3": lambda: script.run(topic_dir), + "s4": lambda: dub.run(topic_dir, voice=voice), + "s5": lambda: match.run(topic_dir), + "s6": lambda: render.run(topic_dir), + "s7": lambda: publish.run(topic_dir), + "s8": lambda: cover.run(topic_dir), + } + for stage in TOPIC_STAGES[:-1]: + if stage in selected: + _run_topic_stage(topic_dir, state, stage, actions[stage], force=force, voice=voice) + + if "s9" not in selected: + return {"passed": True, "partial": True, "stages": sorted(selected)} + + report: dict = {} + started = perf_counter() + try: + report = validate.run(topic_dir) + finally: + _record_metric(topic_dir, state, "s9", perf_counter() - started) + _mark( + topic_dir, + state, + "s9", + "done" if report.get("passed") else "failed", + voice=voice, + error=None if report.get("passed") else ", ".join(report.get("failed", [])), + ) return report +def _s2_ready(topic_dir: Path) -> bool: + material = topic_dir / "素材" + return ( + (topic_dir / "字幕" / "字幕.srt").exists() + and material.exists() + and any( + file.suffix.lower() in {".mp4", ".mkv", ".webm"} + for file in material.glob("高清源视频.*") + ) + ) + + +def _clear_s2_outputs(topic_dir: Path) -> None: + material = topic_dir / "素材" + if material.exists(): + for file in material.glob("高清源视频.*"): + if file.is_file(): + file.unlink() + subtitle = topic_dir / "字幕" / "字幕.srt" + if subtitle.exists(): + subtitle.unlink() + + def _ensure_downloaded(td: Path, url: str, state: dict, force: bool) -> None: - """S2 幂等: 视频+字幕齐则标记完成, 缺则(续)下载。""" - if _stage_done(td, state, "s2", force): + if _stage_done(td, state, "s2", force, url=url): return - mat = td / "素材" - has_video = mat.exists() and any( - f.suffix.lower() in {".mp4", ".mkv", ".webm"} - for f in mat.glob("高清源视频.*")) - has_sub = (td / "字幕" / "字幕.srt").exists() - if has_video and has_sub: - _mark(td, state, "s2") + + existing_entry = state.get("stages", {}).get("s2") + if _s2_ready(td) and not force and not isinstance(existing_entry, dict): + log.info("S2 旧版断点无指纹,采用现有下载产物并写入新指纹") + _mark(td, state, "s2", url=url) return - files = download.download_topic(url, td) - state["video"] = files["video"].name - _mark(td, state, "s2") + if force or isinstance(existing_entry, dict): + _clear_s2_outputs(td) -def run_batch(manifest: Path | None, batch: str, only: list[str] | None, - force: bool, voice: str, limit: int | None) -> list[dict]: + started = perf_counter() + try: + meta = state.get("meta") or {} + files = download.download_topic(url, td, sub_lang=meta.get("sub_lang")) + state["video"] = files["video"].name + except Exception as exc: + _mark(td, state, "s2", "failed", url=url, error=str(exc)) + raise + finally: + _record_metric(td, state, "s2", perf_counter() - started) + _mark(td, state, "s2", url=url) + + +def run_batch( + manifest: Path | None, + batch: str, + only: list[str] | None, + force: bool, + voice: str, + limit: int | None, +) -> list[dict]: root = config.output_root() / batch root.mkdir(parents=True, exist_ok=True) - - # 已有选题目录 → 续跑模式 - existing = sorted(p for p in root.iterdir() if p.is_dir() and not p.name.startswith("_")) + selected = set(only or STAGES) + existing = sorted(path for path in root.iterdir() if path.is_dir() and not path.name.startswith("_")) results: list[dict] = [] if existing and manifest is None: + if selected == {"s1"}: + raise SystemExit("S1 是 manifest 级核验,续跑批次执行 S1 时必须提供 --manifest") log.info("续跑批次 %s: %d 个已有选题", batch, len(existing)) - for td in existing: - state = _load_state(td) + for topic_dir in existing: + state = _load_state(topic_dir) try: - url = state.get("url") - if url: - _ensure_downloaded(td, url, state, force) - report = process_topic(td, only, force, voice) - results.append({"topic": td.name, "passed": report["passed"]}) - except Exception as e: # noqa: BLE001 - log.error("选题 %s 失败: %s", td.name, e) - _mark(td, state, "error", f"failed: {str(e)[:200]}") - results.append({"topic": td.name, "passed": False, "error": str(e)[:200]}) + if "s2" in selected: + url = str(state.get("url") or "") + if not url: + raise RuntimeError("state.json 缺少源 URL,无法重跑 S2") + _ensure_downloaded(topic_dir, url, state, force) + report = process_topic(topic_dir, only, force, voice) + results.append({"topic": topic_dir.name, "passed": bool(report.get("passed", True))}) + except Exception as exc: # noqa: BLE001 + log.error("选题 %s 失败: %s", topic_dir.name, exc) + results.append({"topic": topic_dir.name, "passed": False, "error": str(exc)[:300]}) return results if manifest is None: - raise SystemExit("新批次需要 --manifest; 续跑则只给 --batch") + raise SystemExit("新批次需要 --manifest;续跑则只给 --batch") urls = download.read_manifest(manifest) log.info("manifest: %d 个 URL", len(urls)) @@ -186,72 +287,108 @@ def run_batch(manifest: Path | None, batch: str, only: list[str] | None, if limit: approved = approved[:limit] - for i, meta in enumerate(approved, 1): - name = _topic_name(meta["url"], meta, i) - td = root / name - td.mkdir(parents=True, exist_ok=True) - state = _load_state(td) + if selected == {"s1"}: + return [{"topic": "_S1核验", "passed": True, "approved": len(approved), "total": len(urls)}] + + for index, meta in enumerate(approved, 1): + name = _topic_name(meta["url"], meta, index) + topic_dir = root / name + topic_dir.mkdir(parents=True, exist_ok=True) + state = _load_state(topic_dir) state["url"] = meta["url"] - state["meta"] = {k: meta.get(k) for k in - ("vid", "title", "channel", "duration", "height", "sub_kind")} + state["meta"] = { + key: meta.get(key) + for key in ("vid", "title", "channel", "duration", "height", "sub_kind", "sub_lang") + } + _save_state(topic_dir, state) try: - _ensure_downloaded(td, meta["url"], state, force) - report = process_topic(td, only, force, voice) - results.append({"topic": name, "url": meta["url"], "passed": report["passed"]}) - except Exception as e: # noqa: BLE001 - log.error("选题 %s 失败: %s\n%s", name, e, traceback.format_exc()[-1500:]) - _mark(td, state, "error", f"failed: {str(e)[:200]}") - results.append({"topic": name, "url": meta["url"], "passed": False, - "error": str(e)[:200]}) + if "s2" in selected: + _ensure_downloaded(topic_dir, meta["url"], state, force) + report = process_topic(topic_dir, only, force, voice) + results.append( + { + "topic": name, + "url": meta["url"], + "passed": bool(report.get("passed", True)), + } + ) + except Exception as exc: # noqa: BLE001 + log.error("选题 %s 失败: %s\n%s", name, exc, traceback.format_exc()[-1500:]) + results.append( + { + "topic": name, + "url": meta["url"], + "passed": False, + "error": str(exc)[:300], + } + ) return results +def _parse_override(value: str): + lowered = value.lower() + if lowered in {"true", "false"}: + return lowered == "true" + if lowered in {"none", "null"}: + return None + try: + return int(value) + except ValueError: + try: + return float(value) + except ValueError: + return value + + def main(argv: list[str] | None = None) -> None: - ap = argparse.ArgumentParser(prog="autoup", description="AutoUP 纪录片解说全自动生产线") - ap.add_argument("--manifest", type=Path, help="URL 清单(每行一个或 CSV)") - ap.add_argument("--batch", required=True, help="批次名(产出根下的子目录)") - ap.add_argument("--only", help="只跑指定阶段, 逗号分隔: s3,s4,s5") - ap.add_argument("--force", action="store_true", help="强制重跑指定阶段") - ap.add_argument("--voice", default="default", help="GPT-SoVITS 音色名(config)") - ap.add_argument("--limit", type=int, help="本次最多处理几个选题") - ap.add_argument("--set", dest="overrides", action="append", default=[], - help="运行期配置覆盖, 如 --set script.target_chars=400 (可多次)") - ap.add_argument("--debug", action="store_true") - args = ap.parse_args(argv) + parser = argparse.ArgumentParser(prog="autoup", description="AutoUP 纪录片解说全自动生产线") + parser.add_argument("--manifest", type=Path, help="URL 清单(每行一个或 CSV)") + parser.add_argument("--batch", required=True, help="批次名(产出根下的子目录)") + parser.add_argument("--only", help="只跑指定阶段,逗号分隔: s3,s4,s5") + parser.add_argument("--force", action="store_true", help="强制重跑指定阶段") + parser.add_argument("--allow-partial", action="store_true", help="批次存在失败项时仍返回退出码 0") + parser.add_argument("--voice", default="default", help="GPT-SoVITS 音色名(config)") + parser.add_argument("--limit", type=int, help="本次最多处理几个选题") + parser.add_argument( + "--set", + dest="overrides", + action="append", + default=[], + help="运行期配置覆盖,如 --set script.target_chars=400 (可多次)", + ) + parser.add_argument("--debug", action="store_true") + args = parser.parse_args(argv) logging.basicConfig( level=logging.DEBUG if args.debug else logging.INFO, - format="%(asctime)s %(levelname)s %(name)s: %(message)s", datefmt="%H:%M:%S") - for ov in args.overrides: - key, _, val = ov.partition("=") + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + for override in args.overrides: + key, sep, raw_value = override.partition("=") + if not sep or not key.strip(): + parser.error(f"无效 --set: {override!r},格式应为 key=value") node = config.load() parts = key.strip().split(".") - for p in parts[:-1]: - node = node.setdefault(p, {}) - try: - val = int(val) - except ValueError: - try: - val = float(val) - except ValueError: - pass - node[parts[-1]] = val - log.info("配置覆盖: %s = %r", key, val) + for part in parts[:-1]: + node = node.setdefault(part, {}) + value = _parse_override(raw_value.strip()) + node[parts[-1]] = value + log.info("配置覆盖: %s = %r", key, value) - only = [s.strip().lower() for s in args.only.split(",")] if args.only else None + only = [stage.strip().lower() for stage in args.only.split(",") if stage.strip()] if args.only else None invalid = sorted(set(only or []) - set(STAGES)) if invalid: - ap.error(f"未知阶段: {', '.join(invalid)};可选值: {', '.join(STAGES)}") - results = run_batch(args.manifest, args.batch, - only, - args.force, args.voice, args.limit) + parser.error(f"未知阶段: {', '.join(invalid)};可选值: {', '.join(STAGES)}") + + results = run_batch(args.manifest, args.batch, only, args.force, args.voice, args.limit) print("\n===== 批次结果 =====") - for r in results: - print(("✅" if r.get("passed") else "❌"), r.get("topic"), - r.get("error", "")) - failed = [r for r in results if not r.get("passed")] - print(f"\n完成: {len(results) - len(failed)}/{len(results)} 个选题通过验收") - sys.exit(1 if (results and len(failed) == len(results)) else 0) + for result in results: + print(("✅" if result.get("passed") else "❌"), result.get("topic"), result.get("error", "")) + failed = [result for result in results if not result.get("passed")] + print(f"\n完成: {len(results) - len(failed)}/{len(results)} 个任务成功") + if failed and not args.allow_partial: + raise SystemExit(1) if __name__ == "__main__": diff --git a/autoup/state.py b/autoup/state.py new file mode 100644 index 0000000..e74d77b --- /dev/null +++ b/autoup/state.py @@ -0,0 +1,107 @@ +"""阶段输入指纹:让断点续跑能识别上游内容或配置变化。""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from . import config, utils + +VIDEO_EXTS = {".mp4", ".mkv", ".webm"} + + +def _sig(path: Path) -> dict[str, Any]: + return utils.file_fingerprint(path) + + +def _first_video(topic_dir: Path) -> Path | None: + material = topic_dir / "素材" + if not material.exists(): + return None + return next((p for p in material.glob("高清源视频.*") if p.suffix.lower() in VIDEO_EXTS), None) + + +def _llm_descriptor() -> dict[str, Any]: + return { + "base_url": config.get("llm.base_url"), + "model": config.get("llm.model"), + "temperature": config.get("llm.temperature"), + "max_retries": config.get("llm.max_retries"), + } + + +def _cfg(*keys: str) -> dict[str, Any]: + return {key: config.get(key) for key in keys} + + +def stage_fingerprint(topic_dir: Path, stage: str, *, voice: str = "default", url: str = "") -> str: + topic_dir = Path(topic_dir) + subtitle = topic_dir / "字幕" / "字幕.srt" + script = topic_dir / "文案" / "爆款口播稿.txt" + timing = topic_dir / "配音" / "timing.json" + edit = topic_dir / "edit_decision.json" + video = _first_video(topic_dir) + payload: dict[str, Any] = {"version": 1, "stage": stage} + + if stage == "s2": + payload.update({"url": url, "config": _cfg("download")}) + elif stage == "s3": + payload.update({"subtitle": _sig(subtitle), "config": _cfg("script"), "llm": _llm_descriptor()}) + elif stage == "s4": + payload.update( + { + "script": _sig(script), + "voice_name": voice, + "config": _cfg("voice"), + } + ) + elif stage == "s5": + payload.update( + { + "subtitle": _sig(subtitle), + "timing": _sig(timing), + "config": _cfg("match", "voice.gap_seconds"), + "llm": _llm_descriptor(), + } + ) + elif stage == "s6": + payload.update( + { + "video": _sig(video) if video else {"exists": False}, + "timing": _sig(timing), + "edit": _sig(edit), + "config": _cfg("video", "subtitle", "bgm", "voice.gap_seconds"), + } + ) + elif stage == "s7": + payload.update({"script": _sig(script), "llm": _llm_descriptor()}) + elif stage == "s8": + payload.update( + { + "video": _sig(video) if video else {"exists": False}, + "edit": _sig(edit), + "copy": _sig(topic_dir / "发布" / "封面文案.txt"), + "config": _cfg("cover"), + } + ) + elif stage == "s9": + payload.update( + { + "video": _sig(video) if video else {"exists": False}, + "subtitle": _sig(subtitle), + "script": _sig(script), + "timing": _sig(timing), + "edit": _sig(edit), + "final": _sig(topic_dir / "成片.mp4"), + "cn": _sig(topic_dir / "发布" / "国内平台.txt"), + "en": _sig(topic_dir / "发布" / "海外平台.txt"), + "covers": { + name: _sig(topic_dir / "封面" / f"封面-{name}.png") + for name in ("9x16", "16x9", "1x1") + }, + "config": _cfg("script", "video"), + } + ) + else: + payload["unknown"] = True + + return utils.stable_hash(payload) diff --git a/autoup/utils.py b/autoup/utils.py index cdbd376..de1ab57 100644 --- a/autoup/utils.py +++ b/autoup/utils.py @@ -1,58 +1,81 @@ -"""通用工具: ffmpeg 封装、时长探测、SRT 解析、中文估时、分句、JSON 读写。""" +"""通用工具:进程、媒体探测、SRT、文本、JSON 与稳定指纹。""" from __future__ import annotations +import hashlib import json import logging import re import subprocess +from functools import lru_cache from pathlib import Path +from typing import Any from . import config log = logging.getLogger("autoup") -# ---------- 进程与探测 ---------- -def run(cmd: list, desc: str = "", timeout: int | None = None, - cwd: str | Path | None = None) -> subprocess.CompletedProcess: - log.info("$ %s", " ".join(str(c) for c in cmd[:12]) + (" ..." if len(cmd) > 12 else "")) - p = subprocess.run([str(c) for c in cmd], capture_output=True, text=True, - encoding="utf-8", errors="replace", timeout=timeout, cwd=str(cwd) if cwd else None) - if p.returncode != 0: - log.error("[%s] 失败(%d): %s", desc or cmd[0], p.returncode, (p.stderr or "")[-800:]) - return p +def run( + cmd: list[Any], + desc: str = "", + timeout: int | None = None, + cwd: str | Path | None = None, +) -> subprocess.CompletedProcess[str]: + shown = " ".join(str(c) for c in cmd[:12]) + (" ..." if len(cmd) > 12 else "") + log.info("$ %s", shown) + proc = subprocess.run( + [str(c) for c in cmd], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + cwd=str(cwd) if cwd else None, + ) + if proc.returncode != 0: + log.error("[%s] 失败(%d): %s", desc or cmd[0], proc.returncode, (proc.stderr or "")[-800:]) + return proc def ffprobe_json(media: Path) -> dict: - p = run([config.ffprobe(), "-v", "quiet", "-print_format", "json", - "-show_format", "-show_streams", str(media)], desc="ffprobe") + proc = run( + [ + config.ffprobe(), + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + "-show_streams", + str(media), + ], + desc="ffprobe", + ) try: - return json.loads(p.stdout or "{}") + return json.loads(proc.stdout or "{}") except ValueError: return {} def probe_video(path: Path) -> dict: - """返回 {width,height,duration,fps,has_audio}""" info = ffprobe_json(path) - v = next((s for s in info.get("streams", []) if s.get("codec_type") == "video"), {}) - a = next((s for s in info.get("streams", []) if s.get("codec_type") == "audio"), None) + video = next((s for s in info.get("streams", []) if s.get("codec_type") == "video"), {}) + audio = next((s for s in info.get("streams", []) if s.get("codec_type") == "audio"), None) try: - num, den = str(v.get("avg_frame_rate", "30/1")).split("/") + num, den = str(video.get("avg_frame_rate", "30/1")).split("/") fps = float(num) / float(den or 1) except (ValueError, ZeroDivisionError): fps = 30.0 return { - "width": int(v.get("width", 0)), - "height": int(v.get("height", 0)), + "width": int(video.get("width", 0)), + "height": int(video.get("height", 0)), "duration": float(info.get("format", {}).get("duration", 0) or 0), "fps": fps if fps > 0 else 30.0, - "has_audio": a is not None, + "has_audio": audio is not None, } def audio_duration(path: Path) -> float: - """音频/视频时长(秒); ffprobe 失败用 pydub 兜底。""" info = ffprobe_json(path) try: return float(info["format"]["duration"]) @@ -60,123 +83,159 @@ def audio_duration(path: Path) -> float: pass ensure_pydub_ffmpeg() from pydub import AudioSegment + return len(AudioSegment.from_file(str(path))) / 1000.0 def ensure_pydub_ffmpeg() -> None: - """pydub 在调用时动态 which() 探测 ffmpeg/ffprobe, 需要目录在 PATH 里。""" import os - ff_dir = str(Path(config.ffmpeg()).parent) - if ff_dir not in os.environ.get("PATH", ""): - os.environ["PATH"] = ff_dir + os.pathsep + os.environ.get("PATH", "") + + ff = config.ffmpeg() + ff_path = Path(ff) + if ff_path.parent != Path("."): + ff_dir = str(ff_path.parent) + current = os.environ.get("PATH", "") + if ff_dir not in current.split(os.pathsep): + os.environ["PATH"] = ff_dir + os.pathsep + current + from pydub import AudioSegment - AudioSegment.converter = config.ffmpeg() - AudioSegment.ffmpeg = config.ffmpeg() + + AudioSegment.converter = ff + AudioSegment.ffmpeg = ff AudioSegment.ffprobe = config.ffprobe() +@lru_cache(maxsize=1) def nvenc_available() -> bool: - p = run([config.ffmpeg(), "-hide_banner", "-encoders"], desc="查编码器") - return "h264_nvenc" in (p.stdout or "") - + proc = run([config.ffmpeg(), "-hide_banner", "-encoders"], desc="查编码器") + return "h264_nvenc" in (proc.stdout or "") -# ---------- SRT ---------- _SRT_TIME = re.compile(r"(\d+):(\d+):(\d+)[,.](\d+)") def _srt_seconds(ts: str) -> float: - m = _SRT_TIME.match(ts.strip()) - if not m: + match = _SRT_TIME.match(ts.strip()) + if not match: raise ValueError(f"无法解析 SRT 时间: {ts!r}") - h, mnt, s, ms = (int(g) for g in m.groups()) - return h * 3600 + mnt * 60 + s + ms / 1000.0 + hours, minutes, seconds, millis = (int(group) for group in match.groups()) + return hours * 3600 + minutes * 60 + seconds + millis / 1000.0 def parse_srt(path: Path) -> list[dict]: - """解析 SRT 为 [{index,start,end,duration,text}] (start/end/duration 为秒)。""" raw = Path(path).read_text(encoding="utf-8-sig", errors="replace") blocks = re.split(r"\n\s*\n", raw.strip()) items: list[dict] = [] - for b in blocks: - lines = [l.strip() for l in b.splitlines() if l.strip()] + for block in blocks: + lines = [line.strip() for line in block.splitlines() if line.strip()] if len(lines) < 2: continue - i = 0 + cursor = 0 if re.fullmatch(r"\d+", lines[0]): - idx = int(lines[0]) - i = 1 + index = int(lines[0]) + cursor = 1 else: - idx = len(items) + 1 - mm = re.match(r"([\d:,.]+)\s*-->\s*([\d:,.]+)", lines[i]) - if not mm: + index = len(items) + 1 + if cursor >= len(lines): continue - text = " ".join(lines[i + 1:]) - start, end = _srt_seconds(mm.group(1)), _srt_seconds(mm.group(2)) - items.append({"index": idx, "start": start, "end": end, - "duration": round(max(end - start, 0.0), 3), "text": text}) + match = re.match(r"([\d:,.]+)\s*-->\s*([\d:,.]+)", lines[cursor]) + if not match: + continue + start = _srt_seconds(match.group(1)) + end = _srt_seconds(match.group(2)) + text = " ".join(lines[cursor + 1 :]) + items.append( + { + "index": index, + "start": start, + "end": end, + "duration": round(max(end - start, 0.0), 3), + "text": text, + } + ) return items -# ---------- 中文文本 ---------- - _STOP = "。!?;!?;" _SOFT = ",、,::—…" def split_sentences(text: str, max_len: int = 60) -> list[str]: - """解说文案分句: 句末标点优先; 超长句在软标点/硬上限处二次切分。""" - sents: list[str] = [] - buf = "" - for ch in text: - buf += ch - if ch in _STOP: - if buf.strip(): - sents.append(buf.strip()) - buf = "" - if buf.strip(): - sents.append(buf.strip()) - out: list[str] = [] - for s in sents: - if len(s) <= max_len: - out.append(s) + sentences: list[str] = [] + buffer = "" + for char in text: + buffer += char + if char in _STOP: + if buffer.strip(): + sentences.append(buffer.strip()) + buffer = "" + if buffer.strip(): + sentences.append(buffer.strip()) + + output: list[str] = [] + for sentence in sentences: + if len(sentence) <= max_len: + output.append(sentence) continue - cur = "" - for ch in s: - cur += ch - if len(cur) >= max_len and (ch in _SOFT or len(cur) >= max_len + 20): - out.append(cur.strip()) - cur = "" - if cur.strip(): - out.append(cur.strip()) - return [s for s in out if s] + current = "" + for char in sentence: + current += char + if len(current) >= max_len and (char in _SOFT or len(current) >= max_len + 20): + output.append(current.strip()) + current = "" + if current.strip(): + output.append(current.strip()) + return [item for item in output if item] def estimate_duration(text: str) -> float: - """中文配音时长预估(秒): 每汉字 1 音节 × 0.21s + 标点停顿(VideoLingo 参数)。""" zh = re.sub(r"[^\u4e00-\u9fff]", "", text) - dur = len(zh) * 0.21 - dur += sum(0.15 for ch in text if ch in _STOP) - dur += sum(0.08 for ch in text if ch in _SOFT) - return round(dur, 2) + duration = len(zh) * 0.21 + duration += sum(0.15 for char in text if char in _STOP) + duration += sum(0.08 for char in text if char in _SOFT) + return round(duration, 2) def fmt_ts(seconds: float) -> str: - ms = int(round(max(seconds, 0.0) * 1000)) - h, ms = divmod(ms, 3600000) - m, ms = divmod(ms, 60000) - s, ms = divmod(ms, 1000) - return f"{h}:{m:02d}:{s:02d}.{ms:03d}" - + millis = int(round(max(seconds, 0.0) * 1000)) + hours, millis = divmod(millis, 3_600_000) + minutes, millis = divmod(millis, 60_000) + secs, millis = divmod(millis, 1000) + return f"{hours}:{minutes:02d}:{secs:02d}.{millis:03d}" + + +def stable_hash(data: Any) -> str: + raw = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def file_fingerprint(path: Path, sample_bytes: int = 1_048_576) -> dict[str, Any]: + """小文件全量哈希;大文件只哈希首尾采样,避免断点检查反复读取整部视频。""" + path = Path(path) + if not path.exists() or not path.is_file(): + return {"exists": False} + size = path.stat().st_size + digest = hashlib.sha256() + digest.update(str(size).encode("ascii")) + with path.open("rb") as handle: + if size <= sample_bytes * 2: + digest.update(handle.read()) + else: + digest.update(handle.read(sample_bytes)) + handle.seek(max(size - sample_bytes, 0)) + digest.update(handle.read(sample_bytes)) + return {"exists": True, "size": size, "sha256": digest.hexdigest()} -# ---------- JSON ---------- -def write_json(path: Path, data) -> None: - Path(path).parent.mkdir(parents=True, exist_ok=True) - Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") +def write_json(path: Path, data: Any) -> None: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_name(path.name + ".tmp") + temp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + temp.replace(path) -def read_json(path: Path, default=None): +def read_json(path: Path, default: Any = None) -> Any: try: return json.loads(Path(path).read_text(encoding="utf-8")) except (OSError, ValueError): From 8f170a7fdd6f7ccc083a042ed44912cfafaa36b0 Mon Sep 17 00:00:00 2001 From: DaBao Date: Sat, 3 Oct 2026 22:57:48 +0800 Subject: [PATCH 02/12] fix: enforce factual script, voice-aware cache and semantic matching --- autoup/config.yaml | 6 +- autoup/llm.py | 105 ++++++---- autoup/stages/dub.py | 111 ++++++++--- autoup/stages/match.py | 418 +++++++++++++++++++++++----------------- autoup/stages/script.py | 279 +++++++++++++++++++-------- autoup/tts/__init__.py | 27 +-- 6 files changed, 602 insertions(+), 344 deletions(-) diff --git a/autoup/config.yaml b/autoup/config.yaml index da61f06..ff72343 100644 --- a/autoup/config.yaml +++ b/autoup/config.yaml @@ -57,6 +57,7 @@ llm: model: "glm-4.6" temperature: 0.7 max_retries: 3 + max_tokens: 8192 script: target_chars: 4500 @@ -65,8 +66,9 @@ script: supplement_rounds: 2 match: - batch_sentences: 12 - window_overlap: 0.12 + batch_sentences: 6 + window_overlap: 0.15 + max_context_chars: 14000 repair_attempts: 2 download: diff --git a/autoup/llm.py b/autoup/llm.py index ea09c89..d9c55d6 100644 --- a/autoup/llm.py +++ b/autoup/llm.py @@ -1,77 +1,108 @@ -"""GLM (zai/bigmodel OpenAI 兼容) 客户端: chat + JSON 模式, 带重试。 - -api_key 来源见 config._find_glm_key() (环境变量 GLM_API_KEY 或 Hermes .env)。 -""" +"""OpenAI 兼容 LLM 客户端:连接复用、JSON 模式与有边界的重试。""" from __future__ import annotations import json import logging import re import time +from typing import Any import requests from . import config log = logging.getLogger("autoup.llm") +_SESSION = requests.Session() -def _headers() -> dict: - key = str(config.get("llm.api_key", "")) +def _headers() -> dict[str, str]: + key = str(config.get("llm.api_key", "") or "") if not key: - raise RuntimeError("缺少 GLM_API_KEY (环境变量或 %LOCALAPPDATA%/hermes/.env)") + raise RuntimeError("缺少 GLM_API_KEY(环境变量或本机配置)") return {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} def _strip_fence(text: str) -> str: - m = re.search(r"```(?:json)?\s*(.*?)```", text, re.S) - return (m.group(1) if m else text).strip() + match = re.search(r"\x60\x60\x60(?:json)?\s*(.*?)\x60\x60\x60", text, re.S | re.I) + return (match.group(1) if match else text).strip() + +def chat( + prompt: str, + system: str = "", + json_mode: bool = False, + temperature: float | None = None, +) -> str: + base_url = str(config.get("llm.base_url", "") or "").rstrip("/") + if not base_url: + raise RuntimeError("llm.base_url 未配置") -def chat(prompt: str, system: str = "", json_mode: bool = False, - temperature: float | None = None) -> str: - """单轮对话; json_mode=True 时要求输出可解析 JSON。""" - url = str(config.get("llm.base_url")).rstrip("/") + "/chat/completions" - messages = ([{"role": "system", "content": system}] if system else []) + \ - [{"role": "user", "content": prompt}] - body = { + messages = ([{"role": "system", "content": system}] if system else []) + [ + {"role": "user", "content": prompt} + ] + body: dict[str, Any] = { "model": str(config.get("llm.model", "glm-4.6")), "messages": messages, "temperature": float(config.get("llm.temperature", 0.7)) - if temperature is None else temperature, - "max_tokens": 8192, + if temperature is None + else temperature, + "max_tokens": int(config.get("llm.max_tokens", 8192)), } if json_mode: body["response_format"] = {"type": "json_object"} - body["thinking"] = {"type": "disabled"} # GLM 思考模型: JSON 输出禁用深度思考 - retries = int(config.get("llm.max_retries", 3)) - last_err: Exception | str = "" + body["thinking"] = {"type": "disabled"} + + retries = max(int(config.get("llm.max_retries", 3)), 1) + last_error = "" for attempt in range(1, retries + 1): try: - r = requests.post(url, headers=_headers(), json=body, timeout=300) - if r.status_code == 200: - msg = r.json()["choices"][0]["message"] - content = (msg.get("content") or "").strip() + response = _SESSION.post( + f"{base_url}/chat/completions", + headers=_headers(), + json=body, + timeout=300, + ) + if response.status_code == 200: + message = response.json()["choices"][0]["message"] + content = (message.get("content") or "").strip() if not content: - content = (msg.get("reasoning_content") or "").strip() + content = (message.get("reasoning_content") or "").strip() if content: return _strip_fence(content) if json_mode else content - last_err = f"空内容: message keys={list(msg.keys())}" + last_error = f"空内容: keys={list(message.keys())}" else: - last_err = f"HTTP {r.status_code}: {r.text[:300]}" - except (requests.RequestException, KeyError, ValueError) as e: - last_err = str(e) - log.warning("LLM 第 %d/%d 次失败: %s", attempt, retries, last_err) - time.sleep(min(2 ** attempt * 3, 60)) - raise RuntimeError(f"LLM 调用失败(重试 {retries} 次): {last_err}") + last_error = f"HTTP {response.status_code}: {response.text[:300]}" + if response.status_code < 500 and response.status_code != 429: + break + except (requests.RequestException, KeyError, TypeError, ValueError) as exc: + last_error = str(exc) + + if attempt < retries: + delay = min(2 ** attempt * 2, 30) + log.warning( + "LLM 第 %d/%d 次失败: %s;%ds 后重试", + attempt, + retries, + last_error, + delay, + ) + time.sleep(delay) + + raise RuntimeError(f"LLM 调用失败(尝试 {retries} 次): {last_error}") -def chat_json(prompt: str, system: str = "", temperature: float | None = None) -> dict: +def chat_json( + prompt: str, + system: str = "", + temperature: float | None = None, +) -> Any: raw = chat(prompt, system=system, json_mode=True, temperature=temperature) try: return json.loads(raw) except ValueError: - m = re.search(r"\{.*\}|\[.*\]", raw, re.S) - if m: - return json.loads(m.group(0)) + cleaned = _strip_fence(raw) + for pattern in (r"\{.*\}", r"\[.*\]"): + match = re.search(pattern, cleaned, re.S) + if match: + return json.loads(match.group(0)) raise diff --git a/autoup/stages/dub.py b/autoup/stages/dub.py index 03bebcc..092732c 100644 --- a/autoup/stages/dub.py +++ b/autoup/stages/dub.py @@ -1,9 +1,4 @@ -"""S4 配音: 文案分句 → 逐句 TTS(双引擎自动降级) → 实测时长 → timing.json。 - -输入: <选题>/文案/爆款口播稿.txt -输出: <选题>/配音/0001.wav... + <选题>/配音/timing.json -幂等: 已存在且大于 1KB 的单句音频直接复用(断点续传)。 -""" +"""S4 配音:逐句 TTS + 输入指纹缓存,避免文案/音色变化后误复用旧音频。""" from __future__ import annotations import logging @@ -18,6 +13,24 @@ MIN_WAV_BYTES = 1024 +def _voice_fingerprint(text: str, voice: str) -> str: + voice_config = config.get("voice", {}) or {} + ref_sig = None + ref = config.get(f"voice.gpt_sovits.voices.{voice}.ref_audio_path") + if ref: + ref_path = Path(str(ref)).expanduser() + if ref_path.exists(): + ref_sig = utils.file_fingerprint(ref_path) + return utils.stable_hash( + { + "text": text, + "voice": voice, + "voice_config": voice_config, + "reference_audio": ref_sig, + } + ) + + def run(topic_dir: Path, voice: str = "default", max_sentences: int | None = None) -> Path: script = topic_dir / "文案" / SCRIPT_NAME if not script.exists(): @@ -28,39 +41,79 @@ def run(topic_dir: Path, voice: str = "default", max_sentences: int | None = Non out_dir = topic_dir / "配音" out_dir.mkdir(parents=True, exist_ok=True) + previous = utils.read_json(out_dir / "timing.json", default={}) or {} + previous_by_index = { + int(item.get("index", 0)): item + for item in previous.get("sentences", []) + if item.get("index") + } sentences = utils.split_sentences(text) if max_sentences: sentences = sentences[:max_sentences] log.warning("测试模式: 仅处理前 %d 句", max_sentences) - total = utils.estimate_duration("".join(sentences)) - log.info("分句 %d 段, 预估总时长 %.0fs (%.1f 分钟)", len(sentences), total, total / 60) entries: list[dict] = [] - for i, sent in enumerate(sentences, 1): - wav = out_dir / f"{i:04d}.wav" - if wav.exists() and wav.stat().st_size > MIN_WAV_BYTES: - dur = utils.audio_duration(wav) - entries.append({"index": i, "text": sent, "audio": wav.name, - "duration": round(dur, 3), "engine": "cached"}) - log.info("[%d/%d] 复用已有音频 %.1fs", i, len(sentences), dur) + expected_files: set[str] = set() + for index, sentence in enumerate(sentences, 1): + wav = out_dir / f"{index:04d}.wav" + expected_files.add(wav.name) + fingerprint = _voice_fingerprint(sentence, voice) + old = previous_by_index.get(index) or {} + cache_ok = ( + wav.exists() + and wav.stat().st_size > MIN_WAV_BYTES + and old.get("fingerprint") == fingerprint + ) + if cache_ok: + duration = utils.audio_duration(wav) + entries.append( + { + "index": index, + "text": sentence, + "audio": wav.name, + "duration": round(duration, 3), + "engine": old.get("engine", "cached"), + "fingerprint": fingerprint, + } + ) + log.info("[%d/%d] 复用缓存 %.1fs", index, len(sentences), duration) continue - audio, engine = tts_synthesize(sent, wav) - if audio is None: - raise RuntimeError(f"第 {i} 句全部 TTS 引擎失败: {sent[:50]}…") - dur = utils.audio_duration(wav) - entries.append({"index": i, "text": sent, "audio": wav.name, - "duration": round(dur, 3), "engine": engine}) - log.info("[%d/%d] %s %.1fs: %s", i, len(sentences), engine, dur, sent[:30]) + if wav.exists(): + wav.unlink() + audio, engine = tts_synthesize(sentence, wav, voice=voice) + if audio is None or not wav.exists() or wav.stat().st_size <= MIN_WAV_BYTES: + raise RuntimeError(f"第 {index} 句全部 TTS 引擎失败: {sentence[:50]}…") + duration = utils.audio_duration(wav) + entries.append( + { + "index": index, + "text": sentence, + "audio": wav.name, + "duration": round(duration, 3), + "engine": engine, + "fingerprint": fingerprint, + } + ) + log.info("[%d/%d] %s %.1fs: %s", index, len(sentences), engine, duration, sentence[:30]) + + for wav in out_dir.glob("*.wav"): + if wav.name not in expected_files and wav.stem.isdigit(): + wav.unlink() + + gap = float(config.get("voice.gap_seconds", 0.3)) timing = { "voice": voice, - "gap_seconds": float(config.get("voice.gap_seconds", 0.3)), - "total_duration": round(sum(e["duration"] for e in entries) - + config.get("voice.gap_seconds", 0.3) * max(len(entries) - 1, 0), 3), + "gap_seconds": gap, + "total_duration": round( + sum(entry["duration"] for entry in entries) + + gap * max(len(entries) - 1, 0), + 3, + ), "sentences": entries, } - out = out_dir / "timing.json" - utils.write_json(out, timing) - log.info("timing.json 完成: %d 句, 配音净时长 %.1fs", len(entries), timing["total_duration"]) - return out + output = out_dir / "timing.json" + utils.write_json(output, timing) + log.info("S4 完成: %d 句,配音总时长 %.1fs", len(entries), timing["total_duration"]) + return output diff --git a/autoup/stages/match.py b/autoup/stages/match.py index 5382396..6c477f9 100644 --- a/autoup/stages/match.py +++ b/autoup/stages/match.py @@ -1,12 +1,4 @@ -"""S5 影子匹配: 解说句 × 原片 SRT 语义匹配 → edit_decision.json。 - -匹配 prompt 思想参考 NarratoAI prompts/film_tv_narration/script_matching.py (MIT)。 -护栏(在 LLM 之外由代码强制): - - 每段 start/end 必须落在某个字幕块的 [start, end+容差] 内 (防幻觉) - - 段间不重叠、时间递增 - - 画面时长 ≥ 配音时长 - 0.5s (不足时自动向后顺延扩展) - - 自动剔除片头/片尾/广告/预告(关键字段 LLM 提示 + 字幕关键词二次过滤) -""" +"""S5 画面匹配:按解说进度分窗口匹配,缺句必须二次修复,禁止随机空闲画面兜底。""" from __future__ import annotations import logging @@ -18,214 +10,282 @@ log = logging.getLogger("autoup.s5") BAD_KEYWORDS = re.compile( - r"订阅|关注(我们|频道)|赞助|广告|片尾|下集预告|感谢观看|subscribe|like and", re.I) -TAIL_TOLERANCE = 1.5 # 段尾允许超出字幕块尾的秒数 -MIN_PICTURE_PAD = 0.5 # 画面比配音至少多出的缓冲 + r"订阅|关注(我们|频道)|赞助|广告|片尾|下集预告|感谢观看|subscribe|like and", + re.I, +) +TAIL_TOLERANCE = 1.5 -def _srt_context(srt: list[dict], max_chars: int = 12000) -> str: - """SRT → 紧凑时间轴文本(带序号), 超长截尾。""" - lines = [f"[{i}] {utils.fmt_ts(c['start'])}-{utils.fmt_ts(c['end'])} {c['text']}" - for i, c in enumerate(srt)] +def _srt_context(cues: list[dict], max_chars: int) -> str: + lines = [ + f"[{index}] {utils.fmt_ts(cue['start'])}-{utils.fmt_ts(cue['end'])} {cue['text']}" + for index, cue in enumerate(cues) + ] text = "\n".join(lines) - if len(text) > max_chars: - text = text[:max_chars] + "\n...(字幕过长已截断, 只使用之前的段落)" - return text - - -def _prompt(srt_text: str, sentences: list[str], topic: str) -> str: - sent_lines = "\n".join(f"{i}. {s}" for i, s in enumerate(sentences, 1)) - return f"""# 纪录片解说-画面匹配任务 - -## 选题 -{topic} - -## 解说文案(逐句编号, 必须全部使用且按顺序) + if len(text) <= max_chars: + return text + half = max_chars // 2 + center = len(text) // 2 + return "...(窗口前部省略)\n" + text[max(0, center - half) : center + half] + "\n...(窗口后部省略)" + + +def _window_cues( + srt: list[dict], + start_fraction: float, + end_fraction: float, + overlap_ratio: float, +) -> list[dict]: + if not srt: + return [] + source_start = srt[0]["start"] + source_end = srt[-1]["end"] + duration = max(source_end - source_start, 1.0) + span = max(end_fraction - start_fraction, 0.01) + margin = span * max(overlap_ratio, 0.0) + left = max(0.0, start_fraction - margin) + right = min(1.0, end_fraction + margin) + window_start = source_start + duration * left + window_end = source_start + duration * right + selected = [ + cue for cue in srt if cue["end"] >= window_start and cue["start"] <= window_end + ] + return selected or srt + + +def _prompt(cues_text: str, sentences: list[dict], topic: str, *, repair: bool = False) -> str: + sentence_lines = "\n".join( + f"{item['index']}. {item['text']}" for item in sentences + ) + task = ( + "这是缺失句修复任务。只返回这些句子的匹配结果。" + if repair + else "这是本批次的画面匹配任务。" + ) + return f"""# 纪录片解说-画面匹配 + +选题: {topic} +{task} + +解说句(使用这里给出的全局 sentence_index): -{sent_lines} +{sentence_lines} -## 原片英文字幕时间轴([序号] 起-止 英文) +当前允许使用的原片字幕时间窗口: -{srt_text} +{cues_text} -## 任务 -为每一句解说匹配一段原片画面(取自字幕时间轴), 输出严格 JSON: +输出严格 JSON: {{"items": [{{"sentence_index": 1, "start": "0:01:23.000", "end": "0:01:29.500"}}]}} -## 规则 -1. 每句解说对应一段或多段连续画面; 按解说顺序输出 items。 -2. 画面内容必须与解说句语义对应(事件/人物/场景/物件)。 -3. 禁止匹配: 片头品牌动画、片尾致谢/订阅、广告、预告。字幕出现 subscribe/广告/预告类字样的时间段不得使用。 -4. 画面时长要接近该句解说时长(中文约 0.21 秒/字), 可略长不可明显偏短。 -5. 同一段画面只能被一句解说使用, items 之间时间不得重叠, 且必须时间递增。 -6. 一句解说过长时可拆成多段连续画面(同一 sentence_index 出现多次)。 -7. start/end 必须来自上面字幕时间轴的真实时间(可对齐到其间的任意点), 严禁编造不存在的时刻。 -""" - - -def _parse_ts(ts: str) -> float: - ts = ts.strip().replace(",", ".") - parts = ts.split(":") - parts = [float(p) for p in parts] +规则: +1. 每一句必须且只对应一段连续画面,sentence_index 必须与上面的全局编号一致。 +2. 按句子顺序选择时间递增、互不重叠的真实画面。 +3. start 必须落在给定字幕窗口附近,end 可向后延长以覆盖配音时长。 +4. 禁止片头品牌动画、订阅/广告、片尾致谢和预告。 +5. 内容必须与该解说句语义对应;找不到合适画面时不要编造时间。""" + + +def _parse_ts(value: str) -> float: + parts = [float(part) for part in value.strip().replace(",", ".").split(":")] while len(parts) < 3: parts.insert(0, 0.0) return parts[0] * 3600 + parts[1] * 60 + parts[2] -def _validate_and_fix(items: list[dict], srt: list[dict], - sentences: list[str]) -> tuple[list[dict], list[str]]: - """护栏校验 + 自动修复; 返回 (合法段列表, 问题说明列表)。""" - problems: list[str] = [] - out: list[dict] = [] - last_end = -1.0 - video_end = srt[-1]["end"] if srt else 0.0 - # 字幕块边界索引: (块start, 块end+TOLERANCE) - bounds = [(c["start"], c["end"] + TAIL_TOLERANCE) for c in srt] +def _clamp_start(value: float, srt: list[dict]) -> float | None: + for cue in srt: + left = cue["start"] - 5.0 + right = cue["end"] + TAIL_TOLERANCE + 5.0 + if left <= value <= right: + return max(cue["start"], min(value, cue["end"] + TAIL_TOLERANCE)) + return None + - def clamp_to_srt(t: float) -> float | None: - """时刻必须落在任一字幕块附近(±5s), 否则视为幻觉。""" - for bs, be in bounds: - if bs - 5.0 <= t <= be + 5.0: - return min(max(t, bs), be) - return None +def _filter_bad(srt: list[dict], items: list[dict]) -> list[dict]: + bad_ranges = [ + (cue["start"], cue["end"]) for cue in srt if BAD_KEYWORDS.search(cue["text"]) + ] + if not bad_ranges: + return items + + def overlaps(item: dict) -> bool: + return any(item["start"] < end and item["end"] > start for start, end in bad_ranges) + + return [item for item in items if not overlaps(item)] - for it in items: + +def _validate_and_fix( + raw_items: list[dict], + srt: list[dict], + sentences: list[dict], +) -> tuple[list[dict], list[str]]: + problems: list[str] = [] + by_index = {int(item["index"]): item for item in sentences} + parsed: list[dict] = [] + + for raw in raw_items: try: - si = int(it.get("sentence_index", 0)) - st, en = _parse_ts(str(it["start"])), _parse_ts(str(it["end"])) + sentence_index = int(raw.get("sentence_index", 0)) + start = _parse_ts(str(raw["start"])) + end = _parse_ts(str(raw["end"])) except (KeyError, TypeError, ValueError): - problems.append(f"丢弃无法解析的段: {it}") - continue - if not (1 <= si <= len(sentences)): - problems.append(f"句序号越界: {it}") - continue - cst = clamp_to_srt(st) - if cst is None: - problems.append(f"start 落在字幕空隙(疑似幻觉): {it['start']}") + problems.append(f"丢弃无法解析的段: {str(raw)[:120]}") continue - cen = clamp_to_srt(max(en, cst + 0.5)) - if cen is None: - cen = min(cst + 5.0, video_end) - st, en = min(cst, cen), max(cst, cen) - if en > video_end: - en = video_end - if st <= last_end + 0.01: - st = last_end + 0.05 - if en - st < 0.5: - problems.append(f"段与前段重叠过多被丢弃: {it}") - continue - en = max(en, st + 0.5) - # 画面时长须覆盖该句配音 - need = _sentence_need(sentences[si - 1]) - if en - st < need - 1.0: - en = min(st + need, video_end) # 顺延扩展 - if en <= st: - problems.append(f"画面段无效: {it}") + if sentence_index not in by_index: + problems.append(f"句序号越界: {sentence_index}") continue - out.append({"sentence_index": si, "start": round(st, 3), "end": round(en, 3)}) - last_end = en - return out, problems + parsed.append( + {"sentence_index": sentence_index, "start": start, "end": end} + ) - -def _sentence_need(sentence: str) -> float: - """该句解说的配音时长需求(估时 + 句间间隙)。""" + parsed.sort(key=lambda item: (item["sentence_index"], item["start"])) + output: list[dict] = [] + seen: set[int] = set() + last_end = -1.0 + video_end = srt[-1]["end"] if srt else 0.0 gap = float(config.get("voice.gap_seconds", 0.3)) - return utils.estimate_duration(sentence) + gap + for item in parsed: + sentence_index = item["sentence_index"] + if sentence_index in seen: + problems.append(f"第 {sentence_index} 句返回多个片段,仅保留首个") + continue -def _filter_bad(srt: list[dict], items: list[dict]) -> list[dict]: - """命中广告/片尾关键词的字幕块所在时间段直接弃用。""" - bad_ranges = [(c["start"], c["end"]) for c in srt if BAD_KEYWORDS.search(c["text"])] - if not bad_ranges: - return items - - def overlap(a: dict) -> bool: - return any(a["start"] < be and a["end"] > bs for bs, be in bad_ranges) + start = _clamp_start(item["start"], srt) + if start is None: + problems.append(f"第 {sentence_index} 句 start 不在真实字幕附近") + continue + start = max(start, last_end + 0.05 if last_end >= 0 else start) + minimum = float(by_index[sentence_index].get("duration", 0)) + gap + end = max(float(item["end"]), start + max(minimum, 0.5)) + end = min(end, video_end) + if end - start < max(minimum - 0.5, 0.4): + problems.append(f"第 {sentence_index} 句画面时长不足") + continue - kept = [a for a in items if not overlap(a)] - if len(kept) != len(items): - log.warning("广告/片尾过滤: %d 段被剔除", len(items) - len(kept)) - return kept + fixed = { + "sentence_index": sentence_index, + "start": round(start, 3), + "end": round(end, 3), + } + output.append(fixed) + seen.add(sentence_index) + last_end = end + + return output, problems + + +def _call_match( + topic: str, + srt: list[dict], + sentences: list[dict], + total_sentences: int, + *, + repair: bool, +) -> list[dict]: + first_index = int(sentences[0]["index"]) + last_index = int(sentences[-1]["index"]) + start_fraction = max((first_index - 1) / max(total_sentences, 1), 0.0) + end_fraction = min(last_index / max(total_sentences, 1), 1.0) + cues = _window_cues( + srt, + start_fraction, + end_fraction, + float(config.get("match.window_overlap", 0.15)), + ) + context = _srt_context(cues, int(config.get("match.max_context_chars", 14000))) + data = llm.chat_json( + _prompt(context, sentences, topic, repair=repair), + system="你是纪录片剪辑师,只能使用给定的真实字幕时间窗口,严格输出 JSON。", + temperature=0.25 if repair else 0.3, + ) + items = data.get("items") if isinstance(data, dict) else data + if not isinstance(items, list): + raise ValueError(f"LLM 返回结构异常: {str(data)[:200]}") + return items def run(topic_dir: Path) -> Path: - srt_path = next((topic_dir / "字幕").glob("*.srt"), None) \ - if (topic_dir / "字幕").exists() else None + subtitle_dir = topic_dir / "字幕" + srt_path = next(subtitle_dir.glob("*.srt"), None) if subtitle_dir.exists() else None timing = utils.read_json(topic_dir / "配音" / "timing.json") if srt_path is None: - raise FileNotFoundError(f"缺少字幕: {topic_dir}/字幕/*.srt") - if not timing: - raise FileNotFoundError("缺少 timing.json (先跑 S4 配音)") + raise FileNotFoundError(f"缺少字幕: {subtitle_dir}/*.srt") + if not timing or not timing.get("sentences"): + raise FileNotFoundError("缺少有效 timing.json(先跑 S4)") - sentences = [s["text"] for s in timing["sentences"]] + sentences = timing["sentences"] srt = utils.parse_srt(srt_path) - topic = topic_dir.name - srt_text = _srt_context(srt) - - data = llm.chat_json(_prompt(srt_text, sentences, topic), - system="你是一位懂纪录片叙事节奏的剪辑师, 严格输出 JSON。", - temperature=0.3) - items = data.get("items") if isinstance(data, dict) else data - if not isinstance(items, list): - raise ValueError(f"LLM 返回结构异常: {str(data)[:200]}") + if not srt: + raise ValueError("SRT 无有效字幕块") - # 按句覆盖检查: 缺句则补近似段(顺延上一段尾) - fixed, problems = _validate_and_fix(items, srt, sentences) + batch_size = max(int(config.get("match.batch_sentences", 6)), 1) + topic = topic_dir.name + raw_items: list[dict] = [] + for offset in range(0, len(sentences), batch_size): + batch = sentences[offset : offset + batch_size] + raw_items.extend(_call_match(topic, srt, batch, len(sentences), repair=False)) + log.info( + "S5 匹配批次 %d-%d/%d", + batch[0]["index"], + batch[-1]["index"], + len(sentences), + ) + + fixed, problems = _validate_and_fix(raw_items, srt, sentences) fixed = _filter_bad(srt, fixed) - missing = sorted(set(range(1, len(sentences) + 1)) - {it["sentence_index"] for it in fixed}) - if missing: - problems.append(f"未覆盖解说句: {missing}") - fixed = _fill_missing(fixed, sentences, srt, missing) - covered = sorted({it["sentence_index"] for it in fixed}) - ed = { + repair_attempts = max(int(config.get("match.repair_attempts", 2)), 0) + repairs_used = 0 + for attempt in range(repair_attempts): + covered = {item["sentence_index"] for item in fixed} + missing = [ + sentence for sentence in sentences if int(sentence["index"]) not in covered + ] + if not missing: + break + repairs_used = attempt + 1 + log.warning("S5 第 %d 轮修复,缺失 %d 句", repairs_used, len(missing)) + repair_raw: list[dict] = [] + for offset in range(0, len(missing), batch_size): + batch = missing[offset : offset + batch_size] + repair_raw.extend(_call_match(topic, srt, batch, len(sentences), repair=True)) + raw_items.extend(repair_raw) + fixed, new_problems = _validate_and_fix(raw_items, srt, sentences) + problems.extend(new_problems) + fixed = _filter_bad(srt, fixed) + + covered = {item["sentence_index"] for item in fixed} + missing_indexes = [ + int(sentence["index"]) + for sentence in sentences + if int(sentence["index"]) not in covered + ] + if missing_indexes: + raise RuntimeError( + "S5 语义匹配验收失败,仍有未覆盖句,拒绝随机补画面: " + + ",".join(map(str, missing_indexes[:20])) + ) + + expected_indexes = {int(sentence["index"]) for sentence in sentences} + if covered != expected_indexes or len(fixed) != len(sentences): + raise RuntimeError("S5 覆盖集合异常,未达到一语句一画面的硬门槛") + + edit = { "topic": topic, "source_srt": srt_path.name, "items": fixed, - "uncovered_sentences": missing, - "problems": problems, - "total_picture_duration": round(sum(it["end"] - it["start"] for it in fixed), 3), + "uncovered_sentences": [], + "problems": problems[-50:], + "repair_rounds": repairs_used, + "total_picture_duration": round( + sum(item["end"] - item["start"] for item in fixed), + 3, + ), } - out = topic_dir / "edit_decision.json" - utils.write_json(out, ed) - log.info("匹配完成: %d 段画面 / %d 句解说, 覆盖 %d 句, 画面总时长 %.0fs", - len(fixed), len(sentences), len(covered), ed["total_picture_duration"]) - if problems: - log.warning("匹配问题(已尽力修复): %s", "; ".join(problems[:6])) - return out - - -def _fill_missing(items: list[dict], sentences: list[str], srt: list[dict], - missing: list[int]) -> list[dict]: - """未覆盖句: 在其相邻已覆盖段之间就近取字幕块补齐, 实在无处安放则放弃并记录。""" - if not items: - return items - bounds = [c for c in srt] - result = list(items) - used = [(it["start"], it["end"]) for it in items] - - def free_slots() -> list[tuple[float, float]]: - slots, prev = [], 0.0 - for st, en in sorted(used): - if st - prev > 1.0: - slots.append((prev, st)) - prev = en - if bounds and bounds[-1]["end"] - prev > 1.0: - slots.append((prev, bounds[-1]["end"])) - return slots - - for si in missing: - need = _sentence_need(sentences[si - 1]) - placed = False - for bs, be in free_slots(): - if be - bs >= need: - result.append({"sentence_index": si, "start": round(bs, 3), - "end": round(bs + need, 3)}) - used.append((bs, bs + need)) - placed = True - break - if not placed: - log.warning("第 %d 句无处安放画面, 将用上一段画面延续", si) - return sorted(result, key=lambda x: x["start"]) + output = topic_dir / "edit_decision.json" + utils.write_json(output, edit) + log.info("S5 完成: %d 句全部覆盖,%d 轮修复", len(sentences), repairs_used) + return output diff --git a/autoup/stages/script.py b/autoup/stages/script.py index ce46c5e..ed808c9 100644 --- a/autoup/stages/script.py +++ b/autoup/stages/script.py @@ -1,10 +1,4 @@ -"""S3 文案: SRT → 中文解说词(分段生成, 字数验收, 补写循环)。 - -文风规则吸收 AutoYY references/content-style.md 与 vendor/blader-humanizer 精髓: -纯口播、事实锁死字幕、去 AI 味、无生产标签。 -注: 按实测 edge_tts 语速约 0.25s/字, 4500 字 ≈ 19 分钟配音; 想压 15 分钟可调低 -script.target_chars 或提高 voice.gpt_sovits.speed_factor / edge 语速。 -""" +"""S3 文案:完整字幕按时间顺序分块生成,所有段落都受对应字幕事实约束。""" from __future__ import annotations import logging @@ -18,113 +12,230 @@ log = logging.getLogger("autoup.s3") SYSTEM = ( - "你是一位顶级中文纪录片解说文案作者, 为历史/工程/灾难类纪录片写抖音横版长视频口播稿。" - "你只输出解说词正文, 不输出任何标题、标注、括号说明或制作备注。" + "你是一位专业中文纪录片解说文案作者。" + "你只输出可直接配音的正文,不输出标题、序号、括号备注或制作标签。" ) RULES = """写作铁律: -1. 事实完全来自给定字幕内容, 严禁编造字幕中不存在的人名/数字/事件; 可以做合理的语言润色和因果串联, 不能新增事实。 -2. 纯口语播稿: 短句、主谓宾清晰、数字读法友好(1943 → 一九四三年)、术语要随口解释。 -3. 开头 3 句必须强钩子(悬念/反差/巨大数字), 中段层层推进, 结尾收束有余味, 不要"关注我"类互动话术。 -4. 去 AI 味: 不用"首先/其次/总之/值得一提的是/不难发现/随着…的发展"模板词; 不用排比堆砌; 句长长短交错。 -5. 直接输出解说词正文, 禁止任何小标题、序号、旁注、"(画面: xxx)"类提示。""" - - -def _srt_text(path: Path, max_chars: int = 24000) -> str: - cues = utils.parse_srt(path) - text = "\n".join(f"[{utils.fmt_ts(c['start'])}] {c['text']}" for c in cues) - if len(text) > max_chars: - text = text[:max_chars] + "\n…(字幕过长已截断)" - return text +1. 事实只能来自当前提供的字幕窗口,严禁新增字幕中不存在的人名、数字、事件和因果。 +2. 纯口播:短句优先,主谓宾清楚,术语随口解释,数字适合中文配音。 +3. 开头要迅速进入冲突/悬念;中段按时间与因果推进;最后一段自然收束。 +4. 禁用模板化 AI 套话:首先、其次、总之、值得一提的是、不难发现、随着……的发展。 +5. 不写镜头说明、生产提示、互动求赞、关注或转发话术。""" def _count(text: str) -> int: return len(re.sub(r"\s", "", text)) -def _section_prompt(srt_text: str, topic: str, target: int, prev_tail: str, - part: int, total_parts: int) -> str: - head = (f"纪录片选题: {topic}\n\n原片字幕(英文, 带时间轴):\n\n{srt_text}\n\n\n" - if part == 1 else "") - prev = f"上一段结尾(必须无缝衔接, 不要重复其内容):\n…{prev_tail}\n\n" if prev_tail else "" - task = (f"这是全文的第 {part}/{total_parts} 段。" - f"{'写出开篇钩子并切入主题。' if part == 1 else '承接上文继续推进。'}" - f"{'写出收束全文的结尾。' if part == total_parts else '在段落中间的自然处停笔。'}") - return f"""{head}{prev}{task} +def _chunk_cues(cues: list[dict], parts: int) -> list[list[dict]]: + if not cues: + return [] + count = min(max(parts, 1), len(cues)) + chunks: list[list[dict]] = [] + total = len(cues) + for index in range(count): + start = round(index * total / count) + end = round((index + 1) * total / count) + chunk = cues[start:end] + if chunk: + chunks.append(chunk) + return chunks + + +def _format_cues(cues: list[dict]) -> str: + return "\n".join( + f"[{utils.fmt_ts(cue['start'])}-{utils.fmt_ts(cue['end'])}] {cue['text']}" + for cue in cues + ) + + +def _section_prompt( + source_text: str, + topic: str, + target: int, + prev_tail: str, + part: int, + total_parts: int, + *, + supplement: bool = False, +) -> str: + previous = ( + f"\n上一段结尾,仅用于衔接,禁止重复:\n…{prev_tail}\n" + if prev_tail + else "" + ) + if supplement: + task = ( + "这是补充段。只从当前字幕窗口挑选前文尚未明确写过的事实细节," + "自然接到全文末尾;不要重复已有内容,不得新增事实。" + ) + else: + task = ( + f"这是全文第 {part}/{total_parts} 段。" + f"{'写强钩子并进入主题。' if part == 1 else '承接上文继续推进。'}" + f"{'自然收束全文。' if part == total_parts else '在自然叙事节点停笔。'}" + ) + return f"""纪录片选题: {topic} + +当前可用的原片字幕事实窗口: + +{source_text} + +{previous} +任务: +{task} {RULES} -本段目标约 {target} 个汉字(允许 ±15%)。只输出本段解说词正文。""" +本段目标约 {target} 个汉字,允许约 ±15%。只输出正文。""" + + +def _clean_generated(text: str) -> str: + text = text.strip() + text = re.sub(r"^(好的|以下是|正文如下|【[^】]+】)\s*[::]?\s*", "", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +def _trim_to_limit(text: str, limit: int) -> str: + best_end: int | None = None + for match in re.finditer(r"[。!?]", text): + if _count(text[: match.end()]) <= limit: + best_end = match.end() + else: + break + if best_end: + return text[:best_end].strip() + + visible = 0 + output: list[str] = [] + for char in text: + if not char.isspace(): + visible += 1 + if visible > limit: + break + output.append(char) + return "".join(output).rstrip(",、;: ") + "。" def run(topic_dir: Path) -> Path: - srt_path = next((topic_dir / "字幕").glob("*.srt"), None) \ - if (topic_dir / "字幕").exists() else None + subtitle_dir = topic_dir / "字幕" + srt_path = next(subtitle_dir.glob("*.srt"), None) if subtitle_dir.exists() else None if srt_path is None: - raise FileNotFoundError(f"缺少字幕: {topic_dir}/字幕/*.srt") + raise FileNotFoundError(f"缺少字幕: {subtitle_dir}/*.srt") + + cues = utils.parse_srt(srt_path) + if not cues: + raise ValueError(f"字幕无法解析或为空: {srt_path}") target = int(config.get("script.target_chars", 4500)) tolerance = float(config.get("script.tolerance", 0.12)) - seg_size = int(config.get("script.section_chars", 850)) - # 向上取整,避免 round() 在目标字数刚好跨过分段边界时少生成一段。 - total_parts = max(1, math.ceil(target / seg_size)) - srt_text = _srt_text(srt_path) + section_chars = max(int(config.get("script.section_chars", 850)), 200) + total_parts = max(1, math.ceil(target / section_chars)) + chunks = _chunk_cues(cues, total_parts) + per_target = max(200, math.ceil(target / max(len(chunks), 1))) topic = topic_dir.name out_dir = topic_dir / "文案" out_dir.mkdir(parents=True, exist_ok=True) - draft = out_dir / f"爆款口播稿-draft.txt" + draft = out_dir / "爆款口播稿-draft.txt" + map_path = out_dir / "script_map.json" parts: list[str] = [] - for p in range(1, total_parts + 1): - prev_tail = parts[-1][-120:] if parts else "" - text = llm.chat(_section_prompt(srt_text, topic, seg_size, prev_tail, p, total_parts), - system=SYSTEM, temperature=0.75).strip() - text = re.sub(r"^(好的|以下是|【|\[).*?\n+", "", text) # 防寒暄头 - parts.append(text) + mapping: list[dict] = [] + for index, chunk in enumerate(chunks, 1): + previous = parts[-1][-160:] if parts else "" + generated = _clean_generated( + llm.chat( + _section_prompt( + _format_cues(chunk), + topic, + per_target, + previous, + index, + len(chunks), + ), + system=SYSTEM, + temperature=0.75, + ) + ) + if _count(generated) < 80: + raise RuntimeError(f"S3 第 {index} 段生成过短: {_count(generated)} 字") + parts.append(generated) + mapping.append( + { + "part": index, + "source_start": chunk[0]["start"], + "source_end": chunk[-1]["end"], + "text_chars": _count(generated), + } + ) draft.write_text("\n\n".join(parts), encoding="utf-8") - log.info("第 %d/%d 段完成, 累计 %d 字", p, total_parts, _count("".join(parts))) + utils.write_json(map_path, {"parts": mapping}) + log.info("S3 第 %d/%d 段完成,累计 %d 字", index, len(chunks), _count("".join(parts))) full = "\n\n".join(parts) - lo, hi = int(target * (1 - tolerance)), int(target * (1 + tolerance)) - cnt = _count(full) - # 补写循环(最多 3 轮, 每轮最多 2 段) - rounds = 0 - while cnt < lo and rounds < 3: - rounds += 1 - need = min(seg_size, lo - cnt + 100) - prev_tail = full[-120:] - add = llm.chat(_section_prompt(srt_text, topic, need, prev_tail, - len(parts) + 1, len(parts) + 1), - system=SYSTEM, temperature=0.75).strip() - parts.append(add) - full = "\n\n".join(parts) - cnt = _count(full) - log.info("补写 %d 字, 现 %d 字", _count(add), cnt) - if cnt > hi: - # 单遍扫描句读边界, 取字数 ≤ 上限的最长句号前缀; 无则硬切(避免死循环) - best_end = None - for m in re.finditer(r"[。!?]", full): - if _count(full[:m.end()]) <= hi: - best_end = m.end() - else: - break - if best_end: - cut = full[:best_end] - else: - cut = full[:hi - 1].rstrip() + "。" - full = cut - log.warning("文案超上限, 截至约 %d 字(如需更长请调高 script.target_chars)", _count(full)) + lower = int(target * (1 - tolerance)) + upper = int(target * (1 + tolerance)) + supplement_rounds = max(int(config.get("script.supplement_rounds", 2)), 0) + + for round_index in range(supplement_rounds): + if _count(full) >= lower: + break + chunk = chunks[round_index % len(chunks)] + need = min(per_target, lower - _count(full) + 120) + generated = _clean_generated( + llm.chat( + _section_prompt( + _format_cues(chunk), + topic, + need, + full[-180:], + len(parts) + 1, + len(parts) + 1, + supplement=True, + ), + system=SYSTEM, + temperature=0.65, + ) + ) + if _count(generated) >= 60: + parts.append(generated) + mapping.append( + { + "part": len(parts), + "source_start": chunk[0]["start"], + "source_end": chunk[-1]["end"], + "text_chars": _count(generated), + "supplement": True, + } + ) + full = "\n\n".join(parts) + + if _count(full) > upper: + full = _trim_to_limit(full, upper) + + final_count = _count(full) + if not lower <= final_count <= upper: + raise RuntimeError( + f"S3 字数验收失败: {final_count},要求 {lower}-{upper};拒绝用不受事实约束的内容硬补" + ) - # 清洗: 合并多余空行, 去生产标签 - full = re.sub(r"\n{3,}", "\n\n", full).strip() final = out_dir / SCRIPT_NAME - final.write_text(full, encoding="utf-8") - if draft.exists() and final.exists(): + final.write_text(full.strip(), encoding="utf-8") + utils.write_json( + map_path, + { + "source": srt_path.name, + "target_chars": target, + "final_chars": final_count, + "parts": mapping, + }, + ) + if draft.exists(): draft.unlink() - est = utils.estimate_duration(full) - log.info("文案完成: %d 字, 预估配音 %.0fs (%.1f 分钟)", _count(full), est, est / 60) - if not (lo <= _count(full) <= hi): - log.warning("字数 %d 不在 [%d, %d], 请人工复核", _count(full), lo, hi) + estimated = utils.estimate_duration(full) + log.info("S3 完成: %d 字,预估配音 %.1f 分钟", final_count, estimated / 60) return final diff --git a/autoup/tts/__init__.py b/autoup/tts/__init__.py index 27a32a0..cec16c9 100644 --- a/autoup/tts/__init__.py +++ b/autoup/tts/__init__.py @@ -1,4 +1,4 @@ -"""TTS 引擎统一入口: 主引擎失败自动切换备用引擎。""" +"""TTS 统一入口:主引擎失败后自动降级备用引擎。""" from __future__ import annotations import logging @@ -10,27 +10,28 @@ log = logging.getLogger("autoup.tts") -def synth(text: str, out_path: Path) -> tuple[Path | None, str]: - """合成一句语音。返回 (音频文件, 实际使用的引擎); 全失败返回 (None, 'all_engines_failed')。""" +def synth(text: str, out_path: Path, voice: str = "default") -> tuple[Path | None, str]: engines = [str(config.get("voice.engine", "gpt_sovits"))] - fb = config.get("voice.fallback_engine", "edge_tts") - if fb and fb not in engines: - engines.append(str(fb)) + fallback = config.get("voice.fallback_engine", "edge_tts") + if fallback and str(fallback) not in engines: + engines.append(str(fallback)) + for name in engines: if name == "gpt_sovits": - # 未配置参考音频时直接跳过, 避免白白拉起服务 voices = config.get("voice.gpt_sovits.voices", {}) or {} - vcfg = voices.get("default") or {} - if not vcfg.get("ref_audio_path"): - log.info("GPT-SoVITS 未配置参考音频, 跳过 (用 %s)", fb) + voice_cfg = voices.get(voice) or {} + if not voice_cfg.get("ref_audio_path"): + log.info("GPT-SoVITS 音色 %r 未配置参考音频,跳过", voice) continue if not gpt_sovits.ensure_server(): - log.warning("GPT-SoVITS 不可用, 尝试下一引擎") + log.warning("GPT-SoVITS 不可用,尝试下一引擎") continue - if gpt_sovits.synth(text, out_path): + if gpt_sovits.synth(text, out_path, voice=voice): return out_path, "gpt_sovits" - log.warning("GPT-SoVITS 合成失败, 尝试下一引擎") + log.warning("GPT-SoVITS 合成失败,尝试下一引擎") elif name == "edge_tts": if edge.synth(text, out_path): return out_path, "edge_tts" + else: + log.warning("未知 TTS 引擎: %s", name) return None, "all_engines_failed" From e3cb8744004e844b148b6f21a562edfe5561d1dd Mon Sep 17 00:00:00 2001 From: DaBao Date: Sat, 3 Oct 2026 23:03:26 +0800 Subject: [PATCH 03/12] fix: harden download, render, publication and final validation --- autoup/config.yaml | 3 + autoup/stages/cover.py | 265 +++++++++++++------- autoup/stages/download.py | 355 ++++++++++++++++++-------- autoup/stages/publish.py | 159 +++++++----- autoup/stages/render.py | 514 +++++++++++++++++++++++--------------- autoup/stages/validate.py | 246 +++++++++++++----- autoup/state.py | 43 +++- 7 files changed, 1059 insertions(+), 526 deletions(-) diff --git a/autoup/config.yaml b/autoup/config.yaml index ff72343..7bcb47a 100644 --- a/autoup/config.yaml +++ b/autoup/config.yaml @@ -71,6 +71,9 @@ match: max_context_chars: 14000 repair_attempts: 2 +publish: + repair_attempts: 2 + download: min_duration: 1800 min_height: 720 diff --git a/autoup/stages/cover.py b/autoup/stages/cover.py index b677e40..e97099d 100644 --- a/autoup/stages/cover.py +++ b/autoup/stages/cover.py @@ -1,15 +1,12 @@ -"""S8 封面: 源片帧 + 金黄主标题/白副标题(黑描边+投影) → 9:16 / 16:9 / 1:1 三比例。 - -版式标准继承 AutoYY: 特大金黄主标题+黑描边黑投影, 白色副标题约为主标题 2/3 宽, -上半区居中。默认黑体加粗; 书法字体可通过 cover.font_path 指定。 -""" +"""S8 封面:源片帧 + 6/8字标题,输出 9:16 / 16:9 / 1:1。""" from __future__ import annotations import logging from pathlib import Path +from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont + from .. import config, utils -from PIL import Image, ImageDraw, ImageFont, ImageEnhance, ImageFilter log = logging.getLogger("autoup.s8") @@ -17,105 +14,189 @@ GOLD = (255, 201, 60) -def _load_font(size: int): - custom = config.get("cover.font_path") - path = Path(str(custom)) if custom else Path("C:/Windows/Fonts/simhei.ttf") - if not path.exists(): - path = Path("C:/Windows/Fonts/msyhbd.ttc") - return ImageFont.truetype(str(path), size) - - -def _draw_text_layer(w: int, main: str, sub: str) -> Image.Image: - """透明文字层: 主标题(金黄+黑描边+投影) + 副标题(白+黑描边), 居中。""" - layer = Image.new("RGBA", (w, w), (0, 0, 0, 0)) - d = ImageDraw.Draw(layer) - # 主标题字号: 6 字占宽度 ~86% - size = int(w * 0.86 / max(len(main), 1)) - font = _load_font(size) - bbox = d.textbbox((0, 0), main, font=font, stroke_width=max(size // 14, 4)) - tw = bbox[2] - bbox[0] - if tw > w * 0.92: # 防溢出缩字号 - size = int(size * w * 0.92 / tw) +def _resolve_font() -> Path: + raw = config.get("cover.font_path") + if raw: + path = Path(str(raw)).expanduser() + if not path.is_absolute(): + path = config.repo_root() / path + if path.exists(): + return path + bundled = config.repo_root() / "assets" / "fonts" / "05_江西拙楷.ttf" + if bundled.exists(): + return bundled + raise FileNotFoundError("找不到封面字体,请配置 cover.font_path") + + +def _load_font(size: int) -> ImageFont.FreeTypeFont: + return ImageFont.truetype(str(_resolve_font()), max(size, 10)) + + +def _measure(draw: ImageDraw.ImageDraw, text: str, font, stroke_width: int) -> tuple[int, int, tuple]: + bbox = draw.textbbox((0, 0), text, font=font, stroke_width=stroke_width) + return bbox[2] - bbox[0], bbox[3] - bbox[1], bbox + + +def _fit_font(draw: ImageDraw.ImageDraw, text: str, initial_size: int, max_width: int): + size = max(initial_size, 10) + while size > 20: font = _load_font(size) - x = (w - tw) // 2 + stroke = max(size // 14, 4) + width, _, _ = _measure(draw, text, font, stroke) + if width <= max_width: + return size, font + size = max(int(size * 0.94), size - 2) + return size, _load_font(size) + + +def _draw_text_layer(width: int, main: str, sub: str) -> Image.Image: + layer = Image.new("RGBA", (width, width), (0, 0, 0, 0)) + draw = ImageDraw.Draw(layer) + + initial = int(width * 0.86 / max(len(main), 1)) + size, font = _fit_font(draw, main, initial, int(width * 0.92)) + stroke = max(size // 13, 5) + main_width, _, _ = _measure(draw, main, font, stroke) + x = (width - main_width) // 2 y = 0 - sw = max(size // 13, 5) - # 投影 - d.text((x + size // 22, y + size // 16), main, font=font, - fill=(0, 0, 0, 200), stroke_width=sw, stroke_fill=(0, 0, 0, 200)) - # 金黄主体 + 黑描边 - d.text((x, y), main, font=font, fill=GOLD + (255,), stroke_width=sw, - stroke_fill=(0, 0, 0, 255)) - # 副标题: 约 2/3 宽 - ssize = int(size * 0.62 * len(main) / max(len(sub), 1) * 0.98) - sfont = _load_font(ssize) - sbbox = d.textbbox((0, 0), sub, font=sfont, stroke_width=max(ssize // 14, 3)) - stw = sbbox[2] - sbbox[0] - sx = (w - stw) // 2 - sy = y + int(size * 1.28) - ssw = max(ssize // 13, 3) - d.text((sx + ssize // 22, sy + ssize // 16), sub, font=sfont, - fill=(0, 0, 0, 190), stroke_width=ssw, stroke_fill=(0, 0, 0, 190)) - d.text((sx, sy), sub, font=sfont, fill=(255, 255, 255, 255), - stroke_width=ssw, stroke_fill=(0, 0, 0, 255)) - bbox_full = layer.getbbox() - return layer.crop(bbox_full) if bbox_full else layer - - -def _crop_ratio(img: Image.Image, w: int, h: int) -> Image.Image: - ratio = w / h - iw, ih = img.size - if iw / ih > ratio: - nw = int(ih * ratio) - img = img.crop(((iw - nw) // 2, 0, (iw + nw) // 2, ih)) + + draw.text( + (x + size // 22, y + size // 16), + main, + font=font, + fill=(0, 0, 0, 200), + stroke_width=stroke, + stroke_fill=(0, 0, 0, 200), + ) + draw.text( + (x, y), + main, + font=font, + fill=GOLD + (255,), + stroke_width=stroke, + stroke_fill=(0, 0, 0, 255), + ) + + sub_initial = int(size * 0.62 * len(main) / max(len(sub), 1) * 0.98) + sub_size, sub_font = _fit_font(draw, sub, sub_initial, int(width * 0.72)) + sub_stroke = max(sub_size // 13, 3) + sub_width, _, _ = _measure(draw, sub, sub_font, sub_stroke) + sub_x = (width - sub_width) // 2 + sub_y = y + int(size * 1.28) + draw.text( + (sub_x + sub_size // 22, sub_y + sub_size // 16), + sub, + font=sub_font, + fill=(0, 0, 0, 190), + stroke_width=sub_stroke, + stroke_fill=(0, 0, 0, 190), + ) + draw.text( + (sub_x, sub_y), + sub, + font=sub_font, + fill=(255, 255, 255, 255), + stroke_width=sub_stroke, + stroke_fill=(0, 0, 0, 255), + ) + bounds = layer.getbbox() + return layer.crop(bounds) if bounds else layer + + +def _crop_ratio(image: Image.Image, width: int, height: int) -> Image.Image: + ratio = width / height + image_width, image_height = image.size + if image_width / image_height > ratio: + new_width = int(image_height * ratio) + image = image.crop( + ((image_width - new_width) // 2, 0, (image_width + new_width) // 2, image_height) + ) else: - nh = int(iw / ratio) - img = img.crop((0, (ih - nh) // 2, iw, (ih + nh) // 2)) - return img.resize((w, h), Image.LANCZOS) + new_height = int(image_width / ratio) + image = image.crop( + (0, (image_height - new_height) // 2, image_width, (image_height + new_height) // 2) + ) + return image.resize((width, height), Image.Resampling.LANCZOS) def _pick_frame(topic_dir: Path) -> Path: - """取 ED 最长段中点的源片帧作为封面底图。""" - ed = utils.read_json(topic_dir / "edit_decision.json") or {} - items = sorted(ed.get("items", []), key=lambda i: i["end"] - i["start"], reverse=True) + edit = utils.read_json(topic_dir / "edit_decision.json") or {} + items = sorted( + edit.get("items", []), + key=lambda item: item["end"] - item["start"], + reverse=True, + ) if not items: - raise FileNotFoundError("缺少 edit_decision.json 或其中无 items") - t = (items[0]["start"] + items[0]["end"]) / 2 - src = next((f for f in (topic_dir / "素材").iterdir() - if f.suffix.lower() in {".mp4", ".mkv", ".webm"}), None) - if src is None: + raise FileNotFoundError("edit_decision.json 无有效画面段") + timestamp = (items[0]["start"] + items[0]["end"]) / 2 + material = topic_dir / "素材" + source = next( + ( + file + for file in material.iterdir() + if file.suffix.lower() in {".mp4", ".mkv", ".webm"} + ), + None, + ) if material.exists() else None + if source is None: raise FileNotFoundError("缺少源视频") - out = topic_dir / "封面" / "_frame.png" - out.parent.mkdir(parents=True, exist_ok=True) - p = utils.run([config.ffmpeg(), "-y", "-loglevel", "error", "-ss", t, - "-i", src, "-frames:v", "1", out], desc="抽封面帧") - if p.returncode != 0 or not out.exists(): - raise RuntimeError("抽帧失败") - return out + + output = topic_dir / "封面" / "_frame.png" + output.parent.mkdir(parents=True, exist_ok=True) + proc = utils.run( + [ + config.ffmpeg(), + "-y", + "-loglevel", + "error", + "-ss", + timestamp, + "-i", + source, + "-frames:v", + "1", + output, + ], + desc="抽封面帧", + ) + if proc.returncode != 0 or not output.exists(): + raise RuntimeError("抽封面帧失败") + return output def run(topic_dir: Path) -> Path: - pub = topic_dir / "发布" / "封面文案.txt" - if not pub.exists(): - raise FileNotFoundError("缺少 封面文案.txt (先跑 S7)") - lines = [l.strip() for l in pub.read_text(encoding="utf-8-sig").splitlines() if l.strip()] - main, sub = (lines + ["", ""])[:2] + copy_path = topic_dir / "发布" / "封面文案.txt" + if not copy_path.exists(): + raise FileNotFoundError("缺少 封面文案.txt(先跑 S7)") + lines = [ + line.strip() + for line in copy_path.read_text(encoding="utf-8-sig").splitlines() + if line.strip() + ] + if len(lines) < 2: + raise ValueError("封面文案必须包含主标题和副标题两行") + main, sub = lines[:2] + if len(main) != 6 or len(sub) != 8: + raise ValueError(f"封面标题长度必须 6/8 字,当前 {len(main)}/{len(sub)}") frame = _pick_frame(topic_dir) - base = Image.open(frame).convert("RGB") + with Image.open(frame) as source: + base = source.convert("RGB") base = ImageEnhance.Brightness(base).enhance(0.82) base = ImageEnhance.Contrast(base).enhance(1.06) - out_dir = topic_dir / "封面" - out_dir.mkdir(parents=True, exist_ok=True) - for name, (w, h) in SIZES.items(): - bg = _crop_ratio(base, w, h).filter(ImageFilter.GaussianBlur(1.2)) - text_layer = _draw_text_layer(w, main, sub) - # 文字层放上半区 - ty = int(h * (0.14 if h > w else 0.16)) - bg = bg.convert("RGBA") - bg.alpha_composite(text_layer, ((w - text_layer.width) // 2, ty)) - out = out_dir / f"封面-{name}.png" - bg.convert("RGB").save(out, quality=92) - log.info("封面 %s: %s", name, out) - return out_dir + output_dir = topic_dir / "封面" + output_dir.mkdir(parents=True, exist_ok=True) + for name, (width, height) in SIZES.items(): + background = _crop_ratio(base, width, height).filter(ImageFilter.GaussianBlur(1.2)) + text_layer = _draw_text_layer(width, main, sub) + top = int(height * (0.14 if height > width else 0.16)) + background = background.convert("RGBA") + background.alpha_composite( + text_layer, + ((width - text_layer.width) // 2, top), + ) + output = output_dir / f"封面-{name}.png" + background.convert("RGB").save(output, format="PNG", optimize=True) + log.info("封面 %s: %s", name, output) + return output_dir diff --git a/autoup/stages/download.py b/autoup/stages/download.py index ed2a77e..4bb27b8 100644 --- a/autoup/stages/download.py +++ b/autoup/stages/download.py @@ -1,16 +1,11 @@ -"""S1/S2 源核验与下载 (yt-dlp)。 - -规则(docs/requirements.md #13/#14): - - 半自动选题: 入口为人工提供的 URL 清单(manifest) - - 只接受 YouTube 自带字幕(手动 en 优先, 自动 en 兜底), 无 en 字幕直接拒 - - 时长 ≥ min_duration 且最高分辨率 ≥ min_height 才接单 -""" +"""S1/S2 源核验与下载:精确记录字幕语言,下载失败可恢复,容器转换不伪装扩展名。""" from __future__ import annotations import csv import logging import re import shutil +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from .. import config, utils @@ -18,40 +13,45 @@ log = logging.getLogger("autoup.s12") YT_SUB_LANGS = ["en", "en-US", "en-GB", "en-orig"] +VIDEO_EXTS = {".mp4", ".mkv", ".webm"} def _fix_curl_ca() -> None: - """yt-dlp curl 后端在非 ASCII 路径下加载 certifi 失败(WinError curl:77), - 把 CA 串复制到纯 ASCII 路径并指给 curl/SSL。""" import os - import shutil import tempfile + try: import certifi - src = certifi.where() + + source = certifi.where() except ImportError: return try: - str(src).encode("ascii") - return # 路径已纯 ASCII, 无需处理 + str(source).encode("ascii") + return except UnicodeEncodeError: pass - dst = Path(tempfile.gettempdir()) / "autoup_cacert.pem" + + destination = Path(tempfile.gettempdir()) / "autoup_cacert.pem" try: - if not dst.exists() or dst.stat().st_size != Path(src).stat().st_size: - shutil.copy(str(src), str(dst)) - os.environ.setdefault("CURL_CA_BUNDLE", str(dst)) - os.environ["SSL_CERT_FILE"] = str(dst) - except OSError as e: - log.warning("CA 证书复制失败: %s", e) + if not destination.exists() or destination.stat().st_size != Path(source).stat().st_size: + shutil.copy(str(source), str(destination)) + os.environ.setdefault("CURL_CA_BUNDLE", str(destination)) + os.environ["SSL_CERT_FILE"] = str(destination) + except OSError as exc: + log.warning("CA 证书复制失败: %s", exc) def _ensure_ffmpeg_on_path() -> None: - """yt-dlp 合并格式需要能找到 ffmpeg。""" import os - ff_dir = str(Path(config.ffmpeg()).parent) - if ff_dir not in os.environ.get("PATH", ""): - os.environ["PATH"] = ff_dir + os.pathsep + os.environ.get("PATH", "") + + ff_path = Path(config.ffmpeg()) + if ff_path.parent == Path("."): + return + ff_dir = str(ff_path.parent) + current = os.environ.get("PATH", "") + if ff_dir not in current.split(os.pathsep): + os.environ["PATH"] = ff_dir + os.pathsep + current def _base_opts() -> dict: @@ -70,23 +70,37 @@ def _base_opts() -> dict: } +def _choose_english_language(manual: set[str], automatic: set[str]) -> tuple[str, str]: + def choose(pool: set[str]) -> str: + for preferred in YT_SUB_LANGS: + if preferred in pool: + return preferred + candidates = sorted(lang for lang in pool if lang.lower().startswith("en")) + return candidates[0] if candidates else "" + + language = choose(manual) + if language: + return language, "manual" + language = choose(automatic) + if language: + return language, "auto" + return "", "none" + + def fetch_meta(url: str) -> dict: - """元数据探测(不下载)。返回 {ok, url, title, duration, height, has_en_sub, sub_kind, error}""" import yt_dlp - opts = _base_opts() + try: - with yt_dlp.YoutubeDL(opts) as ydl: + with yt_dlp.YoutubeDL(_base_opts()) as ydl: info = ydl.extract_info(url, download=False) - except Exception as e: # noqa: BLE001 - return {"ok": False, "url": url, "error": str(e)[:300]} - manual = set((info.get("subtitles") or {}).keys()) - auto = set((info.get("automatic_captions") or {}).keys()) - - def has_en(pool: set[str]) -> bool: - return any(l.split("-")[0] == "en" for l in pool) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "url": url, "error": str(exc)[:300]} + manual = set((info.get("subtitles") or {}).keys()) + automatic = set((info.get("automatic_captions") or {}).keys()) + sub_lang, sub_kind = _choose_english_language(manual, automatic) formats = info.get("formats") or [] - height = max((f.get("height") or 0) for f in formats) if formats else 0 + height = max((fmt.get("height") or 0) for fmt in formats) if formats else 0 return { "ok": True, "url": url, @@ -97,113 +111,242 @@ def has_en(pool: set[str]) -> bool: "height": int(height), "view_count": int(info.get("view_count") or 0), "upload_date": info.get("upload_date") or "", - "has_en_sub": has_en(manual), - "sub_kind": "manual" if has_en(manual) else ("auto" if has_en(auto) else "none"), + "has_en_sub": bool(sub_lang), + "sub_kind": sub_kind, + "sub_lang": sub_lang, } def verify_url(url: str) -> dict: - """S1 单链接核验: 时长/分辨率/字幕门禁。""" meta = fetch_meta(url) if not meta.get("ok"): return {**meta, "verdict": "reject", "reason": "无法获取元数据"} - min_dur = int(config.get("download.min_duration", 1800)) - min_h = int(config.get("download.min_height", 720)) - if meta["duration"] < min_dur: - return {**meta, "verdict": "reject", "reason": f"时长 {meta['duration']:.0f}s < {min_dur}s"} - if meta["height"] < min_h: - return {**meta, "verdict": "reject", "reason": f"最高 {meta['height']}p < {min_h}p"} + min_duration = int(config.get("download.min_duration", 1800)) + min_height = int(config.get("download.min_height", 720)) + if meta["duration"] < min_duration: + return { + **meta, + "verdict": "reject", + "reason": f"时长 {meta['duration']:.0f}s < {min_duration}s", + } + if meta["height"] < min_height: + return { + **meta, + "verdict": "reject", + "reason": f"最高 {meta['height']}p < {min_height}p", + } if meta["sub_kind"] == "none": - return {**meta, "verdict": "reject", "reason": "无英文字幕(规则: 只接受 YouTube 自带字幕)"} + return { + **meta, + "verdict": "reject", + "reason": "无英文字幕(只接受 YouTube 自带字幕)", + } return {**meta, "verdict": "approve", "reason": "通过"} def verify_batch(urls: list[str], report_csv: Path) -> list[dict]: - """S1 批量核验, 写 CSV 报告, 返回通过行。""" - rows = [verify_url(u) for u in urls] + workers = max(int(config.get("download.verify_workers", 4)), 1) + if workers == 1 or len(urls) <= 1: + rows = [verify_url(url) for url in urls] + else: + with ThreadPoolExecutor(max_workers=min(workers, len(urls))) as executor: + rows = list(executor.map(verify_url, urls)) + report_csv.parent.mkdir(parents=True, exist_ok=True) - cols = ["verdict", "reason", "url", "vid", "title", "channel", "duration", - "height", "view_count", "upload_date", "sub_kind", "has_en_sub", "error"] - with open(report_csv, "w", newline="", encoding="utf-8-sig") as f: - w = csv.DictWriter(f, fieldnames=cols, extrasaction="ignore") - w.writeheader() - w.writerows(rows) - ok = [r for r in rows if r["verdict"] == "approve"] - log.info("核验完成: %d 通过 / %d 拒绝, 报告 %s", len(ok), len(rows) - len(ok), report_csv) - return ok + columns = [ + "verdict", + "reason", + "url", + "vid", + "title", + "channel", + "duration", + "height", + "view_count", + "upload_date", + "sub_kind", + "sub_lang", + "has_en_sub", + "error", + ] + with report_csv.open("w", newline="", encoding="utf-8-sig") as handle: + writer = csv.DictWriter(handle, fieldnames=columns, extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + approved = [row for row in rows if row.get("verdict") == "approve"] + log.info( + "核验完成: %d 通过 / %d 拒绝,报告 %s", + len(approved), + len(rows) - len(approved), + report_csv, + ) + return approved -def _safe_name(name: str) -> str: - return re.sub(r'[\\/:*?"<>|]', "_", name).strip()[:80] or "untitled" +def _remux_to_mp4(video: Path) -> Path: + if video.suffix.lower() == ".mp4": + return video -def download_topic(url: str, topic_dir: Path) -> dict: - """S2 下载: 1080p 视频入 素材/, en 字幕转 srt 入 字幕/。返回 {video, subtitle}。""" + target = video.with_suffix(".mp4") + if target.exists(): + target.unlink() + copy_cmd = [ + config.ffmpeg(), + "-y", + "-hide_banner", + "-loglevel", + "error", + "-i", + video, + "-map", + "0", + "-c", + "copy", + "-movflags", + "+faststart", + target, + ] + proc = utils.run(copy_cmd, desc="封装为 mp4", timeout=1800) + if proc.returncode != 0: + if target.exists(): + target.unlink() + transcode_cmd = [ + config.ffmpeg(), + "-y", + "-hide_banner", + "-loglevel", + "error", + "-i", + video, + "-map", + "0:v:0", + "-map", + "0:a?", + "-c:v", + "libx264", + "-preset", + "fast", + "-crf", + "18", + "-c:a", + "aac", + "-b:a", + "192k", + "-movflags", + "+faststart", + target, + ] + proc = utils.run(transcode_cmd, desc="转码为 mp4", timeout=7200) + if proc.returncode != 0 or not target.exists(): + raise RuntimeError(f"无法生成有效 mp4: {video}") + video.unlink() + return target + + +def download_topic(url: str, topic_dir: Path, sub_lang: str | None = None) -> dict: import yt_dlp - mat = topic_dir / "素材" - sub_dir = topic_dir / "字幕" - mat.mkdir(parents=True, exist_ok=True) - sub_dir.mkdir(parents=True, exist_ok=True) - mh = int(config.get("download.max_height", 1080)) - opts = _base_opts() | { - "outtmpl": str(mat / "高清源视频.%(ext)s"), - "format": (f"b[height<={mh}][vcodec!=none][acodec!=none]/" - f"bv*[height<={mh}][ext=mp4]+ba[ext=m4a]/" - f"bv*[height<={mh}]+ba/" - f"b[height<={mh}]/b"), + + material = topic_dir / "素材" + subtitle_dir = topic_dir / "字幕" + material.mkdir(parents=True, exist_ok=True) + subtitle_dir.mkdir(parents=True, exist_ok=True) + + max_height = int(config.get("download.max_height", 1080)) + subtitle_languages = [sub_lang] if sub_lang else YT_SUB_LANGS + options = _base_opts() | { + "outtmpl": str(material / "高清源视频.%(ext)s"), + "format": ( + f"b[height<={max_height}][vcodec!=none][acodec!=none]/" + f"bv*[height<={max_height}][ext=mp4]+ba[ext=m4a]/" + f"bv*[height<={max_height}]+ba/" + f"b[height<={max_height}]/b" + ), "merge_output_format": "mp4", "writesubtitles": True, "writeautomaticsub": True, - "subtitleslangs": YT_SUB_LANGS, + "subtitleslangs": subtitle_languages, "convert_subtitles": "srt", } - with yt_dlp.YoutubeDL(opts) as ydl: + with yt_dlp.YoutubeDL(options) as ydl: ydl.download([url]) - video = next((f for f in mat.iterdir() - if f.stem.startswith("高清源视频") and f.suffix.lower() in - {".mp4", ".mkv", ".webm"}), None) + video = next( + ( + file + for file in material.iterdir() + if file.stem.startswith("高清源视频") and file.suffix.lower() in VIDEO_EXTS + ), + None, + ) if video is None: raise RuntimeError(f"下载后未找到视频: {topic_dir}") - if video.suffix.lower() != ".mp4": - fix = video.with_suffix(".mp4") - shutil.move(str(video), str(fix)) - video = fix - - srt_src = next((f for f in mat.iterdir() if f.suffix.lower() == ".srt"), None) - if srt_src is None: - # yt-dlp 中途失败会留下 vtt, 用 ffmpeg 兜底转换 - vtt = next((f for f in mat.iterdir() if f.suffix.lower() == ".vtt"), None) - if vtt is not None: - srt_src = vtt.with_suffix(".srt") - p = utils.run([config.ffmpeg(), "-y", "-loglevel", "error", "-i", vtt, srt_src], - desc="vtt→srt") - if p.returncode != 0: - srt_src = None - if srt_src is None: - raise RuntimeError("下载后未找到字幕(核验时明明有——可能被平台移除)") - # 多语言回退时保证拿到的是 en: 文件名形如 高清源视频.en.srt - cands = [f for f in mat.iterdir() if f.suffix.lower() == ".srt"] - srt_src = next((f for f in cands if f.stem.endswith(".en")), cands[0]) - srt_dst = sub_dir / "字幕.srt" - shutil.move(str(srt_src), str(srt_dst)) - log.info("下载完成: %s (%.1f MB) + %s", - video.name, video.stat().st_size / 1e6, srt_dst.name) - return {"video": video, "subtitle": srt_dst} + video = _remux_to_mp4(video) + + candidates = sorted(material.glob("*.srt")) + if not candidates: + vtt = next(iter(sorted(material.glob("*.vtt"))), None) + if vtt: + converted = vtt.with_suffix(".srt") + proc = utils.run( + [config.ffmpeg(), "-y", "-loglevel", "error", "-i", vtt, converted], + desc="vtt→srt", + ) + if proc.returncode == 0 and converted.exists(): + candidates = [converted] + + if not candidates: + raise RuntimeError("下载后未找到字幕;字幕可能已被平台移除") + + selected = None + if sub_lang: + selected = next( + ( + file + for file in candidates + if file.stem.lower().endswith("." + sub_lang.lower()) + ), + None, + ) + if selected is None: + selected = next( + ( + file + for file in candidates + if re.search(r"\.en(?:[-_.]|$)", file.stem, re.I) + ), + candidates[0], + ) + + subtitle = subtitle_dir / "字幕.srt" + if subtitle.exists(): + subtitle.unlink() + shutil.move(str(selected), str(subtitle)) + for extra in candidates: + if extra.exists() and extra != selected: + extra.unlink() + + log.info( + "下载完成: %s (%.1f MB) + %s [%s]", + video.name, + video.stat().st_size / 1e6, + subtitle.name, + sub_lang or "auto", + ) + return {"video": video, "subtitle": subtitle} def read_manifest(path: Path) -> list[str]: - """manifest: 每行一个 URL (# 注释), 或 CSV 含 url 列。""" urls: list[str] = [] text = Path(path).read_text(encoding="utf-8-sig") if "," in text and not text.strip().startswith("http"): for row in csv.DictReader(text.splitlines()): - u = (row.get("url") or row.get("URL") or "").strip() - if u: - urls.append(u) + url = (row.get("url") or row.get("URL") or "").strip() + if url: + urls.append(url) else: for line in text.splitlines(): line = line.strip() if line and not line.startswith("#") and line.startswith("http"): urls.append(line) - return urls + return list(dict.fromkeys(urls)) diff --git a/autoup/stages/publish.py b/autoup/stages/publish.py index 205f0f9..39cbcdd 100644 --- a/autoup/stages/publish.py +++ b/autoup/stages/publish.py @@ -1,4 +1,4 @@ -"""S7 发布信息: 文案 → 国内平台(≤25字标题+5标签) / 海外平台(英文标题+描述) / 封面文案(6字主+8字副)。""" +"""S7 发布信息:生成后严格校验,不合格则修复重试,最终 fail-closed。""" from __future__ import annotations import json @@ -6,52 +6,70 @@ import re from pathlib import Path -from .. import llm, utils +from .. import config, llm, utils log = logging.getLogger("autoup.s7") -SYSTEM = ("你是短视频发布运营专家, 熟悉抖音/YouTube 标题生态, 严格按要求格式输出 JSON。") +SYSTEM = "你是短视频发布运营编辑,只能基于给定解说词事实,严格按 JSON 格式输出。" def _prompt(topic: str, script_text: str) -> str: - body = script_text[:1800] + body = script_text[:2500] return f"""纪录片选题: {topic} -解说词(节选): +解说词节选: -基于解说词中真实出现的事实, 生成发布素材, 输出严格 JSON: +输出严格 JSON: {{ - "cn_title": "国内平台标题, 最多25个字符(每个汉字/字母/数字/标点都算1字符), 信息差钩子, 基于真实数字/反差/后果", - "cn_tags": ["标签1", "标签2", "标签3", "标签4", "标签5"], - "en_title": "YouTube 风格英文标题, ≤90 字符, curiosity-driven", - "en_description": "英文描述, 2-4 句, 概括视频内容, 末尾附 3 个英文 hashtag", - "cover_main": "封面主标题, 恰好6个汉字", - "cover_sub": "封面副标题, 恰好8个汉字" + "cn_title": "国内平台标题,最多25字符,不含#", + "cn_tags": ["标签1", "标签2", "标签3", "标签4", "标签5"], + "en_title": "英文标题,最多90字符", + "en_description": "英文描述2-4句,末尾3个英文hashtag", + "cover_main": "恰好6个汉字", + "cover_sub": "恰好8个汉字" }} -要求: -1. cn_title 严格 ≤25 字符且不含 # 号; cn_tags 恰好 5 个, 每个不含空格, 贴合选题垂类。 -2. en_title/en_description 只用英文字符。 -3. cover_main/cover_sub 必须恰好 6/8 个汉字, 来自选题最有冲击力的点, 不与 cn_title 完全相同。 -4. 所有内容必须能在解说词中找到事实依据, 禁止夸大编造。 -""" +硬规则: +1. 所有事实必须来自解说词,禁止夸大或新增事实。 +2. cn_title 必须非空且 <=25字符;cn_tags 恰好5个,每个标签不含空格和#。 +3. en_title 必须非空且 <=90字符;英文标题和描述不得出现中文字符。 +4. cover_main 必须恰好6个汉字,cover_sub 必须恰好8个汉字。 +5. 只输出 JSON。""" def _validate(data: dict) -> list[str]: - problems = [] - t = data.get("cn_title", "") - if len(t) > 25: - problems.append(f"国内标题 {len(t)} 字符超 25") + problems: list[str] = [] + cn_title = str(data.get("cn_title", "")).strip() tags = data.get("cn_tags", []) - if len(tags) != 5: - problems.append(f"标签数 {len(tags)} != 5") - if not re.fullmatch(r"[\u4e00-\u9fff]{6}", data.get("cover_main", "")): - problems.append("封面主标题不是 6 个汉字") - if not re.fullmatch(r"[\u4e00-\u9fff]{8}", data.get("cover_sub", "")): - problems.append("封面副标题不是 8 个汉字") + en_title = str(data.get("en_title", "")).strip() + en_description = str(data.get("en_description", "")).strip() + cover_main = str(data.get("cover_main", "")).strip() + cover_sub = str(data.get("cover_sub", "")).strip() + + if not cn_title or len(cn_title) > 25 or "#" in cn_title: + problems.append("国内标题必须非空、≤25字符且不含#") + if not isinstance(tags, list) or len(tags) != 5: + problems.append("国内标签必须恰好5个") + else: + normalized = [str(tag).strip() for tag in tags] + if any(not tag or "#" in tag or re.search(r"\s", tag) for tag in normalized): + problems.append("每个国内标签必须非空且不含空格/#") + if len(set(normalized)) != 5: + problems.append("国内标签不能重复") + + if not en_title or len(en_title) > 90: + problems.append("英文标题必须非空且≤90字符") + if re.search(r"[\u4e00-\u9fff]", en_title + en_description): + problems.append("英文标题/描述不得包含中文") + if not en_description: + problems.append("英文描述不能为空") + if not re.fullmatch(r"[\u4e00-\u9fff]{6}", cover_main): + problems.append("封面主标题必须恰好6个汉字") + if not re.fullmatch(r"[\u4e00-\u9fff]{8}", cover_sub): + problems.append("封面副标题必须恰好8个汉字") return problems @@ -59,35 +77,62 @@ def run(topic_dir: Path) -> Path: script_path = topic_dir / "文案" / "爆款口播稿.txt" if not script_path.exists(): raise FileNotFoundError(f"缺少文案: {script_path}") - text = script_path.read_text(encoding="utf-8-sig") - data = llm.chat_json(_prompt(topic_dir.name, text), system=SYSTEM, temperature=0.6) + script_text = script_path.read_text(encoding="utf-8-sig") + data = llm.chat_json( + _prompt(topic_dir.name, script_text), + system=SYSTEM, + temperature=0.6, + ) + if not isinstance(data, dict): + raise ValueError("S7 LLM 返回的不是 JSON object") + attempts = max(int(config.get("publish.repair_attempts", 2)), 0) problems = _validate(data) - if problems: - # 一次修复重试 - log.warning("格式问题 %s, 重试修复", problems) - fix = llm.chat_json( - "修复以下 JSON 使其满足要求: " + "; ".join(problems) + - "\n原 JSON:\n" + json.dumps(data, ensure_ascii=False), system=SYSTEM, temperature=0.3) - data = {**data, **fix} + for attempt in range(attempts): + if not problems: + break + log.warning("S7 格式修复第 %d/%d 轮: %s", attempt + 1, attempts, problems) + fixed = llm.chat_json( + "只修复格式和长度,不新增任何事实。\n" + + "问题: " + + "; ".join(problems) + + "\n原 JSON:\n" + + json.dumps(data, ensure_ascii=False), + system=SYSTEM, + temperature=0.25, + ) + if not isinstance(fixed, dict): + continue + data = {**data, **fixed} problems = _validate(data) - # 硬兜底: 封面主/副标题超长时直接截断(保证 S8 排版规范) - cm = re.sub(r"[^\u4e00-\u9fff]", "", data.get("cover_main", "")) - cs = re.sub(r"[^\u4e00-\u9fff]", "", data.get("cover_sub", "")) - if len(cm) > 6: - data["cover_main"], problems = cm[:6], problems + ["主标题硬截断为6字"] - if len(cs) > 8: - data["cover_sub"], problems = cs[:8], problems + ["副标题硬截断为8字"] - - pub = topic_dir / "发布" - pub.mkdir(parents=True, exist_ok=True) - (pub / "国内平台.txt").write_text( - data.get("cn_title", "") + "\n" + - " ".join(f"#{t.lstrip('#')}" for t in data.get("cn_tags", [])) + "\n", encoding="utf-8") - (pub / "海外平台.txt").write_text( - data.get("en_title", "") + "\n\n" + data.get("en_description", "") + "\n", encoding="utf-8") - (pub / "封面文案.txt").write_text( - data.get("cover_main", "") + "\n" + data.get("cover_sub", "") + "\n", encoding="utf-8") - utils.write_json(pub / "发布信息.json", {"data": data, "problems": problems}) - log.info("发布信息完成%s", "" if not problems else f"(仍有问题: {problems})") - return pub + + if problems: + raise ValueError("S7 发布信息验收失败: " + "; ".join(problems)) + + publish_dir = topic_dir / "发布" + publish_dir.mkdir(parents=True, exist_ok=True) + tags = [str(tag).strip().lstrip("#") for tag in data["cn_tags"]] + (publish_dir / "国内平台.txt").write_text( + str(data["cn_title"]).strip() + + "\n" + + " ".join(f"#{tag}" for tag in tags) + + "\n", + encoding="utf-8", + ) + (publish_dir / "海外平台.txt").write_text( + str(data["en_title"]).strip() + + "\n\n" + + str(data["en_description"]).strip() + + "\n", + encoding="utf-8", + ) + (publish_dir / "封面文案.txt").write_text( + str(data["cover_main"]).strip() + + "\n" + + str(data["cover_sub"]).strip() + + "\n", + encoding="utf-8", + ) + utils.write_json(publish_dir / "发布信息.json", {"data": data, "problems": []}) + log.info("S7 完成") + return publish_dir diff --git a/autoup/stages/render.py b/autoup/stages/render.py index 28bec24..222c936 100644 --- a/autoup/stages/render.py +++ b/autoup/stages/render.py @@ -1,19 +1,10 @@ -"""S6 合成: ED 段剪切 → concat → 配音轨(pydub) → BGM 闪避 → 烧字幕 → 一次终编码。 - -剪切/编码器回退思想参考 NarratoAI app/services/clip_video.py 与 generate_video.py (MIT)。 -时间轴模型(纯解说压剪): - - 画面: 按 edit_decision.json 顺序拼接, 句末段多剪 gap_seconds 作为句间停顿画面 - - 配音: 每句语音从该句首段画面的起点开始铺放(pyekub 混入静音底轨) - - 字幕: ASS, 时间 = 配音起点 → 配音起点+句长 -""" +"""S6 合成:剪切画面、配音、字幕与 BGM,严格要求 S5 已覆盖全部句子。""" from __future__ import annotations import logging -import random from pathlib import Path from .. import config, utils -from ..stages.dub import SCRIPT_NAME log = logging.getLogger("autoup.s6") @@ -21,69 +12,67 @@ SEG_BASENAME = "seg_{:04d}.mp4" -# ---------- 时间轴规划 ---------- - def plan_timeline(ed: dict, timing: dict, source_duration: float) -> dict: - """把 ED 段 + timing 展开为输出时间轴: - cuts: [{src_start, src_end, out_start, out_end}] 画面剪切段(句末含 gap) - voice: [{index, text, audio, t_start, duration}] 配音铺放 - subs: [{start, end, text}] ASS 字幕事件 - """ gap = float(config.get("voice.gap_seconds", 0.3)) - sents = {s["index"]: s for s in timing["sentences"]} - items = sorted(ed["items"], key=lambda x: x["start"]) - covered = {it["sentence_index"] for it in items} - missing = sorted(set(sents) - covered) + sentences = {int(item["index"]): item for item in timing["sentences"]} + items = sorted(ed["items"], key=lambda item: item["start"]) + covered = {int(item["sentence_index"]) for item in items} + missing = sorted(set(sentences) - covered) + if missing: + raise ValueError(f"S6 拒绝渲染:S5 仍缺少句子 {missing[:20]}") + if len(items) != len(sentences): + raise ValueError("S6 拒绝渲染:必须满足一语句一画面") cuts: list[dict] = [] voice: list[dict] = [] - subs: list[dict] = [] + subtitles: list[dict] = [] cursor = 0.0 - def append_cut(src_start: float, src_end: float) -> None: - nonlocal cursor - src_end = min(src_end, source_duration) + for item in items: + sentence_index = int(item["sentence_index"]) + sentence = sentences[sentence_index] + src_start = max(float(item["start"]), 0.0) + src_end = min(float(item["end"]) + gap, source_duration) if src_end - src_start < 0.3: - return - cuts.append({"src_start": round(src_start, 3), "src_end": round(src_end, 3), - "out_start": round(cursor, 3), "out_end": round(cursor + src_end - src_start, 3)}) - cursor += src_end - src_start - - for pos, it in enumerate(items): - si = it["sentence_index"] - is_sentence_end = (pos + 1 == len(items)) or (items[pos + 1]["sentence_index"] != si) - if si not in voice and si in sents: - voice.append({"index": si, "text": sents[si]["text"], - "audio": sents[si]["audio"], "t_start": round(cursor, 3), - "duration": sents[si]["duration"]}) - subs.append({"start": round(cursor, 3), - "end": round(cursor + sents[si]["duration"], 3), - "text": sents[si]["text"]}) - extend = gap if is_sentence_end else 0.0 - append_cut(it["start"], it["end"] + extend) - - # 未覆盖句: 挂在最后一段之后, 延长末段画面 - for si in missing: - s = sents[si] - need = s["duration"] + gap - if cuts: - cuts[-1]["src_end"] = round(min(cuts[-1]["src_end"] + need, source_duration), 3) - cuts[-1]["out_end"] = round(cuts[-1]["out_start"] + cuts[-1]["src_end"] - cuts[-1]["src_start"], 3) - cursor = cuts[-1]["out_end"] - else: - append_cut(0.0, min(need, source_duration)) - voice.append({"index": si, "text": s["text"], "audio": s["audio"], - "t_start": round(cursor - need, 3), "duration": s["duration"]}) - subs.append({"start": round(cursor - need, 3), "end": round(cursor - gap, 3), "text": s["text"]}) - log.warning("第 %d 句无匹配画面, 延续末段画面", si) + raise ValueError(f"第 {sentence_index} 句画面段过短") + + out_start = cursor + out_end = cursor + (src_end - src_start) + cuts.append( + { + "src_start": round(src_start, 3), + "src_end": round(src_end, 3), + "out_start": round(out_start, 3), + "out_end": round(out_end, 3), + } + ) + voice.append( + { + "index": sentence_index, + "text": sentence["text"], + "audio": sentence["audio"], + "t_start": round(out_start, 3), + "duration": float(sentence["duration"]), + } + ) + subtitles.append( + { + "start": round(out_start, 3), + "end": round(out_start + float(sentence["duration"]), 3), + "text": sentence["text"], + } + ) + cursor = out_end + + return { + "cuts": cuts, + "voice": voice, + "subs": subtitles, + "total": round(cursor, 3), + } - return {"cuts": cuts, "voice": voice, "subs": subs, "total": round(cursor, 3)} - - -# ---------- 画面段剪切与拼接 ---------- def _video_codec_args() -> tuple[str, list[str]]: - """(编码器名, 编码参数); NVENC 可用且配置允许则用 NVENC, 否则 libx264。""" crf = str(config.get("video.crf", 18)) preset = str(config.get("video.preset", "medium")) if config.get("video.prefer_gpu", True) and utils.nvenc_available(): @@ -92,127 +81,190 @@ def _video_codec_args() -> tuple[str, list[str]]: def cut_segments(src: Path, cuts: list[dict], workdir: Path) -> list[Path]: - """逐段精确剪切(-ss/-to 快速定位) + 统一参数重编码 + 去原声。""" codec, codec_args = _video_codec_args() - w, h = int(config.get("video.width", 1920)), int(config.get("video.height", 1080)) + width = int(config.get("video.width", 1920)) + height = int(config.get("video.height", 1080)) fps = str(config.get("video.fps", 30)) - vf = (f"scale={w}:{h}:force_original_aspect_ratio=decrease," - f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps}") + video_filter = ( + f"scale={width}:{height}:force_original_aspect_ratio=decrease," + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps}" + ) files: list[Path] = [] - for i, c in enumerate(cuts): - out = workdir / SEG_BASENAME.format(i + 1) - cmd = [config.ffmpeg(), "-y", "-hide_banner", "-loglevel", "error", - "-ss", c["src_start"], "-to", c["src_end"], "-i", src, - "-an", "-vf", vf, "-c:v", codec, *codec_args, - "-pix_fmt", "yuv420p", "-avoid_negative_ts", "make_zero", out] - p = utils.run(cmd, desc=f"剪切段 {i + 1}/{len(cuts)}", timeout=600) - if p.returncode != 0: - raise RuntimeError(f"段 {i + 1} 剪切失败") - files.append(out) + for index, cut in enumerate(cuts, 1): + output = workdir / SEG_BASENAME.format(index) + command = [ + config.ffmpeg(), + "-y", + "-hide_banner", + "-loglevel", + "error", + "-ss", + cut["src_start"], + "-to", + cut["src_end"], + "-i", + src, + "-an", + "-vf", + video_filter, + "-c:v", + codec, + *codec_args, + "-pix_fmt", + "yuv420p", + "-avoid_negative_ts", + "make_zero", + output, + ] + proc = utils.run(command, desc=f"剪切段 {index}/{len(cuts)}", timeout=600) + if proc.returncode != 0: + raise RuntimeError(f"段 {index} 剪切失败") + files.append(output) return files +def _concat_entry(path: Path) -> str: + escaped = path.as_posix().replace("'", r"'\''") + return f"file '{escaped}'\n" + + def concat_segments(files: list[Path], workdir: Path) -> Path: - """concat demuxer 拼接(各段编码参数一致, 流复制零损耗)。""" - lst = workdir / "concat.txt" - lst.write_text("".join(f"file '{f.as_posix()}'\n" for f in files), encoding="utf-8") - out = workdir / "picture.mp4" - p = utils.run([config.ffmpeg(), "-y", "-hide_banner", "-loglevel", "error", - "-f", "concat", "-safe", "0", "-i", lst, "-c", "copy", - "-movflags", "+faststart", out], desc="拼接画面", timeout=600) - if p.returncode != 0: + if not files: + raise ValueError("没有可拼接的画面段") + listing = workdir / "concat.txt" + listing.write_text("".join(_concat_entry(file) for file in files), encoding="utf-8") + output = workdir / "picture.mp4" + proc = utils.run( + [ + config.ffmpeg(), + "-y", + "-hide_banner", + "-loglevel", + "error", + "-f", + "concat", + "-safe", + "0", + "-i", + listing, + "-c", + "copy", + "-movflags", + "+faststart", + output, + ], + desc="拼接画面", + timeout=600, + ) + if proc.returncode != 0: raise RuntimeError("画面拼接失败") - return out + return output -# ---------- 配音轨与字幕 ---------- - def build_voice_track(voice: list[dict], topic_dir: Path, total: float, workdir: Path) -> Path: - """静音底轨 + 逐句铺放配音 → voice_full.wav (44.1kHz 立体声)。""" utils.ensure_pydub_ffmpeg() from pydub import AudioSegment + base = AudioSegment.silent(duration=int(total * 1000) + 500, frame_rate=44100) base = base.set_frame_rate(44100).set_channels(2) - for v in voice: - wav = topic_dir / "配音" / v["audio"] - seg = AudioSegment.from_file(str(wav)) - seg = seg.set_frame_rate(44100).set_channels(2) - pos_ms = max(int(v["t_start"] * 1000), 0) - base = base.overlay(seg, position=pos_ms) - out = workdir / "voice_full.wav" - base.export(str(out), format="wav") - return out - - -def _ass_time(t: float) -> str: - h = int(t // 3600) - m = int(t % 3600 // 60) - s = t % 60 - return f"{h:d}:{m:02d}:{s:05.2f}" - - -def _font_family(path: str) -> str: - """读字体内部 family 名, 供 ASS FontName 匹配(fontsdir)。""" + for item in voice: + wav = topic_dir / "配音" / item["audio"] + if not wav.exists(): + raise FileNotFoundError(f"缺少配音文件: {wav}") + segment = AudioSegment.from_file(str(wav)).set_frame_rate(44100).set_channels(2) + base = base.overlay(segment, position=max(int(item["t_start"] * 1000), 0)) + output = workdir / "voice_full.wav" + base.export(str(output), format="wav") + return output + + +def _ass_time(value: float) -> str: + hours = int(value // 3600) + minutes = int(value % 3600 // 60) + seconds = value % 60 + return f"{hours:d}:{minutes:02d}:{seconds:05.2f}" + + +def _resolve_font(raw: str | None) -> Path: + if raw: + path = Path(str(raw)).expanduser() + if not path.is_absolute(): + path = config.repo_root() / path + if path.exists(): + return path + bundled = config.repo_root() / "assets" / "fonts" / "05_江西拙楷.ttf" + if bundled.exists(): + return bundled + raise FileNotFoundError("找不到字幕字体,请在 subtitle.font_path 配置有效字体") + + +def _font_family(path: Path) -> str: try: from PIL import ImageFont - return ImageFont.truetype(path, 20).getname()[0] + + return ImageFont.truetype(str(path), 20).getname()[0] except Exception: # noqa: BLE001 return "Microsoft YaHei" def _stage_font(workdir: Path) -> Path: - """把字幕字体硬链/复制进工作目录(相对路径引用, 避开盘符冒号)。""" import os import shutil - src = Path(str(config.get("subtitle.font_path") - or config.get("cover.font_path") or "C:/Windows/Fonts/simhei.ttf")) + + source = _resolve_font(config.get("subtitle.font_path") or config.get("cover.font_path")) fonts_dir = workdir / "fonts" fonts_dir.mkdir(parents=True, exist_ok=True) - dst = fonts_dir / "subtitle.ttf" - if not dst.exists() or dst.stat().st_size != src.stat().st_size: + target = fonts_dir / "subtitle.ttf" + if not target.exists() or target.stat().st_size != source.stat().st_size: + if target.exists(): + target.unlink() try: - os.link(src, dst) + os.link(source, target) except OSError: - shutil.copy(str(src), str(dst)) + shutil.copy(str(source), str(target)) return fonts_dir def _split_lines(text: str, max_chars: int) -> list[str]: - """把句子切成 ≤max_chars 的行: 先按标点切段→段内去标点→打包; 超长段才硬切。 - 字幕内不保留任何标点(断句信息由换行和轮显节奏承担)。""" - import re as _re - PUNCT = ",。!?;:、,.!?;:…—“”‘’()()[]【】《》<>\"'~~·" - phrases = [p for p in _re.split(r"[,。!?;:、,.!?;:…]", text) if p] + punctuation = ",。!?;:、,.!?;:…—“”‘’()()[]【】《》<>\"'~~·" + phrases = [part for part in re_split(text) if part] lines: list[str] = [] - buf = "" + buffer = "" for raw in phrases: - ph = "".join(ch for ch in raw if ch not in PUNCT) - if not ph: + phrase = "".join(char for char in raw if char not in punctuation) + if not phrase: continue - while len(ph) > max_chars: # 超长短语硬切 - if buf: - lines.append(buf) - buf = "" - lines.append(ph[:max_chars]) - ph = ph[max_chars:] - if len(buf) + len(ph) <= max_chars: - buf += ph + while len(phrase) > max_chars: + if buffer: + lines.append(buffer) + buffer = "" + lines.append(phrase[:max_chars]) + phrase = phrase[max_chars:] + if len(buffer) + len(phrase) <= max_chars: + buffer += phrase else: - if buf: - lines.append(buf) - buf = ph - if buf: - lines.append(buf) + if buffer: + lines.append(buffer) + buffer = phrase + if buffer: + lines.append(buffer) return lines or [text] +def re_split(text: str) -> list[str]: + import re + + return re.split(r"[,。!?;:、,.!?;:…]", text) + + def build_ass(subs: list[dict], out: Path) -> Path: - """按 config.subtitle 生成 ASS(封面同款手写体, 单行≤15字, 长句分段轮显)。""" - st = config.get("subtitle", {}) or {} - font_path = str(st.get("font_path") or config.get("cover.font_path") or "C:/Windows/Fonts/simhei.ttf") + subtitle_config = config.get("subtitle", {}) or {} + font_path = _resolve_font( + subtitle_config.get("font_path") or config.get("cover.font_path") + ) family = _font_family(font_path) - size = int(st.get("font_size", 85)) - max_chars = int(st.get("max_chars_per_line", 15)) + size = int(subtitle_config.get("font_size", 85)) + max_chars = int(subtitle_config.get("max_chars_per_line", 15)) header = f"""[Script Info] ScriptType: v4.00+ PlayResX: {int(config.get('video.width', 1920))} @@ -222,103 +274,149 @@ def build_ass(subs: list[dict], out: Path) -> Path: [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Narr,{family},{size},{st.get('primary_colour', '&H00FFFFFF')},&H000000FF,{st.get('outline_colour', '&H00000000')},&H80000000,0,0,0,0,100,100,0,0,1,{float(st.get('outline', 2.0)) * 2:.1f},{st.get('shadow', 1.0)},2,60,60,{int(st.get('margin_v', 60))},1 +Style: Narr,{family},{size},{subtitle_config.get('primary_colour', '&H00FFFFFF')},&H000000FF,{subtitle_config.get('outline_colour', '&H00000000')},&H80000000,0,0,0,0,100,100,0,0,1,{float(subtitle_config.get('outline', 2.0)) * 2:.1f},{subtitle_config.get('shadow', 1.0)},2,60,60,{int(subtitle_config.get('margin_v', 60))},1 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text """ lines = [header] - for s in subs: - text = str(s["text"]).replace("\n", " ").replace("{", "(").replace("}", ")") + for subtitle in subs: + text = str(subtitle["text"]).replace("\n", " ").replace("{", "(").replace("}", ")") chunks = _split_lines(text, max_chars) - total_chars = sum(len(c) for c in chunks) or 1 - dur = max(float(s["end"]) - float(s["start"]), 0.4) - t = float(s["start"]) - for ch in chunks: - dt = dur * len(ch) / total_chars - lines.append(f"Dialogue: 0,{_ass_time(t)},{_ass_time(t + dt)},Narr,,0,0,0,,{ch}\n") - t += dt + total_chars = sum(len(chunk) for chunk in chunks) or 1 + duration = max(float(subtitle["end"]) - float(subtitle["start"]), 0.4) + cursor = float(subtitle["start"]) + for chunk in chunks: + chunk_duration = duration * len(chunk) / total_chars + lines.append( + f"Dialogue: 0,{_ass_time(cursor)},{_ass_time(cursor + chunk_duration)},Narr,,0,0,0,,{chunk}\n" + ) + cursor += chunk_duration out.write_text("".join(lines), encoding="utf-8") return out -# ---------- BGM ---------- - -def _pick_bgm() -> Path | None: - d = config.get("bgm.directory") - bgm_dir = Path(d) if d else Path(config.repo_root()) / "assets" / "bgm" +def _pick_bgm(topic_dir: Path) -> Path | None: + configured = config.get("bgm.directory") + bgm_dir = Path(str(configured)).expanduser() if configured else config.repo_root() / "assets" / "bgm" + if configured and not bgm_dir.is_absolute(): + bgm_dir = config.repo_root() / bgm_dir if not bgm_dir.exists(): return None - files = [f for f in bgm_dir.iterdir() - if f.suffix.lower() in {".mp3", ".wav", ".m4a", ".flac", ".ogg"} - and not f.name.startswith(".")] - return random.choice(files) if files else None + files = sorted( + file + for file in bgm_dir.iterdir() + if file.suffix.lower() in {".mp3", ".wav", ".m4a", ".flac", ".ogg"} + and not file.name.startswith(".") + ) + if not files: + return None + + pick = str(config.get("bgm.pick", "random") or "random") + if pick != "random": + exact = bgm_dir / pick + if not exact.exists(): + raise FileNotFoundError(f"指定 BGM 不存在: {exact}") + return exact + + index = int(utils.stable_hash(topic_dir.name)[:12], 16) % len(files) + return files[index] -# ---------- 主流程 ---------- def run(topic_dir: Path) -> Path: timing = utils.read_json(topic_dir / "配音" / "timing.json") - ed = utils.read_json(topic_dir / "edit_decision.json") - if not timing or not ed: - raise FileNotFoundError("缺少 timing.json 或 edit_decision.json (先跑 S4/S5)") - src = next((f for f in (topic_dir / "素材").iterdir() - if f.suffix.lower() in VIDEO_EXTS), None) \ - if (topic_dir / "素材").exists() else None - if src is None: - raise FileNotFoundError(f"缺少源视频: {topic_dir}/素材/") + edit = utils.read_json(topic_dir / "edit_decision.json") + if not timing or not edit: + raise FileNotFoundError("缺少 timing.json 或 edit_decision.json(先跑 S4/S5)") + + material = topic_dir / "素材" + source = next( + (file for file in material.iterdir() if file.suffix.lower() in VIDEO_EXTS), + None, + ) if material.exists() else None + if source is None: + raise FileNotFoundError(f"缺少源视频: {material}") workdir = topic_dir / "成片工程" workdir.mkdir(parents=True, exist_ok=True) - src_info = utils.probe_video(src) - - plan = plan_timeline(ed, timing, src_info["duration"]) - log.info("时间轴: %d 画面段, 总时长 %.1fs (%.1f 分钟)", - len(plan["cuts"]), plan["total"], plan["total"] / 60) + source_info = utils.probe_video(source) + plan = plan_timeline(edit, timing, source_info["duration"]) + log.info("时间轴: %d 段,总时长 %.1f 分钟", len(plan["cuts"]), plan["total"] / 60) - seg_files = cut_segments(src, plan["cuts"], workdir) - picture = concat_segments(seg_files, workdir) + segment_files = cut_segments(source, plan["cuts"], workdir) + picture = concat_segments(segment_files, workdir) voice_wav = build_voice_track(plan["voice"], topic_dir, plan["total"], workdir) - ass = build_ass(plan["subs"], workdir / "subs.ass") + build_ass(plan["subs"], workdir / "subs.ass") - # 终编码: 字幕烧录 + 配音/BGM 混音(闪避) codec, codec_args = _video_codec_args() total = plan["total"] fade = float(config.get("bgm.fade_out_seconds", 3.0)) - bgm_vol = float(config.get("bgm.volume", 0.12)) + bgm_volume = float(config.get("bgm.volume", 0.12)) duck = float(config.get("bgm.duck_ratio", 0.35)) - cmd = [config.ffmpeg(), "-y", "-hide_banner", "-loglevel", "error", - "-i", picture, "-i", voice_wav] - bgm = _pick_bgm() if config.get("bgm.enabled", True) else None + command = [ + config.ffmpeg(), + "-y", + "-hide_banner", + "-loglevel", + "error", + "-i", + picture, + "-i", + voice_wav, + ] + bgm = _pick_bgm(topic_dir) if config.get("bgm.enabled", True) else None if bgm: log.info("BGM: %s", bgm.name) - cmd += ["-stream_loop", "-1", "-i", bgm] - # BGM: 压到配置音量 → sidechaincompress 随解说闪避 → 尾部淡出 - ratio = round(bgm_vol / max(duck, 0.01), 2) # 闪避后落在 duck*线性电平 - afilter = (f"[2:a]volume={ratio}," - f"sidechaincompress=threshold=0.02:ratio=6:attack=80:release=600:makeup=1[bgm];" - f"[bgm]afade=t=out:st={max(total - fade, 0):.2f}:d={fade}[bgmF];" - f"[1:a][bgmF]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]") + command += ["-stream_loop", "-1", "-i", bgm] + pre_volume = round(bgm_volume / max(duck, 0.01), 2) + audio_filter = ( + f"[2:a]volume={pre_volume}," + "sidechaincompress=threshold=0.02:ratio=6:attack=80:release=600:makeup=1[bgm];" + f"[bgm]afade=t=out:st={max(total - fade, 0):.2f}:d={fade}[bgmF];" + "[1:a][bgmF]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]" + ) else: - afilter = "[1:a]anull[aout]" - # ass 滤镜参数含 Windows 盘符冒号会被解析器截断 → cwd=workdir 用相对路径 - # 自定义字体未装系统 → fontsdir 指向工作目录内字体副本 + audio_filter = "[1:a]anull[aout]" + fonts_dir = _stage_font(workdir) - cmd += ["-filter_complex", f"[0:v]ass=subs.ass:fontsdir={fonts_dir.name}[v];{afilter}", - "-map", "[v]", "-map", "[aout]", - "-c:v", codec, *codec_args, "-pix_fmt", "yuv420p", - "-c:a", "aac", "-b:a", "192k", - "-t", f"{total:.3f}", "-movflags", "+faststart", - topic_dir / "成片.mp4"] - p = utils.run(cmd, desc="终编码", timeout=3600, cwd=workdir) - if p.returncode != 0: + output = topic_dir / "成片.mp4" + command += [ + "-filter_complex", + f"[0:v]ass=subs.ass:fontsdir={fonts_dir.name}[v];{audio_filter}", + "-map", + "[v]", + "-map", + "[aout]", + "-c:v", + codec, + *codec_args, + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-b:a", + "192k", + "-t", + f"{total:.3f}", + "-movflags", + "+faststart", + output, + ] + proc = utils.run(command, desc="终编码", timeout=3600, cwd=workdir) + if proc.returncode != 0: raise RuntimeError("终编码失败") - report = {"total": total, "cuts": len(plan["cuts"]), - "voice_count": len(plan["voice"]), "bgm": bgm.name if bgm else None, - "codec": codec} - utils.write_json(workdir / "render_report.json", report) - log.info("成片完成: %s (%.1fs, %d 段, BGM=%s)", - topic_dir / "成片.mp4", total, len(plan["cuts"]), bool(bgm)) - return topic_dir / "成片.mp4" + utils.write_json( + workdir / "render_report.json", + { + "total": total, + "cuts": len(plan["cuts"]), + "voice_count": len(plan["voice"]), + "bgm": bgm.name if bgm else None, + "codec": codec, + }, + ) + log.info("S6 完成: %s", output) + return output diff --git a/autoup/stages/validate.py b/autoup/stages/validate.py index 2485816..e49cba2 100644 --- a/autoup/stages/validate.py +++ b/autoup/stages/validate.py @@ -1,14 +1,27 @@ -"""S9 验收: 选题目录完整性 + 各产物规格校验 → 验收报告.json。""" +"""S9 验收:只要关键契约有一项不满足就失败。""" from __future__ import annotations import logging +import re from pathlib import Path +from PIL import Image + from .. import config, utils log = logging.getLogger("autoup.s9") VIDEO_EXTS = {".mp4", ".mkv", ".webm"} +BANNED_PATTERNS = ( + r"【开场钩子】", + r"【核心悬念】", + r"【结尾升华】", + r"镜头切到", + r"画面来到", + r"点赞", + r"关注我", + r"转发给", +) def run(topic_dir: Path) -> dict: @@ -17,76 +30,195 @@ def run(topic_dir: Path) -> dict: def check(name: str, ok: bool, detail: str = "") -> None: checks.append({"item": name, "ok": bool(ok), "detail": detail}) - # 素材 - src = next((f for f in (topic_dir / "素材").glob("*") if f.suffix.lower() in VIDEO_EXTS), None) - check("源视频", src is not None and src.stat().st_size > 1e6, str(src)) - - # 字幕 - srt = topic_dir / "字幕" / "字幕.srt" - cues = utils.parse_srt(srt) if srt.exists() else [] + material = topic_dir / "素材" + source = next( + ( + file + for file in material.glob("*") + if file.suffix.lower() in VIDEO_EXTS + ), + None, + ) + source_ok = source is not None and source.stat().st_size > 1_000_000 + check("源视频", source_ok, str(source) if source else "缺失") + + srt_path = topic_dir / "字幕" / "字幕.srt" + cues = utils.parse_srt(srt_path) if srt_path.exists() else [] check("字幕SRT", len(cues) > 10, f"{len(cues)} 条") - # 文案 - script = topic_dir / "文案" / "爆款口播稿.txt" - n_chars = 0 - if script.exists(): - import re - n_chars = len(re.sub(r"\s", "", script.read_text(encoding="utf-8-sig"))) - lo = int(config.get("script.target_chars", 4500) * (1 - config.get("script.tolerance", 0.12))) - check("解说文案", n_chars >= lo, f"{n_chars} 字 (下限 {lo})") + script_path = topic_dir / "文案" / "爆款口播稿.txt" + script_text = ( + script_path.read_text(encoding="utf-8-sig") if script_path.exists() else "" + ) + script_chars = len(re.sub(r"\s", "", script_text)) + target = int(config.get("script.target_chars", 4500)) + tolerance = float(config.get("script.tolerance", 0.12)) + lower = int(target * (1 - tolerance)) + upper = int(target * (1 + tolerance)) + banned = [ + pattern for pattern in BANNED_PATTERNS if re.search(pattern, script_text) + ] + check( + "解说文案字数", + lower <= script_chars <= upper, + f"{script_chars} 字,要求 {lower}-{upper}", + ) + check( + "解说文案无生产标签", + not banned, + "命中: " + ", ".join(banned) if banned else "", + ) - # 配音 timing = utils.read_json(topic_dir / "配音" / "timing.json") or {} - sents = timing.get("sentences", []) - missing_audio = [s["audio"] for s in sents - if not (topic_dir / "配音" / s["audio"]).exists()] - check("配音timing", len(sents) > 0 and not missing_audio, - f"{len(sents)} 句" + (f", 缺音频 {missing_audio[:3]}" if missing_audio else "")) - - # 匹配 - ed = utils.read_json(topic_dir / "edit_decision.json") or {} - covered = {i.get("sentence_index") for i in ed.get("items", [])} - check("画面匹配", len(covered) >= max(len(sents) - 1, 1), - f"覆盖 {len(covered)}/{len(sents)} 句") - - # 成片 + sentences = timing.get("sentences", []) + missing_audio = [] + missing_fingerprints = [] + for sentence in sentences: + audio = topic_dir / "配音" / str(sentence.get("audio", "")) + if not audio.exists() or audio.stat().st_size <= 1024: + missing_audio.append(str(sentence.get("audio", ""))) + if not sentence.get("fingerprint"): + missing_fingerprints.append(int(sentence.get("index", 0) or 0)) + check( + "配音timing", + bool(sentences) and not missing_audio, + f"{len(sentences)} 句" + + (f",缺音频 {missing_audio[:3]}" if missing_audio else ""), + ) + check( + "配音缓存指纹", + bool(sentences) and not missing_fingerprints, + f"缺指纹句 {missing_fingerprints[:5]}" if missing_fingerprints else "", + ) + + edit = utils.read_json(topic_dir / "edit_decision.json") or {} + items = edit.get("items", []) + expected = {int(sentence.get("index", 0)) for sentence in sentences} + covered = {int(item.get("sentence_index", 0)) for item in items} + one_to_one = len(items) == len(sentences) and covered == expected + chronological = True + previous_end = -1.0 + for item in items: + start = float(item.get("start", -1)) + end = float(item.get("end", -1)) + if start < previous_end or end <= start: + chronological = False + break + previous_end = end + check( + "画面匹配全覆盖", + bool(sentences) + and one_to_one + and not edit.get("uncovered_sentences"), + f"覆盖 {len(covered)}/{len(sentences)} 句", + ) + check("画面时间轴递增无重叠", chronological) + final = topic_dir / "成片.mp4" - if final.exists() and final.stat().st_size > 1e6: + if final.exists() and final.stat().st_size > 1_000_000: info = utils.probe_video(final) - expect = float((ed.get("total_picture_duration") or 0)) or float(timing.get("total_duration") or 0) - dur_ok = not expect or abs(info["duration"] - expect) < max(expect * 0.15, 5) - res_ok = info["width"] == int(config.get("video.width", 1920)) and \ - info["height"] == int(config.get("video.height", 1080)) - check("成片分辨率", res_ok, f"{info['width']}x{info['height']}") - check("成片时长", dur_ok, f"{info['duration']:.0f}s vs 计划 {expect:.0f}s") - check("成片音轨", info["has_audio"]) + expected_duration = float(timing.get("total_duration") or 0) + duration_ok = ( + not expected_duration + or abs(info["duration"] - expected_duration) + < max(expected_duration * 0.20, 8) + ) + resolution_ok = ( + info["width"] == int(config.get("video.width", 1920)) + and info["height"] == int(config.get("video.height", 1080)) + ) + check( + "成片分辨率", + resolution_ok, + f"{info['width']}x{info['height']}", + ) + check( + "成片时长", + duration_ok, + f"{info['duration']:.1f}s vs 配音 {expected_duration:.1f}s", + ) + check("成片音轨", bool(info["has_audio"])) else: - check("成片", False, "文件缺失或过小") - - # 发布信息 - cn = topic_dir / "发布" / "国内平台.txt" - if cn.exists(): - lines = [l for l in cn.read_text(encoding="utf-8-sig").splitlines() if l.strip()] - check("国内标题≤25字", len(lines) >= 1 and len(lines[0]) <= 25, f"{len(lines[0]) if lines else 0} 字符") - check("国内标签5个", len(lines) >= 2 and len(lines[1].split()) == 5, lines[1] if len(lines) > 1 else "") + check("成片文件", False, "缺失或过小") + + cn_path = topic_dir / "发布" / "国内平台.txt" + if cn_path.exists(): + lines = [ + line.strip() + for line in cn_path.read_text(encoding="utf-8-sig").splitlines() + if line.strip() + ] + title = lines[0] if lines else "" + tags = lines[1].split() if len(lines) >= 2 else [] + valid_tags = [ + tag for tag in tags if re.fullmatch(r"#[^\s#]+", tag) + ] + check( + "国内标题", + bool(title) and len(title) <= 25 and "#" not in title, + f"{len(title)} 字符", + ) + check( + "国内标签", + len(tags) == 5 + and len(valid_tags) == 5 + and len(set(tags)) == 5, + " ".join(tags), + ) else: check("国内平台.txt", False, "缺失") - check("海外平台.txt", (topic_dir / "发布" / "海外平台.txt").exists()) - # 封面 - for name in ("9x16", "16x9", "1x1"): - f = topic_dir / "封面" / f"封面-{name}.png" - ok = f.exists() and f.stat().st_size > 50_000 - check(f"封面{name}", ok) + en_path = topic_dir / "发布" / "海外平台.txt" + if en_path.exists(): + en_lines = [ + line.strip() + for line in en_path.read_text(encoding="utf-8-sig").splitlines() + if line.strip() + ] + en_title = en_lines[0] if en_lines else "" + en_text = " ".join(en_lines) + check( + "海外平台信息", + bool(en_title) + and len(en_title) <= 90 + and not re.search(r"[\u4e00-\u9fff]", en_text), + f"标题 {len(en_title)} 字符", + ) + else: + check("海外平台.txt", False, "缺失") + + expected_sizes = { + "9x16": (1080, 1920), + "16x9": (1920, 1080), + "1x1": (1080, 1080), + } + for name, expected_size in expected_sizes.items(): + cover = topic_dir / "封面" / f"封面-{name}.png" + size = None + if cover.exists() and cover.stat().st_size > 50_000: + try: + with Image.open(cover) as image: + size = image.size + except OSError: + size = None + check( + f"封面{name}", + size == expected_size, + f"{size},要求 {expected_size}", + ) report = { "topic": topic_dir.name, - "passed": all(c["ok"] for c in checks), - "failed": [c["item"] for c in checks if not c["ok"]], + "passed": all(item["ok"] for item in checks), + "failed": [item["item"] for item in checks if not item["ok"]], "checks": checks, } utils.write_json(topic_dir / "验收报告.json", report) - ok_n = sum(c["ok"] for c in checks) - log.info("验收: %d/%d 项通过%s — 失败: %s", ok_n, len(checks), - " ✅" if report["passed"] else " ❌", report["failed"]) + passed = sum(item["ok"] for item in checks) + log.info( + "验收: %d/%d 项通过%s", + passed, + len(checks), + " ✅" if report["passed"] else f" ❌ {report['failed']}", + ) return report diff --git a/autoup/state.py b/autoup/state.py index e74d77b..61320ff 100644 --- a/autoup/state.py +++ b/autoup/state.py @@ -1,4 +1,4 @@ -"""阶段输入指纹:让断点续跑能识别上游内容或配置变化。""" +"""阶段输入指纹:让断点续跑能识别上游内容、模型参数或本机资源变化。""" from __future__ import annotations from pathlib import Path @@ -17,7 +17,10 @@ def _first_video(topic_dir: Path) -> Path | None: material = topic_dir / "素材" if not material.exists(): return None - return next((p for p in material.glob("高清源视频.*") if p.suffix.lower() in VIDEO_EXTS), None) + return next( + (path for path in material.glob("高清源视频.*") if path.suffix.lower() in VIDEO_EXTS), + None, + ) def _llm_descriptor() -> dict[str, Any]: @@ -26,6 +29,7 @@ def _llm_descriptor() -> dict[str, Any]: "model": config.get("llm.model"), "temperature": config.get("llm.temperature"), "max_retries": config.get("llm.max_retries"), + "max_tokens": config.get("llm.max_tokens"), } @@ -33,25 +37,46 @@ def _cfg(*keys: str) -> dict[str, Any]: return {key: config.get(key) for key in keys} -def stage_fingerprint(topic_dir: Path, stage: str, *, voice: str = "default", url: str = "") -> str: +def _voice_reference(voice: str) -> dict[str, Any] | None: + raw = config.get(f"voice.gpt_sovits.voices.{voice}.ref_audio_path") + if not raw: + return None + path = Path(str(raw)).expanduser() + return _sig(path) if path.exists() else {"exists": False, "path": str(path)} + + +def stage_fingerprint( + topic_dir: Path, + stage: str, + *, + voice: str = "default", + url: str = "", +) -> str: topic_dir = Path(topic_dir) subtitle = topic_dir / "字幕" / "字幕.srt" script = topic_dir / "文案" / "爆款口播稿.txt" timing = topic_dir / "配音" / "timing.json" edit = topic_dir / "edit_decision.json" video = _first_video(topic_dir) - payload: dict[str, Any] = {"version": 1, "stage": stage} + payload: dict[str, Any] = {"version": 2, "stage": stage} if stage == "s2": payload.update({"url": url, "config": _cfg("download")}) elif stage == "s3": - payload.update({"subtitle": _sig(subtitle), "config": _cfg("script"), "llm": _llm_descriptor()}) + payload.update( + { + "subtitle": _sig(subtitle), + "config": _cfg("script"), + "llm": _llm_descriptor(), + } + ) elif stage == "s4": payload.update( { "script": _sig(script), "voice_name": voice, "config": _cfg("voice"), + "reference_audio": _voice_reference(voice), } ) elif stage == "s5": @@ -73,7 +98,13 @@ def stage_fingerprint(topic_dir: Path, stage: str, *, voice: str = "default", ur } ) elif stage == "s7": - payload.update({"script": _sig(script), "llm": _llm_descriptor()}) + payload.update( + { + "script": _sig(script), + "config": _cfg("publish"), + "llm": _llm_descriptor(), + } + ) elif stage == "s8": payload.update( { From a4b0f53f21cf64bcdb8086ed97621d793caac32e Mon Sep 17 00:00:00 2001 From: DaBao Date: Sat, 3 Oct 2026 23:29:30 +0800 Subject: [PATCH 04/12] test: add CI quality gates and remove legacy scripts --- .github/workflows/ci.yml | 50 +++++ .gitignore | 19 ++ pyproject.toml | 57 +++++ requirements.txt | 6 - scripts/_srt_to_text.py | 47 ---- scripts/_validate_batch.py | 54 ----- scripts/build_topic_covers_from_video.py | 263 ----------------------- scripts/download_from_manifest.ps1 | 209 ------------------ scripts/gen_jimeng_cover_prompts.py | 123 ----------- scripts/validate_deliverables.py | 254 ---------------------- scripts/validate_publication_info.py | 99 --------- tests/test_config.py | 47 ++++ tests/test_llm.py | 80 +++++++ tests/test_run_state.py | 68 ++++++ tests/test_smoke.py | 20 ++ tests/test_stages.py | 229 ++++++++++++++++++++ tests/test_utils.py | 40 ++++ tests/test_validate.py | 115 ++++++++++ 18 files changed, 725 insertions(+), 1055 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 scripts/_srt_to_text.py delete mode 100644 scripts/_validate_batch.py delete mode 100644 scripts/build_topic_covers_from_video.py delete mode 100644 scripts/download_from_manifest.ps1 delete mode 100644 scripts/gen_jimeng_cover_prompts.py delete mode 100644 scripts/validate_deliverables.py delete mode 100644 scripts/validate_publication_info.py create mode 100644 tests/test_config.py create mode 100644 tests/test_llm.py create mode 100644 tests/test_run_state.py create mode 100644 tests/test_smoke.py create mode 100644 tests/test_stages.py create mode 100644 tests/test_utils.py create mode 100644 tests/test_validate.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f29f78d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: + - main + - "refactor/**" + pull_request: + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.11" + enable-cache: true + - name: Resolve dependencies + run: uv lock + - name: Install + run: uv sync --all-extras + - name: Compile + run: uv run python -m compileall -q autoup tests + - name: Ruff + run: uv run ruff check autoup tests + - name: Tests + run: uv run pytest -q --cov=autoup --cov-report=term-missing --cov-report=xml + - name: Upload lockfile + uses: actions/upload-artifact@v4 + with: + name: uv-lock + path: uv.lock + if-no-files-found: error + + windows-smoke: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.11" + enable-cache: true + - name: Install + run: uv sync --all-extras + - name: Smoke tests + run: uv run pytest -q tests/test_smoke.py tests/test_config.py tests/test_utils.py diff --git a/.gitignore b/.gitignore index 43ae0e2..f2b122a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,21 @@ __pycache__/ *.py[cod] +*.egg-info/ +.venv/ +.env +.env.* +!.env.example +autoup/config.local.yaml +output/ +dist/ +build/ +.pytest_cache/ +.ruff_cache/ +.coverage +coverage.xml +htmlcov/ +*.log +*.tmp +.DS_Store +.idea/ +.vscode/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..992c8b0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["hatchling>=1.26"] +build-backend = "hatchling.build" + +[project] +name = "autoup" +version = "0.2.0" +description = "Lightweight documentary narration automation pipeline" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +dependencies = [ + "yt-dlp>=2026.8.19", + "pydub>=0.25.1", + "Pillow>=10.0", + "edge-tts>=7.0.0", + "requests>=2.31", + "PyYAML>=6.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3", + "pytest-cov>=6.0", + "ruff>=0.12", +] + +[project.scripts] +autoup = "autoup.run:main" + +[tool.hatch.build.targets.wheel] +packages = ["autoup"] + +[tool.ruff] +target-version = "py311" +line-length = 120 +extend-exclude = ["assets", "docs", "github", "vendor"] + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" + +[tool.coverage.run] +source = ["autoup"] +omit = [ + "autoup/tts/edge.py", + "autoup/tts/gpt_sovits.py", + "autoup/stages/font_preview.py", +] + +[tool.coverage.report] +fail_under = 70 +show_missing = true +skip_covered = true diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 8b3ed56..0000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -yt-dlp>=2026.8.19 -pydub>=0.25.1 -Pillow>=10.0 -edge-tts>=7.0.0 -requests>=2.31 -PyYAML>=6.0 diff --git a/scripts/_srt_to_text.py b/scripts/_srt_to_text.py deleted file mode 100644 index 66dfe5a..0000000 --- a/scripts/_srt_to_text.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -"""Convert SRT files to plain text transcripts (strip indexes/timestamps).""" -import os, re, sys - -ROOT = r"F:\16 监狱2" - -def srt_to_text(path): - with open(path, "r", encoding="utf-8-sig", errors="replace") as f: - raw = f.read() - # Remove BOM, split blocks - blocks = re.split(r"\n\s*\n", raw) - lines = [] - for b in blocks: - b = b.strip() - if not b: - continue - parts = b.split("\n") - # parts[0] = index, parts[1] = timestamp, rest = text - text_parts = [] - for p in parts: - if re.match(r"^\d+$", p.strip()): - continue - if "-->" in p: - continue - text_parts.append(p.strip()) - if text_parts: - lines.append(" ".join(text_parts)) - return "\n".join(lines) - -for d in sorted(os.listdir(ROOT)): - full = os.path.join(ROOT, d) - if not os.path.isdir(full): - continue - srt = None - for f in os.listdir(full): - if f.lower().endswith(".srt"): - srt = os.path.join(full, f) - break - if not srt: - print(f"[SKIP] no srt in {d}") - continue - text = srt_to_text(srt) - out = os.path.join(full, "_transcript.txt") - with open(out, "w", encoding="utf-8") as f: - f.write(text) - print(f"[OK] {d}: {len(text)} chars -> {out}") -print("DONE") diff --git a/scripts/_validate_batch.py b/scripts/_validate_batch.py deleted file mode 100644 index 8774442..0000000 --- a/scripts/_validate_batch.py +++ /dev/null @@ -1,54 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validate autoyy batch deliverables in F:\16 监狱2.""" -import os, re, sys, glob - -ROOT = r"F:\16 监狱2" - -def cn_len(s): - return len(re.sub(r"\s+", "", s)) - -results = [] -for d in sorted(os.listdir(ROOT)): - full = os.path.join(ROOT, d) - if not os.path.isdir(full): - continue - r = {"dir": d, "script": None, "pub": None, "cover": None} - script = os.path.join(full, "爆款口播稿.txt") - pub = os.path.join(full, "发布信息.txt") - cover = os.path.join(full, "封面提示词-即梦.txt") - if os.path.exists(script): - with open(script, encoding="utf-8") as f: - t = f.read() - n = cn_len(t) - r["script"] = f"OK({n}字)" if 4500 <= n <= 5500 else f"FAIL({n}字)" - else: - r["script"] = "MISSING" - if os.path.exists(pub): - with open(pub, encoding="utf-8") as f: - lines = [l.rstrip("\r\n") for l in f.readlines()] - nonempty = [l for l in lines if l.strip()] - title = nonempty[0] if nonempty else "" - tags = nonempty[1].split() if len(nonempty) > 1 else [] - tl = len(title) - r["pub"] = f"{'OK' if (len(nonempty)==2 and tl<=25 and len(tags)==5) else 'FAIL'}(行{len(nonempty)} 标题{tl}字 标签{len(tags)})" - r["title"] = title - r["tags"] = tags - else: - r["pub"] = "MISSING" - if os.path.exists(cover): - with open(cover, encoding="utf-8") as f: - c = f.read().strip() - paras = [p for p in re.split(r"\n\s*\n", c) if p.strip()] - r["cover"] = f"{'OK' if len(paras)==2 else 'FAIL'}({len(paras)}段)" - else: - r["cover"] = "MISSING" - results.append(r) - -print(f"{'目录':<30} {'口播稿':<16} {'发布信息':<38} {'封面提示词':<10}") -for r in results: - print(f"{r['dir']:<30} {r['script']:<16} {r['pub']:<38} {r['cover']:<10}") - if r.get("title"): - print(f"{'':<30} 标题: {r['title']} 标签: {' '.join(r['tags'])}") - -ok = sum(1 for r in results if r["script"].startswith("OK") and r["pub"].startswith("OK") and r["cover"].startswith("OK")) -print(f"\n完成: {ok}/{len(results)}") diff --git a/scripts/build_topic_covers_from_video.py b/scripts/build_topic_covers_from_video.py deleted file mode 100644 index 8be4c2a..0000000 --- a/scripts/build_topic_covers_from_video.py +++ /dev/null @@ -1,263 +0,0 @@ -#!/usr/bin/env python3 -"""Build deterministic 3:4 and 4:3 topic covers from local source videos. - -The tool keeps exact Chinese cover text by rendering it locally after selecting -a representative documentary frame from each topic video. Existing finished -covers are skipped unless --force is supplied. -""" - -from __future__ import annotations - -import argparse -import math -import os -import re -import subprocess -import sys -import tempfile -from pathlib import Path - -from PIL import Image, ImageEnhance, ImageFilter, ImageFont, ImageOps, ImageStat - - -IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"} -VIDEO_EXTS = {".mp4", ".mkv", ".webm", ".mov", ".m4v", ".avi"} -PROMPT_NAME = "封面提示词-即梦.txt" - -MISSING_TEXT = { - "27-哈尔登监狱:全球最奢华监狱": ("奢华挪威监狱", "囚犯住进森林别墅"), - "33-最凶残狱友": ("最凶狱友实录", "与杀人犯同室生存"), - "35-死囚比利·特雷西": ("死囚最后岁月", "比利特雷西等行刑"), - "43-美国ADX超级监狱内幕": ("地下超级监狱", "二十三小时独囚禁"), - "45-美国最臭名昭著死囚区": ("死囚区的噩梦", "囚犯每天等待死亡"), - "47-拥挤暴力的世界监狱": ("塞爆世界监狱", "拥挤暴力天天上演"), - "50-萨尔瓦多黑帮大监狱": ("黑帮巨型牢笼", "四万人塞进大监狱"), - "58-红洋葱超级监狱": ("红洋葱活地狱", "最高戒备终身独囚"), - "78-美国最危险监狱群": ("危险监狱帝国", "全美重刑犯都在此"), -} - - -def parse_args() -> argparse.Namespace: - ap = argparse.ArgumentParser() - ap.add_argument("root", type=Path) - ap.add_argument("--ffmpeg", type=Path, required=True) - ap.add_argument("--title-font", type=Path, required=True) - ap.add_argument("--subtitle-font", type=Path, required=True) - ap.add_argument("--force", action="store_true") - ap.add_argument("--limit", type=int) - ap.add_argument("--background-override", type=Path) - ap.add_argument("--background-override-dir", default="") - ap.add_argument("--background-dir", type=Path) - return ap.parse_args() - - -def cover_text(folder: Path) -> tuple[str, str]: - prompt = folder / PROMPT_NAME - if prompt.exists(): - text = prompt.read_text(encoding="utf-8") - main = re.search(r"主标题[「“\"]([^」”\"]+)[」”\"]", text) - sub = re.search(r"副标题[「“\"]([^」”\"]+)[」”\"]", text) - if main and sub: - return main.group(1), sub.group(1) - if folder.name in MISSING_TEXT: - return MISSING_TEXT[folder.name] - raise ValueError(f"missing cover text: {folder}") - - -def duration_seconds(ffmpeg: Path, video: Path) -> float: - proc = subprocess.run( - [str(ffmpeg), "-hide_banner", "-i", str(video)], - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="replace", - ) - match = re.search(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)", proc.stderr) - if not match: - raise RuntimeError(f"cannot read duration: {video}") - hours, minutes, seconds = match.groups() - return int(hours) * 3600 + int(minutes) * 60 + float(seconds) - - -def frame_score(path: Path) -> float: - image = Image.open(path).convert("RGB").resize((320, 180)) - gray = ImageOps.grayscale(image) - stat = ImageStat.Stat(gray) - mean = stat.mean[0] - contrast = stat.stddev[0] - entropy = gray.entropy() - edge = ImageStat.Stat(gray.filter(ImageFilter.FIND_EDGES)).mean[0] - exposure_penalty = abs(mean - 112) * 0.20 - black_penalty = max(0, 42 - mean) * 1.8 - white_penalty = max(0, mean - 215) * 1.8 - return contrast * 1.7 + entropy * 7.0 + edge * 1.2 - exposure_penalty - black_penalty - white_penalty - - -def extract_best_frame(ffmpeg: Path, video: Path, temp_dir: Path) -> Image.Image: - duration = duration_seconds(ffmpeg, video) - candidates: list[tuple[float, Path]] = [] - for index, fraction in enumerate((0.14, 0.26, 0.38, 0.50, 0.62, 0.74, 0.86)): - output = temp_dir / f"frame-{index}.jpg" - timestamp = max(1.0, min(duration - 1.0, duration * fraction)) - cmd = [ - str(ffmpeg), "-hide_banner", "-loglevel", "error", "-ss", f"{timestamp:.3f}", - "-i", str(video), "-frames:v", "1", "-vf", "scale=1920:-2", "-q:v", "2", - "-y", str(output), - ] - subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) - candidates.append((frame_score(output), output)) - _, best = max(candidates, key=lambda item: item[0]) - with Image.open(best) as image: - return image.convert("RGB").copy() - - -def fit_background(source: Image.Image, size: tuple[int, int]) -> Image.Image: - image = ImageOps.fit(source, size, method=Image.Resampling.LANCZOS, centering=(0.5, 0.48)) - image = ImageEnhance.Color(image).enhance(0.82) - image = ImageEnhance.Contrast(image).enhance(1.18) - image = ImageEnhance.Sharpness(image).enhance(1.12) - cool = Image.new("RGB", size, (19, 42, 61)) - image = Image.blend(image, cool, 0.12) - - # Darken the title area while retaining enough scene detail. - overlay = Image.new("RGBA", size, (0, 0, 0, 0)) - alpha = Image.new("L", (1, size[1]), 0) - pixels = alpha.load() - height = size[1] - for y in range(height): - t = y / height - top = max(0.0, 1.0 - t / 0.52) - bottom = max(0.0, (t - 0.82) / 0.18) - value = int(125 * top * top + 28 * bottom) - pixels[0, y] = min(170, value) - alpha = alpha.resize(size) - overlay.putalpha(alpha) - return Image.alpha_composite(image.convert("RGBA"), overlay) - - -def fitted_font(font_path: Path, text: str, max_width: int, start_size: int) -> ImageFont.FreeTypeFont: - size = start_size - probe = Image.new("L", (8, 8)) - draw = __import__("PIL.ImageDraw", fromlist=["ImageDraw"]).Draw(probe) - while size >= 36: - font = ImageFont.truetype(str(font_path), size) - box = draw.textbbox((0, 0), text, font=font, stroke_width=max(1, size // 70)) - if box[2] - box[0] <= max_width: - return font - size -= 2 - return ImageFont.truetype(str(font_path), size) - - -def centered_text( - image: Image.Image, - text: str, - y: int, - font_path: Path, - font_size: int, - max_width: int, - fill: tuple[int, int, int, int], - stroke: int, - shadow_blur: int, - shadow_offset: int, -) -> int: - from PIL import ImageDraw - - font = fitted_font(font_path, text, max_width, font_size) - probe = ImageDraw.Draw(image) - box = probe.textbbox((0, 0), text, font=font, stroke_width=stroke) - width = box[2] - box[0] - height = box[3] - box[1] - x = (image.width - width) // 2 - box[0] - baseline_y = y - box[1] - - shadow_layer = Image.new("RGBA", image.size, (0, 0, 0, 0)) - sd = ImageDraw.Draw(shadow_layer) - sd.text( - (x + shadow_offset, baseline_y + shadow_offset), text, font=font, - fill=(0, 0, 0, 235), stroke_width=stroke + 2, stroke_fill=(0, 0, 0, 245), - ) - shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(shadow_blur)) - image.alpha_composite(shadow_layer) - - draw = ImageDraw.Draw(image) - draw.text( - (x, baseline_y), text, font=font, fill=fill, - stroke_width=stroke, stroke_fill=(8, 8, 8, 255), - ) - return y + height - - -def build_cover(source: Image.Image, size: tuple[int, int], main: str, sub: str, title_font: Path, subtitle_font: Path) -> Image.Image: - image = fit_background(source, size) - width, height = size - main_top = int(height * 0.055) - main_bottom = centered_text( - image, main, main_top, title_font, int(height * 0.155), int(width * 0.92), - (255, 205, 0, 255), max(3, width // 360), max(6, width // 150), max(6, width // 180), - ) - sub_top = main_bottom + int(height * 0.025) - centered_text( - image, sub, sub_top, subtitle_font, int(height * 0.075), int(width * 0.70), - (248, 248, 248, 255), max(2, width // 500), max(5, width // 210), max(4, width // 240), - ) - return image.convert("RGB") - - -def choose_video(folder: Path) -> Path: - videos = sorted(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in VIDEO_EXTS) - if not videos: - raise FileNotFoundError(f"no source video: {folder}") - return max(videos, key=lambda p: p.stat().st_size) - - -def main() -> int: - args = parse_args() - folders = sorted((p for p in args.root.iterdir() if p.is_dir()), key=lambda p: p.name) - if args.limit: - folders = folders[: args.limit] - - completed = 0 - skipped = 0 - failed: list[tuple[str, str]] = [] - for index, folder in enumerate(folders, 1): - portrait = folder / "封面-3比4.png" - landscape = folder / "封面-4比3.png" - if not args.force and portrait.exists() and landscape.exists(): - print(f"[{index}/{len(folders)}] SKIP {folder.name}") - skipped += 1 - continue - try: - main_text, sub_text = cover_text(folder) - if len(main_text) != 6 or len(sub_text) != 8: - raise ValueError(f"title lengths are {len(main_text)}+{len(sub_text)}, expected 6+8") - cached_background = args.background_dir / f"{folder.name}.png" if args.background_dir else None - if cached_background and cached_background.exists(): - with Image.open(cached_background) as image: - source = image.convert("RGB").copy() - elif args.background_override and folder.name == args.background_override_dir: - with Image.open(args.background_override) as image: - source = image.convert("RGB").copy() - else: - video = choose_video(folder) - with tempfile.TemporaryDirectory(prefix="autoyy-cover-") as temp: - source = extract_best_frame(args.ffmpeg, video, Path(temp)) - - p_img = build_cover(source, (1200, 1600), main_text, sub_text, args.title_font, args.subtitle_font) - l_img = build_cover(source, (1600, 1200), main_text, sub_text, args.title_font, args.subtitle_font) - p_img.save(portrait, format="PNG", optimize=True) - l_img.save(landscape, format="PNG", optimize=True) - completed += 1 - print(f"[{index}/{len(folders)}] OK {folder.name} | {main_text} / {sub_text}") - except Exception as exc: # keep the batch resumable - failed.append((folder.name, str(exc))) - print(f"[{index}/{len(folders)}] FAIL {folder.name}: {exc}", file=sys.stderr) - - print(f"completed={completed} skipped={skipped} failed={len(failed)}") - for name, reason in failed: - print(f"FAILED\t{name}\t{reason}") - return 1 if failed else 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/download_from_manifest.ps1 b/scripts/download_from_manifest.ps1 deleted file mode 100644 index 47bfd6f..0000000 --- a/scripts/download_from_manifest.ps1 +++ /dev/null @@ -1,209 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] - [string]$Manifest, - [Parameter(Mandatory = $true)] - [string]$OutputRoot, - [string]$YtDlp = "yt-dlp", - [string]$FfmpegLocation, - [string]$CookiesFromBrowser, - [string]$Proxy, - [ValidateRange(144, 4320)] - [int]$MinHeight = 720, - [ValidateRange(144, 4320)] - [int]$MaxHeight = 1080, - [switch]$PlanOnly -) - -$ErrorActionPreference = "Stop" - -function Add-OptionalArgument { - param( - [System.Collections.Generic.List[string]]$Arguments, - [string]$Name, - [string]$Value - ) - if (-not [string]::IsNullOrWhiteSpace($Value)) { - $Arguments.Add($Name) - $Arguments.Add($Value) - } -} - -function Invoke-YtDlp { - param([string[]]$Arguments) - & $YtDlp @Arguments - if ($LASTEXITCODE -ne 0) { - throw "yt-dlp failed with exit code $LASTEXITCODE" - } -} - -function Get-SubtitleLanguage { - param([string]$Url) - - $arguments = [System.Collections.Generic.List[string]]::new() - @("--dump-single-json", "--skip-download", "--no-playlist", "--no-warnings", $Url) | - ForEach-Object { $arguments.Add($_) } - Add-OptionalArgument $arguments "--cookies-from-browser" $CookiesFromBrowser - Add-OptionalArgument $arguments "--proxy" $Proxy - - $jsonText = (& $YtDlp @arguments) -join "`n" - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($jsonText)) { - return $null - } - try { - $metadata = $jsonText | ConvertFrom-Json - } catch { - return $null - } - - $manual = @() - $automatic = @() - if ($metadata.subtitles) { - $manual = @($metadata.subtitles.PSObject.Properties.Name | Where-Object { $_ -ne "live_chat" }) - } - if ($metadata.automatic_captions) { - $automatic = @($metadata.automatic_captions.PSObject.Properties.Name | Where-Object { $_ -ne "live_chat" }) - } - - $groups = @( - @($manual | Where-Object { $_ -eq "en" }), - @($manual | Where-Object { $_ -like "en*" }), - @($automatic | Where-Object { $_ -eq "en" }), - @($automatic | Where-Object { $_ -like "en*" }), - @($manual | Where-Object { $_ -like "zh*" }), - @($automatic | Where-Object { $_ -like "zh*" }), - $manual, - $automatic - ) - foreach ($group in $groups) { - $candidate = $group | Select-Object -First 1 - if ($candidate) { - return $candidate - } - } - return $null -} - -$manifestPath = (Resolve-Path -LiteralPath $Manifest).Path -$rows = @(Import-Csv -LiteralPath $manifestPath -Encoding UTF8) -if ($rows.Count -eq 0) { - throw "Manifest contains no data rows: $manifestPath" -} - -New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null -$logPath = Join-Path $OutputRoot "下载状态.csv" -$statusRows = [System.Collections.Generic.List[object]]::new() - -foreach ($row in $rows) { - $folderName = [string]$row.folder_name - $url = [string]$row.url - if ([string]::IsNullOrWhiteSpace($folderName) -or [string]::IsNullOrWhiteSpace($url)) { - $statusRows.Add([pscustomobject]@{ - folder_name = $folderName - url = $url - video = $false - subtitle = $false - subtitle_language = "" - status = "invalid manifest row" - }) - continue - } - if ($url -notmatch '^https://(www\.)?(youtube\.com/watch\?|youtu\.be/)') { - $statusRows.Add([pscustomobject]@{ - folder_name = $folderName - url = $url - video = $false - subtitle = $false - subtitle_language = "" - status = "unsupported URL" - }) - continue - } - - $topicDir = Join-Path $OutputRoot $folderName - if ($PlanOnly) { - Write-Output "PLAN`t$folderName`t$url`t$topicDir" - continue - } - - New-Item -ItemType Directory -Path $topicDir -Force | Out-Null - $existingVideo = Get-ChildItem -LiteralPath $topicDir -File -ErrorAction SilentlyContinue | - Where-Object { $_.BaseName -eq "高清源视频" -and $_.Extension -in @(".mp4", ".mkv", ".webm") } | - Select-Object -First 1 - $status = "complete" - $subtitleLanguage = [string]$row.subtitle_language - - try { - if (-not $existingVideo) { - $arguments = [System.Collections.Generic.List[string]]::new() - @( - "--continue", "--no-overwrites", "--no-playlist", - "--retries", "10", "--fragment-retries", "10", - "--concurrent-fragments", "4", "--embed-metadata", - "--merge-output-format", "mp4", "--remux-video", "mp4", - "-f", "bestvideo[height<=$MaxHeight][height>=$MinHeight]+bestaudio/best[height<=$MaxHeight][height>=$MinHeight]", - "-o", (Join-Path $topicDir "高清源视频.%(ext)s"), $url - ) | ForEach-Object { $arguments.Add($_) } - Add-OptionalArgument $arguments "--ffmpeg-location" $FfmpegLocation - Add-OptionalArgument $arguments "--cookies-from-browser" $CookiesFromBrowser - Add-OptionalArgument $arguments "--proxy" $Proxy - Invoke-YtDlp $arguments.ToArray() - } - - $existingSubtitle = Get-ChildItem -LiteralPath $topicDir -File -Filter "*.srt" -ErrorAction SilentlyContinue | - Select-Object -First 1 - if (-not $existingSubtitle) { - if ([string]::IsNullOrWhiteSpace($subtitleLanguage)) { - $subtitleLanguage = Get-SubtitleLanguage $url - } - if (-not [string]::IsNullOrWhiteSpace($subtitleLanguage)) { - $arguments = [System.Collections.Generic.List[string]]::new() - @( - "--skip-download", "--no-playlist", "--write-subs", "--write-auto-subs", - "--sub-langs", $subtitleLanguage, "--convert-subs", "srt", - "-o", (Join-Path $topicDir "字幕.%(ext)s"), $url - ) | ForEach-Object { $arguments.Add($_) } - Add-OptionalArgument $arguments "--ffmpeg-location" $FfmpegLocation - Add-OptionalArgument $arguments "--cookies-from-browser" $CookiesFromBrowser - Add-OptionalArgument $arguments "--proxy" $Proxy - Invoke-YtDlp $arguments.ToArray() - - $generated = Get-ChildItem -LiteralPath $topicDir -File -Filter "字幕*.srt" -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | - Select-Object -First 1 - if ($generated -and $generated.Name -ne "字幕.srt") { - Move-Item -LiteralPath $generated.FullName -Destination (Join-Path $topicDir "字幕.srt") -Force - } - } - } - } catch { - $status = $_.Exception.Message - } - - $videoCheck = Get-ChildItem -LiteralPath $topicDir -File -ErrorAction SilentlyContinue | - Where-Object { $_.BaseName -eq "高清源视频" -and $_.Extension -in @(".mp4", ".mkv", ".webm") } | - Select-Object -First 1 - $subtitleCheck = Get-ChildItem -LiteralPath $topicDir -File -Filter "*.srt" -ErrorAction SilentlyContinue | - Select-Object -First 1 - if ((-not $videoCheck -or -not $subtitleCheck) -and $status -eq "complete") { - $status = "incomplete" - } - - $statusRows.Add([pscustomobject]@{ - folder_name = $folderName - url = $url - video = [bool]$videoCheck - subtitle = [bool]$subtitleCheck - subtitle_language = $subtitleLanguage - status = $status - }) - $statusRows | Export-Csv -LiteralPath $logPath -Encoding UTF8 -NoTypeInformation -} - -if ($PlanOnly) { - Write-Output "Planned $($rows.Count) topic(s). No files downloaded." -} else { - $complete = @($statusRows | Where-Object { $_.video -and $_.subtitle }).Count - Write-Output "Completed $complete/$($statusRows.Count) topic(s). Status: $logPath" -} - diff --git a/scripts/gen_jimeng_cover_prompts.py b/scripts/gen_jimeng_cover_prompts.py deleted file mode 100644 index d6f5bb8..0000000 --- a/scripts/gen_jimeng_cover_prompts.py +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""为每个选题目录生成《封面提示词-即梦.txt》(两段纯提示词:3:4竖版 + 4:3横版)。 - -用法: - python scripts/gen_jimeng_cover_prompts.py [--csv ] [--dry-run] - -CSV 列(表头): 目录名,主标题,副标题,竖版画面,横版画面 - - 目录名: 与 root 下子目录名精确匹配,决定提示词文件写到哪个目录 - - 主标题: 6 字(金黄色粗毛笔大字) - - 副标题: 8 字(白色毛笔字) - - 竖版画面 / 横版画面: 该比例的画面描述(不含文字排版部分,脚本自动拼接) - -CSV 模板: assets/jimeng-cover-template.csv -输出: 每个匹配目录下 封面提示词-即梦.txt,只含两段提示词,段落间空一行。 -""" -import argparse -import csv -import os -import sys - -HEAD_3X4 = "电影级写实纪录片封面,3:4竖版构图。画面:" -HEAD_4X3 = "电影级写实纪录片封面,4:3横版构图。画面:" - -TEXT_3X4 = ( - "画面正上方约三分之一处居中排列两行书法大字,文字醒目、笔画清晰、无变形无错字:" - "第一行主标题「{m}」——特大号金黄色粗毛笔书法字,刚劲有力,黑色细描边,柔和黑色投影,几乎横贯版面宽度;" - "第二行副标题「{s}」——中号白色毛笔书法字,与主标题同风格描边和投影,宽度约为主标题三分之二,紧贴主标题下方居中。" - "文字有安全边距,笔画不裁切。除这两行文字外画面无其他文字、无英文、无logo、无水印。" - "背景高清写实、细节丰富、景深自然,纪录片封面质感,主体突出,构图平衡,色彩高级。" -) - -TEXT_4X3 = ( - "画面上部居中排列两行书法大字,文字醒目、笔画清晰、无变形无错字:" - "第一行主标题「{m}」——特大号金黄色粗毛笔书法字,刚劲有力,黑色细描边,柔和黑色投影,几乎横贯版面宽度;" - "第二行副标题「{s}」——中号白色毛笔书法字,与主标题同风格描边和投影,宽度约为主标题三分之二,紧贴主标题下方居中。" - "文字有安全边距,笔画不裁切。除这两行文字外画面无其他文字、无英文、无logo、无水印。" - "背景高清写实、细节丰富、景深自然,纪录片封面质感,主体突出,构图平衡,色彩高级。" -) - - -def build_3x4(main: str, sub: str, scene: str) -> str: - return HEAD_3X4 + scene + TEXT_3X4.format(m=main, s=sub) - - -def build_4x3(main: str, sub: str, scene: str) -> str: - return HEAD_4X3 + scene + TEXT_4X3.format(m=main, s=sub) - - -def main() -> int: - ap = argparse.ArgumentParser(description="生成即梦封面提示词文件") - ap.add_argument("root", help="选题根目录(含各 / 子目录)") - ap.add_argument("--csv", default="jimeng-cover-input.csv", - help="输入 CSV 路径(默认 ./jimeng-cover-input.csv)") - ap.add_argument("--dry-run", action="store_true", help="只打印将生成的配对,不写文件") - args = ap.parse_args() - - if not os.path.isdir(args.root): - print(f"错误: root 目录不存在: {args.root}", file=sys.stderr) - return 1 - if not os.path.isfile(args.csv): - print(f"错误: CSV 不存在: {args.csv}", file=sys.stderr) - return 1 - - with open(args.csv, encoding="utf-8-sig", newline="") as f: - rows = list(csv.DictReader(f)) - if not rows: - print("错误: CSV 无数据行", file=sys.stderr) - return 1 - - done, skipped, missing = 0, 0, [] - for i, row in enumerate(rows, start=2): - name = (row.get("目录名") or "").strip() - main6 = (row.get("主标题") or "").strip() - sub8 = (row.get("副标题") or "").strip() - scene_v = (row.get("竖版画面") or "").strip() - scene_h = (row.get("横版画面") or "").strip() - if not (name and main6 and sub8 and scene_v and scene_h): - print(f"跳过 第{i}行: 字段不全", file=sys.stderr) - skipped += 1 - continue - if len(main6) != 6: - print(f"警告 第{i}行「{name}」主标题应为6字, 实际{len(main6)}字: {main6}", file=sys.stderr) - if len(sub8) != 8: - print(f"警告 第{i}行「{name}」副标题应为8字, 实际{len(sub8)}字: {sub8}", file=sys.stderr) - - target = os.path.join(args.root, name) - if not os.path.isdir(target): - missing.append(name) - print(f"跳过 第{i}行: 目录不存在 {target}", file=sys.stderr) - skipped += 1 - continue - - # 已有封面 → 跳过。自主判断:目录中图片文件(png/jpg/jpeg/webp/bmp/gif)数量 >= 2 - # 即视为已有两张封面(文件名可能为 封面-3比4 / 封面-4比3 / 封面 / cover 等任意命名) - import glob as _glob - _IMG_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif") - cover_files = [p for p in _glob.glob(os.path.join(target, "*")) - if os.path.splitext(p)[1].lower() in _IMG_EXTS] - if len(cover_files) >= 2: - names = ", ".join(os.path.basename(p) for p in sorted(cover_files)) - print(f"跳过 第{i}行: 已有 {len(cover_files)} 张封面图 ({names}) → {os.path.basename(target)}", file=sys.stderr) - skipped += 1 - continue - - content = build_3x4(main6, sub8, scene_v) + "\n\n" + build_4x3(main6, sub8, scene_h) - out = os.path.join(target, "封面提示词-即梦.txt") - if args.dry_run: - print(f"[dry-run] 将写入 {out} ({len(content)}字符)") - else: - with open(out, "w", encoding="utf-8") as f: - f.write(content) - print(f"已写入 {out} ({len(content)}字符)") - done += 1 - - print(f"\n完成: 生成 {done} 个, 跳过 {skipped} 个") - if missing: - print(f"未找到目录: {missing}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/validate_deliverables.py b/scripts/validate_deliverables.py deleted file mode 100644 index b29fd24..0000000 --- a/scripts/validate_deliverables.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import re -import struct -import sys -from pathlib import Path - - -VIDEO_EXTENSIONS = {".mp4", ".mkv", ".webm"} -IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg"} -BANNED_PATTERNS = [ - r"【开场钩子】", - r"【核心悬念】", - r"【推进", - r"【结尾升华】", - r"【补充叙事】", - r"镜头切到", - r"画面来到", - r"这段视频最容易", - r"回看整条因果链", - r"前面三条线索共同指向", - r"点赞", - r"关注我", - r"转发给", -] - - -def image_size(path: Path) -> tuple[int, int] | None: - with path.open("rb") as handle: - head = handle.read(32) - if head.startswith(b"\x89PNG\r\n\x1a\n") and len(head) >= 24: - return struct.unpack(">II", head[16:24]) - if head[:2] != b"\xff\xd8": - return None - handle.seek(2) - while True: - marker_start = handle.read(1) - if not marker_start: - return None - if marker_start != b"\xff": - continue - marker = handle.read(1) - while marker == b"\xff": - marker = handle.read(1) - if marker in {b"\xd8", b"\xd9"}: - continue - length_data = handle.read(2) - if len(length_data) != 2: - return None - length = struct.unpack(">H", length_data)[0] - if marker and marker[0] in { - 0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, - 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF, - }: - payload = handle.read(5) - if len(payload) != 5: - return None - height, width = struct.unpack(">HH", payload[1:5]) - return width, height - handle.seek(length - 2, 1) - - -def find_cover(folder: Path, stem: str) -> Path | None: - for extension in IMAGE_EXTENSIONS: - candidate = folder / f"{stem}{extension}" - if candidate.exists(): - return candidate - return None - - -def duplicate_paragraphs(text: str) -> list[str]: - paragraphs = [ - re.sub(r"\s+", " ", item.strip()) - for item in re.split(r"\r?\n\s*\r?\n", text) - if item.strip() - ] - seen: set[str] = set() - duplicates: list[str] = [] - for paragraph in paragraphs: - if len(paragraph) >= 50 and paragraph in seen: - duplicates.append(paragraph[:80]) - seen.add(paragraph) - return duplicates - - -def validate_topic(folder: Path, min_script_chars: int, max_script_chars: int) -> dict: - issues: list[str] = [] - videos = [ - item for item in folder.iterdir() - if item.is_file() - and item.stem == "高清源视频" - and item.suffix.lower() in VIDEO_EXTENSIONS - and item.stat().st_size > 0 - ] - subtitles = [ - item for item in folder.glob("*.srt") - if item.is_file() and item.stat().st_size > 0 - ] - if len(videos) != 1: - issues.append(f"primary video count is {len(videos)}, expected 1") - if not subtitles: - issues.append("subtitle SRT missing") - - publish_path = folder / "发布信息.txt" - script_path = folder / "爆款口播稿.txt" - superseded_scripts = sorted( - item.name - for item in folder.iterdir() - if item.is_file() - and ( - item.name == "爆款钩子文案.txt" - or ( - item.name.startswith("爆款口播稿-") - and item.suffix.lower() == ".txt" - ) - ) - ) - if superseded_scripts: - issues.append( - "superseded voiceover files remain: " - + ", ".join(superseded_scripts) - ) - if not publish_path.exists() or publish_path.stat().st_size == 0: - issues.append("发布信息.txt missing or empty") - else: - publish_text = publish_path.read_text(encoding="utf-8-sig", errors="replace") - lines = [line.strip() for line in publish_text.splitlines() if line.strip()] - if len(lines) != 2: - issues.append( - f"publication non-empty line count is {len(lines)}, expected 2" - ) - if lines: - title = lines[0] - if len(title) > 25: - issues.append( - f"publication title length is {len(title)}, expected at most 25" - ) - if re.match(r"^(?:爆款)?标题[::]", title): - issues.append("publication title must not include a field label") - if len(lines) >= 2: - tag_tokens = lines[1].split() - valid_tags = [ - token for token in tag_tokens - if re.fullmatch(r"#[^\s#]+", token) - ] - if len(tag_tokens) != 5 or len(valid_tags) != 5: - issues.append( - f"hashtag count is {len(valid_tags)}, expected exactly 5 " - "space-separated hashtags on line 2" - ) - else: - issues.append("publication hashtag line missing") - - script_chars = 0 - if not script_path.exists() or script_path.stat().st_size == 0: - issues.append("爆款口播稿.txt missing or empty") - else: - script_text = script_path.read_text(encoding="utf-8-sig", errors="replace") - script_chars = len(re.sub(r"\s+", "", script_text)) - if script_chars < min_script_chars or script_chars > max_script_chars: - issues.append( - f"script non-whitespace characters {script_chars}, " - f"expected {min_script_chars}-{max_script_chars}" - ) - matches = [ - pattern for pattern in BANNED_PATTERNS - if re.search(pattern, script_text) - ] - if matches: - issues.append("banned voiceover patterns: " + ", ".join(matches)) - duplicates = duplicate_paragraphs(script_text) - if duplicates: - issues.append(f"duplicate long paragraphs: {len(duplicates)}") - - cover_results = {} - for stem, expected_ratio in (("封面-3比4", 3 / 4), ("封面-4比3", 4 / 3)): - cover = find_cover(folder, stem) - if cover is None or cover.stat().st_size == 0: - issues.append(f"{stem} missing or empty") - cover_results[stem] = None - continue - size = image_size(cover) - if size is None: - issues.append(f"{cover.name} dimensions unreadable") - cover_results[stem] = None - continue - width, height = size - ratio = width / height - ok = abs(ratio - expected_ratio) <= 0.01 - if not ok: - issues.append( - f"{cover.name} ratio {ratio:.4f}, expected {expected_ratio:.4f}" - ) - cover_results[stem] = { - "file": cover.name, - "width": width, - "height": height, - "ratio_ok": ok, - } - - return { - "folder": folder.name, - "complete": not issues, - "issues": issues, - "script_chars": script_chars, - "video_count": len(videos), - "subtitle_count": len(subtitles), - "covers": cover_results, - } - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Validate documentary topic deliverable folders." - ) - parser.add_argument("output_root", type=Path) - parser.add_argument("--min-script-chars", type=int, default=4500) - parser.add_argument("--max-script-chars", type=int, default=5500) - parser.add_argument("--json-out", type=Path) - args = parser.parse_args() - - root = args.output_root.resolve() - if not root.is_dir(): - print(f"Output root does not exist: {root}", file=sys.stderr) - return 2 - - folders = sorted( - item for item in root.iterdir() - if item.is_dir() and re.match(r"^\d{2}-", item.name) - ) - results = [ - validate_topic(folder, args.min_script_chars, args.max_script_chars) - for folder in folders - ] - summary = { - "output_root": str(root), - "topic_count": len(results), - "complete_count": sum(item["complete"] for item in results), - "incomplete_count": sum(not item["complete"] for item in results), - "results": results, - } - - rendered = json.dumps(summary, ensure_ascii=False, indent=2) - if args.json_out: - args.json_out.write_text(rendered, encoding="utf-8") - print(rendered) - return 0 if summary["incomplete_count"] == 0 else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/validate_publication_info.py b/scripts/validate_publication_info.py deleted file mode 100644 index 9ae5c0e..0000000 --- a/scripts/validate_publication_info.py +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import re -import sys -from pathlib import Path - - -LEGACY_FIELDS = ( - "爆款标题:", - "匹配标签:", - "发布建议:", - "版权提醒:", - "封面正标题", - "封面副标题", -) -EMOJI_RE = re.compile( - "[\U0001F1E6-\U0001F1FF\U0001F300-\U0001FAFF\u2600-\u27BF]" -) -HASHTAG_RE = re.compile(r"#[^\s#]+") - - -def validate_file(path: Path, root: Path) -> dict: - issues: list[str] = [] - text = path.read_text(encoding="utf-8-sig", errors="replace") - lines = text.splitlines() - - if len(lines) != 2: - issues.append(f"physical line count is {len(lines)}, expected 2") - if any(not line.strip() for line in lines): - issues.append("blank line found") - - title = lines[0].strip() if lines else "" - tag_line = lines[1].strip() if len(lines) >= 2 else "" - - if not title: - issues.append("title missing") - if len(title) > 25: - issues.append(f"title length is {len(title)}, expected at most 25") - if re.match(r"^(?:爆款)?标题[::]", title): - issues.append("title contains a field label") - if EMOJI_RE.search(text): - issues.append("emoji found") - - tag_tokens = tag_line.split() - valid_tags = [token for token in tag_tokens if HASHTAG_RE.fullmatch(token)] - if len(tag_tokens) != 5 or len(valid_tags) != 5: - issues.append( - f"valid hashtag count is {len(valid_tags)}, expected exactly 5" - ) - - found_legacy = [field for field in LEGACY_FIELDS if field in text] - if found_legacy: - issues.append("legacy fields: " + ", ".join(found_legacy)) - - return { - "file": str(path.relative_to(root)), - "valid": not issues, - "title": title, - "title_length": len(title), - "hashtag_count": len(valid_tags), - "issues": issues, - } - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Recursively validate AutoYY publication information files." - ) - parser.add_argument("root", type=Path) - parser.add_argument("--json-out", type=Path) - args = parser.parse_args() - - root = args.root.resolve() - if not root.is_dir(): - print(f"Root does not exist: {root}", file=sys.stderr) - return 2 - - files = sorted(root.rglob("发布信息.txt")) - results = [validate_file(path, root) for path in files] - summary = { - "root": str(root), - "file_count": len(results), - "valid_count": sum(item["valid"] for item in results), - "invalid_count": sum(not item["valid"] for item in results), - "results": results, - } - - rendered = json.dumps(summary, ensure_ascii=False, indent=2) - if args.json_out: - args.json_out.write_text(rendered, encoding="utf-8") - print(rendered) - return 0 if results and summary["invalid_count"] == 0 else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..fb6716f --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,47 @@ +from pathlib import Path + +from autoup import config + + +def test_deep_merge_and_dotted_set(): + base = {"a": {"b": 1}, "x": 1} + result = config._deep_merge(base, {"a": {"c": 2}, "x": 3}) + config._set_dotted(result, "a.d.e", 4) + assert result == {"a": {"b": 1, "c": 2, "d": {"e": 4}}, "x": 3} + + +def test_load_honors_local_config_and_env(tmp_path, monkeypatch): + default = tmp_path / "config.yaml" + local = tmp_path / "local.yaml" + default.write_text( + "paths:\n ffmpeg: ffmpeg\n output_root: output\nllm:\n model: base\n", + encoding="utf-8", + ) + local.write_text("llm:\n model: local\n", encoding="utf-8") + + monkeypatch.setattr(config, "_CONFIG_PATH", default) + monkeypatch.setattr(config, "_LOCAL_CONFIG_PATH", local) + monkeypatch.setattr(config, "_REPO_ROOT", tmp_path) + monkeypatch.setenv("AUTOUP_FFMPEG", "custom-ffmpeg") + monkeypatch.setenv("GLM_API_KEY", "secret") + monkeypatch.delenv("AUTOUP_CONFIG", raising=False) + config.reset_cache() + + loaded = config.load() + assert loaded["llm"]["model"] == "local" + assert loaded["llm"]["api_key"] == "secret" + assert config.ffmpeg() == "custom-ffmpeg" + assert config.output_root() == tmp_path / "output" + + config.reset_cache() + + +def test_read_yaml_rejects_non_mapping(tmp_path): + path = tmp_path / "bad.yaml" + path.write_text("- a\n- b\n", encoding="utf-8") + try: + config._read_yaml(path) + except ValueError as exc: + assert "mapping" in str(exc) + else: + raise AssertionError("expected ValueError") diff --git a/tests/test_llm.py b/tests/test_llm.py new file mode 100644 index 0000000..d7a2ef8 --- /dev/null +++ b/tests/test_llm.py @@ -0,0 +1,80 @@ +import json + +import pytest +import requests + +from autoup import llm + + +class FakeResponse: + def __init__(self, status_code=200, payload=None, text=""): + self.status_code = status_code + self._payload = payload or {} + self.text = text + + def json(self): + return self._payload + + +def test_strip_fence_and_json_extraction(monkeypatch): + assert llm._strip_fence("```json\n{\"a\": 1}\n```") == '{"a": 1}' + monkeypatch.setattr(llm, "chat", lambda *args, **kwargs: 'prefix {"x": 2} suffix') + assert llm.chat_json("p") == {"x": 2} + + +def test_chat_success(monkeypatch): + settings = { + "llm.api_key": "k", + "llm.base_url": "https://example.test/v1", + "llm.model": "model", + "llm.temperature": 0.2, + "llm.max_tokens": 100, + "llm.max_retries": 1, + } + monkeypatch.setattr(llm.config, "get", lambda key, default=None: settings.get(key, default)) + monkeypatch.setattr( + llm._SESSION, + "post", + lambda *args, **kwargs: FakeResponse( + payload={"choices": [{"message": {"content": "ok"}}]} + ), + ) + assert llm.chat("hello") == "ok" + + +def test_chat_fails_fast_on_client_error(monkeypatch): + settings = { + "llm.api_key": "k", + "llm.base_url": "https://example.test/v1", + "llm.model": "model", + "llm.temperature": 0.2, + "llm.max_tokens": 100, + "llm.max_retries": 3, + } + monkeypatch.setattr(llm.config, "get", lambda key, default=None: settings.get(key, default)) + monkeypatch.setattr( + llm._SESSION, + "post", + lambda *args, **kwargs: FakeResponse(status_code=400, text="bad request"), + ) + with pytest.raises(RuntimeError, match="HTTP 400"): + llm.chat("hello") + + +def test_chat_handles_request_exception(monkeypatch): + settings = { + "llm.api_key": "k", + "llm.base_url": "https://example.test/v1", + "llm.model": "model", + "llm.temperature": 0.2, + "llm.max_tokens": 100, + "llm.max_retries": 1, + } + monkeypatch.setattr(llm.config, "get", lambda key, default=None: settings.get(key, default)) + + def boom(*args, **kwargs): + raise requests.RequestException("network") + + monkeypatch.setattr(llm._SESSION, "post", boom) + with pytest.raises(RuntimeError, match="network"): + llm.chat("hello") diff --git a/tests/test_run_state.py b/tests/test_run_state.py new file mode 100644 index 0000000..ddc88d4 --- /dev/null +++ b/tests/test_run_state.py @@ -0,0 +1,68 @@ +from pathlib import Path + +from autoup import run, state, utils + + +def test_parse_override(): + assert run._parse_override("true") is True + assert run._parse_override("null") is None + assert run._parse_override("12") == 12 + assert run._parse_override("1.5") == 1.5 + assert run._parse_override("text") == "text" + + +def test_stage_fingerprint_changes_when_input_changes(tmp_path, monkeypatch): + subtitle_dir = tmp_path / "字幕" + subtitle_dir.mkdir() + subtitle = subtitle_dir / "字幕.srt" + subtitle.write_text("one", encoding="utf-8") + + monkeypatch.setattr( + state.config, + "get", + lambda key, default=None: { + "script": {"target_chars": 100}, + "llm.base_url": "x", + "llm.model": "m", + "llm.temperature": 0.1, + "llm.max_retries": 1, + "llm.max_tokens": 100, + }.get(key, default), + ) + first = state.stage_fingerprint(tmp_path, "s3") + subtitle.write_text("two", encoding="utf-8") + second = state.stage_fingerprint(tmp_path, "s3") + assert first != second + + +def test_stage_done_checks_fingerprint(tmp_path, monkeypatch): + script_dir = tmp_path / "文案" + script_dir.mkdir() + (script_dir / "爆款口播稿.txt").write_text("ok", encoding="utf-8") + state_data = {"stages": {"s3": {"status": "done", "fingerprint": "same"}}} + monkeypatch.setattr(run.stage_state, "stage_fingerprint", lambda *args, **kwargs: "same") + assert run._stage_done(tmp_path, state_data, "s3", False) + monkeypatch.setattr(run.stage_state, "stage_fingerprint", lambda *args, **kwargs: "changed") + assert not run._stage_done(tmp_path, state_data, "s3", False) + + +def test_process_topic_only_does_not_force_validation(tmp_path, monkeypatch): + called = [] + monkeypatch.setattr(run, "_load_state", lambda path: {}) + monkeypatch.setattr(run, "_stage_done", lambda *args, **kwargs: False) + monkeypatch.setattr(run, "_mark", lambda *args, **kwargs: None) + monkeypatch.setattr(run, "_record_metric", lambda *args, **kwargs: None) + monkeypatch.setattr(run.script, "run", lambda path: called.append("s3")) + monkeypatch.setattr(run.validate, "run", lambda path: (_ for _ in ()).throw(AssertionError("S9 should not run"))) + result = run.process_topic(tmp_path, ["s3"], False, "default") + assert called == ["s3"] + assert result["partial"] is True + + +def test_mark_persists_structured_stage(tmp_path, monkeypatch): + monkeypatch.setattr(run.stage_state, "stage_fingerprint", lambda *args, **kwargs: "fp") + data = {} + run._mark(tmp_path, data, "s3") + saved = utils.read_json(tmp_path / "state.json") + assert saved["stages"]["s3"]["status"] == "done" + assert saved["stages"]["s3"]["fingerprint"] == "fp" diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..741f20d --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,20 @@ +from autoup import __version__ +from autoup import config, llm, run, state, utils +from autoup.stages import cover, download, dub, match, publish, render, script, validate + + +def test_import_surface(): + assert __version__ + assert run.STAGES == ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9"] + assert callable(config.get) + assert callable(llm.chat_json) + assert callable(state.stage_fingerprint) + assert callable(utils.stable_hash) + assert callable(download.read_manifest) + assert callable(script.run) + assert callable(dub.run) + assert callable(match.run) + assert callable(render.plan_timeline) + assert callable(publish.run) + assert callable(cover.run) + assert callable(validate.run) diff --git a/tests/test_stages.py b/tests/test_stages.py new file mode 100644 index 0000000..d315e38 --- /dev/null +++ b/tests/test_stages.py @@ -0,0 +1,229 @@ +from pathlib import Path + +import pytest +from PIL import Image + +from autoup.stages import cover, download, dub, match, publish, render, script + + +def test_download_language_manifest_and_gate(tmp_path, monkeypatch): + assert download._choose_english_language({"en-GB", "fr"}, {"en"}) == ("en-GB", "manual") + assert download._choose_english_language(set(), {"en-US"}) == ("en-US", "auto") + assert download._choose_english_language({"fr"}, {"de"}) == ("", "none") + + manifest = tmp_path / "urls.txt" + manifest.write_text( + "# comment\nhttps://example.test/a\nhttps://example.test/a\nhttps://example.test/b\n", + encoding="utf-8", + ) + assert download.read_manifest(manifest) == [ + "https://example.test/a", + "https://example.test/b", + ] + + monkeypatch.setattr( + download, + "fetch_meta", + lambda url: { + "ok": True, + "url": url, + "duration": 2000, + "height": 1080, + "sub_kind": "manual", + }, + ) + monkeypatch.setattr( + download.config, + "get", + lambda key, default=None: { + "download.min_duration": 1800, + "download.min_height": 720, + }.get(key, default), + ) + assert download.verify_url("u")["verdict"] == "approve" + + +def test_script_run_uses_all_source_windows(tmp_path, monkeypatch): + subtitle_dir = tmp_path / "字幕" + subtitle_dir.mkdir() + rows = [] + for index in range(1, 9): + start = (index - 1) * 10 + rows.append( + f"{index}\n00:00:{start:02d},000 --> 00:00:{start + 5:02d},000\nsource fact {index}\n" + ) + (subtitle_dir / "字幕.srt").write_text("\n".join(rows), encoding="utf-8") + + settings = { + "script.target_chars": 400, + "script.tolerance": 0.25, + "script.section_chars": 200, + "script.supplement_rounds": 0, + } + monkeypatch.setattr(script.config, "get", lambda key, default=None: settings.get(key, default)) + + prompts = [] + + def fake_chat(prompt, **kwargs): + prompts.append(prompt) + return "真实事实推动故事继续发展" * 17 + "。" + + monkeypatch.setattr(script.llm, "chat", fake_chat) + output = script.run(tmp_path) + + text = output.read_text(encoding="utf-8") + assert 300 <= script._count(text) <= 500 + assert len(prompts) == 2 + assert all("" in prompt and "" in prompt for prompt in prompts) + assert "source fact 1" in prompts[0] + assert "source fact 8" in prompts[-1] + assert (tmp_path / "文案" / "script_map.json").exists() + + +def test_dub_voice_cache_is_input_aware(tmp_path, monkeypatch): + script_dir = tmp_path / "文案" + script_dir.mkdir() + (script_dir / dub.SCRIPT_NAME).write_text("第一句。第二句。", encoding="utf-8") + + settings = { + "voice": {"engine": "edge_tts", "gap_seconds": 0.3}, + "voice.gap_seconds": 0.3, + "voice.gpt_sovits.voices.special.ref_audio_path": "", + } + monkeypatch.setattr(dub.config, "get", lambda key, default=None: settings.get(key, default)) + monkeypatch.setattr(dub.utils, "audio_duration", lambda path: 1.25) + + calls = [] + + def fake_synth(text, path, voice="default"): + calls.append((text, voice)) + path.write_bytes(b"R" * 2048) + return path, "fake" + + monkeypatch.setattr(dub, "tts_synthesize", fake_synth) + + dub.run(tmp_path, voice="special") + assert len(calls) == 2 + assert {voice for _, voice in calls} == {"special"} + + calls.clear() + dub.run(tmp_path, voice="special") + assert calls == [] + + (script_dir / dub.SCRIPT_NAME).write_text("第一句改了。第二句。", encoding="utf-8") + dub.run(tmp_path, voice="special") + assert len(calls) >= 1 + + +def test_match_run_requires_complete_semantic_coverage(tmp_path, monkeypatch): + subtitle_dir = tmp_path / "字幕" + subtitle_dir.mkdir() + blocks = [] + for index in range(1, 7): + start = (index - 1) * 10 + blocks.append( + f"{index}\n00:00:{start:02d},000 --> 00:00:{start + 9:02d},000\nfact {index}\n" + ) + (subtitle_dir / "字幕.srt").write_text("\n".join(blocks), encoding="utf-8") + + timing = { + "sentences": [ + {"index": 1, "text": "甲。", "duration": 2.0, "audio": "0001.wav"}, + {"index": 2, "text": "乙。", "duration": 2.0, "audio": "0002.wav"}, + {"index": 3, "text": "丙。", "duration": 2.0, "audio": "0003.wav"}, + ] + } + (tmp_path / "配音").mkdir() + from autoup import utils + + utils.write_json(tmp_path / "配音" / "timing.json", timing) + + settings = { + "match.batch_sentences": 2, + "match.repair_attempts": 1, + "voice.gap_seconds": 0.3, + } + monkeypatch.setattr(match.config, "get", lambda key, default=None: settings.get(key, default)) + + def fake_call(topic, srt, sentences, total_sentences, repair): + return [ + { + "sentence_index": item["index"], + "start": f"0:00:{(item['index'] - 1) * 10:02d}.000", + "end": f"0:00:{(item['index'] - 1) * 10 + 4:02d}.000", + } + for item in sentences + ] + + monkeypatch.setattr(match, "_call_match", fake_call) + output = match.run(tmp_path) + edit = utils.read_json(output) + assert len(edit["items"]) == 3 + assert edit["uncovered_sentences"] == [] + + +def test_render_plan_and_deterministic_bgm(tmp_path, monkeypatch): + timing = { + "sentences": [ + {"index": 1, "text": "甲", "audio": "1.wav", "duration": 1.0}, + {"index": 2, "text": "乙", "audio": "2.wav", "duration": 1.5}, + ] + } + edit = { + "items": [ + {"sentence_index": 1, "start": 0.0, "end": 2.0}, + {"sentence_index": 2, "start": 3.0, "end": 5.0}, + ] + } + monkeypatch.setattr( + render.config, + "get", + lambda key, default=None: {"voice.gap_seconds": 0.3}.get(key, default), + ) + plan = render.plan_timeline(edit, timing, 20.0) + assert len(plan["cuts"]) == 2 + assert plan["voice"][1]["t_start"] == pytest.approx(2.3) + + bgm_dir = tmp_path / "bgm" + bgm_dir.mkdir() + (bgm_dir / "a.mp3").write_bytes(b"a") + (bgm_dir / "b.mp3").write_bytes(b"b") + values = {"bgm.directory": str(bgm_dir), "bgm.pick": "random"} + monkeypatch.setattr(render.config, "get", lambda key, default=None: values.get(key, default)) + first = render._pick_bgm(Path("topic-A")) + second = render._pick_bgm(Path("topic-A")) + assert first == second + + +def test_publish_validation_and_write(tmp_path, monkeypatch): + valid = { + "cn_title": "真实纪录片标题", + "cn_tags": ["纪录片", "历史", "故事", "人物", "档案"], + "en_title": "A Real Documentary Story", + "en_description": "A factual documentary account. #history #story #archive", + "cover_main": "历史真相浮现", + "cover_sub": "档案揭开尘封往事", + } + assert publish._validate(valid) == [] + invalid = {**valid, "cn_tags": ["重复"] * 5} + assert publish._validate(invalid) + + script_dir = tmp_path / "文案" + script_dir.mkdir() + (script_dir / "爆款口播稿.txt").write_text("真实事实。" * 50, encoding="utf-8") + monkeypatch.setattr(publish.llm, "chat_json", lambda *args, **kwargs: valid.copy()) + monkeypatch.setattr( + publish.config, + "get", + lambda key, default=None: {"publish.repair_attempts": 1}.get(key, default), + ) + output = publish.run(tmp_path) + assert (output / "国内平台.txt").read_text(encoding="utf-8").count("#") == 5 + + +def test_cover_crop_and_text_layer(): + image = Image.new("RGB", (800, 600), "white") + assert cover._crop_ratio(image, 1080, 1920).size == (1080, 1920) + layer = cover._draw_text_layer(600, "历史真相浮现", "档案揭开尘封往事") + assert layer.width > 0 + assert layer.height > 0 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..7b644c5 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,40 @@ +from autoup import utils + + +def test_srt_parse_and_format(tmp_path): + srt = tmp_path / "a.srt" + srt.write_text( + "1\n00:00:01,000 --> 00:00:02,500\nHello world\n\n" + "2\n00:00:03.000 --> 00:00:05.000\nSecond line\n", + encoding="utf-8", + ) + cues = utils.parse_srt(srt) + assert len(cues) == 2 + assert cues[0]["start"] == 1.0 + assert cues[0]["duration"] == 1.5 + assert utils.fmt_ts(65.125) == "0:01:05.125" + + +def test_text_helpers(): + parts = utils.split_sentences("第一句。第二句!第三句没有句号") + assert parts == ["第一句。", "第二句!", "第三句没有句号"] + assert utils.estimate_duration("你好。") > 0 + + +def test_hash_and_file_fingerprint(tmp_path): + assert utils.stable_hash({"b": 2, "a": 1}) == utils.stable_hash({"a": 1, "b": 2}) + path = tmp_path / "data.bin" + path.write_bytes(b"abc" * 100) + first = utils.file_fingerprint(path) + path.write_bytes(b"xyz" * 100) + second = utils.file_fingerprint(path) + assert first["exists"] is True + assert first["sha256"] != second["sha256"] + assert utils.file_fingerprint(tmp_path / "missing") == {"exists": False} + + +def test_json_atomic_roundtrip(tmp_path): + path = tmp_path / "nested" / "data.json" + utils.write_json(path, {"中文": [1, 2, 3]}) + assert utils.read_json(path) == {"中文": [1, 2, 3]} + assert utils.read_json(tmp_path / "none.json", default={"x": 1}) == {"x": 1} diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 index 0000000..9589413 --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,115 @@ +from PIL import Image + +from autoup import utils +from autoup.stages import validate + + +def _write_srt(path, count=12): + blocks = [] + for index in range(1, count + 1): + start = index * 2 + blocks.append( + f"{index}\n00:00:{start:02d},000 --> 00:00:{start + 1:02d},000\nfact {index}\n" + ) + path.write_text("\n".join(blocks), encoding="utf-8") + + +def test_validate_complete_topic(tmp_path, monkeypatch): + material = tmp_path / "素材" + subtitle_dir = tmp_path / "字幕" + script_dir = tmp_path / "文案" + voice_dir = tmp_path / "配音" + publish_dir = tmp_path / "发布" + cover_dir = tmp_path / "封面" + for folder in (material, subtitle_dir, script_dir, voice_dir, publish_dir, cover_dir): + folder.mkdir() + + source = material / "高清源视频.mp4" + source.write_bytes(b"0" * 1_100_000) + _write_srt(subtitle_dir / "字幕.srt") + (script_dir / "爆款口播稿.txt").write_text("真实纪录片故事内容。" * 3, encoding="utf-8") + + for name in ("0001.wav", "0002.wav"): + (voice_dir / name).write_bytes(b"R" * 2048) + utils.write_json( + voice_dir / "timing.json", + { + "total_duration": 4.3, + "sentences": [ + { + "index": 1, + "text": "真实故事。", + "audio": "0001.wav", + "duration": 2.0, + "fingerprint": "a", + }, + { + "index": 2, + "text": "继续讲述。", + "audio": "0002.wav", + "duration": 2.0, + "fingerprint": "b", + }, + ], + }, + ) + utils.write_json( + tmp_path / "edit_decision.json", + { + "items": [ + {"sentence_index": 1, "start": 0.0, "end": 2.0}, + {"sentence_index": 2, "start": 3.0, "end": 5.0}, + ], + "uncovered_sentences": [], + }, + ) + + final = tmp_path / "成片.mp4" + final.write_bytes(b"1" * 1_100_000) + (publish_dir / "国内平台.txt").write_text( + "真实纪录片\n#纪录片 #历史 #故事 #人物 #档案\n", + encoding="utf-8", + ) + (publish_dir / "海外平台.txt").write_text( + "A Real Documentary\n\nA factual account. #history #story #archive\n", + encoding="utf-8", + ) + + for name, size in { + "9x16": (1080, 1920), + "16x9": (1920, 1080), + "1x1": (1080, 1080), + }.items(): + path = cover_dir / f"封面-{name}.png" + Image.new("RGB", size, "white").save(path) + with path.open("ab") as handle: + handle.write(b"x" * 60_000) + + settings = { + "script.target_chars": 30, + "script.tolerance": 0.5, + "video.width": 1920, + "video.height": 1080, + } + monkeypatch.setattr(validate.config, "get", lambda key, default=None: settings.get(key, default)) + monkeypatch.setattr( + validate.utils, + "probe_video", + lambda path: { + "width": 1920, + "height": 1080, + "duration": 4.3, + "fps": 30.0, + "has_audio": True, + }, + ) + + report = validate.run(tmp_path) + assert report["passed"] is True + assert report["failed"] == [] + + +def test_validate_rejects_missing_contracts(tmp_path): + report = validate.run(tmp_path) + assert report["passed"] is False + assert "源视频" in report["failed"] From 3944cc278d49071c0472ba4377520fa90debdff2 Mon Sep 17 00:00:00 2001 From: DaBao Date: Sat, 3 Oct 2026 23:31:20 +0800 Subject: [PATCH 05/12] ci: fix lint findings and always publish lockfile --- .github/workflows/ci.yml | 1 + autoup/run.py | 4 ++-- tests/test_config.py | 2 -- tests/test_llm.py | 2 -- tests/test_run_state.py | 2 -- tests/test_smoke.py | 3 +-- 6 files changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f29f78d..86af425 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: - name: Tests run: uv run pytest -q --cov=autoup --cov-report=term-missing --cov-report=xml - name: Upload lockfile + if: always() uses: actions/upload-artifact@v4 with: name: uv-lock diff --git a/autoup/run.py b/autoup/run.py index e4d50ea..89b70fa 100644 --- a/autoup/run.py +++ b/autoup/run.py @@ -4,13 +4,13 @@ import argparse import logging import re -import sys import time import traceback from pathlib import Path from time import perf_counter -from . import config, state as stage_state, utils +from . import config, utils +from . import state as stage_state from .stages import cover, download, dub, match, publish, render, script, validate log = logging.getLogger("autoup.run") diff --git a/tests/test_config.py b/tests/test_config.py index fb6716f..7ba384b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,3 @@ -from pathlib import Path - from autoup import config diff --git a/tests/test_llm.py b/tests/test_llm.py index d7a2ef8..5b0e11f 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -1,5 +1,3 @@ -import json - import pytest import requests diff --git a/tests/test_run_state.py b/tests/test_run_state.py index ddc88d4..1ae4aaf 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -1,5 +1,3 @@ -from pathlib import Path - from autoup import run, state, utils diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 741f20d..cde34d6 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,5 +1,4 @@ -from autoup import __version__ -from autoup import config, llm, run, state, utils +from autoup import __version__, config, llm, run, state, utils from autoup.stages import cover, download, dub, match, publish, render, script, validate From 0fb9122242e27e85fd5c0fadb9b49d03dd2b9876 Mon Sep 17 00:00:00 2001 From: DaBao Date: Sat, 3 Oct 2026 23:34:09 +0800 Subject: [PATCH 06/12] test: cover orchestration download render and TTS branches --- tests/test_pipeline_branches.py | 306 ++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 tests/test_pipeline_branches.py diff --git a/tests/test_pipeline_branches.py b/tests/test_pipeline_branches.py new file mode 100644 index 0000000..077bd35 --- /dev/null +++ b/tests/test_pipeline_branches.py @@ -0,0 +1,306 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yt_dlp + +from autoup import run, state, utils +from autoup.stages import download, render +from autoup import tts + + +def test_download_fetch_meta_and_verify_branches(monkeypatch): + class FakeYDL: + def __init__(self, opts): + self.opts = opts + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def extract_info(self, url, download=False): + return { + "id": "abc", + "title": "Title", + "uploader": "Channel", + "duration": 2000, + "view_count": 123, + "upload_date": "20260101", + "subtitles": {"en-US": [{}]}, + "automatic_captions": {}, + "formats": [{"height": 720}, {"height": 1080}], + } + + monkeypatch.setattr(yt_dlp, "YoutubeDL", FakeYDL) + monkeypatch.setattr(download, "_base_opts", lambda: {}) + meta = download.fetch_meta("https://example.test/video") + assert meta["ok"] is True + assert meta["sub_lang"] == "en-US" + assert meta["height"] == 1080 + + settings = {"download.min_duration": 1800, "download.min_height": 720} + monkeypatch.setattr(download.config, "get", lambda key, default=None: settings.get(key, default)) + + monkeypatch.setattr(download, "fetch_meta", lambda url: {**meta, "duration": 10}) + assert "时长" in download.verify_url("u")["reason"] + monkeypatch.setattr(download, "fetch_meta", lambda url: {**meta, "height": 360}) + assert "最高" in download.verify_url("u")["reason"] + monkeypatch.setattr(download, "fetch_meta", lambda url: {**meta, "sub_kind": "none"}) + assert "字幕" in download.verify_url("u")["reason"] + monkeypatch.setattr(download, "fetch_meta", lambda url: {"ok": False, "url": url}) + assert download.verify_url("u")["verdict"] == "reject" + + +def test_download_verify_batch_and_remux(tmp_path, monkeypatch): + monkeypatch.setattr(download.config, "get", lambda key, default=None: 1 if key == "download.verify_workers" else default) + monkeypatch.setattr( + download, + "verify_url", + lambda url: { + "url": url, + "verdict": "approve" if url.endswith("a") else "reject", + "reason": "x", + }, + ) + approved = download.verify_batch(["a", "b"], tmp_path / "report.csv") + assert [row["url"] for row in approved] == ["a"] + assert (tmp_path / "report.csv").exists() + + source = tmp_path / "video.webm" + source.write_bytes(b"video") + + def fake_run(command, **kwargs): + target = Path(command[-1]) + target.write_bytes(b"mp4") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(download.utils, "run", fake_run) + target = download._remux_to_mp4(source) + assert target.suffix == ".mp4" + assert target.exists() + assert not source.exists() + + +def test_download_topic_with_fake_ytdlp(tmp_path, monkeypatch): + class FakeYDL: + def __init__(self, opts): + self.opts = opts + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def download(self, urls): + material = tmp_path / "素材" + material.mkdir(exist_ok=True) + (material / "高清源视频.mp4").write_bytes(b"video") + (material / "高清源视频.en-US.srt").write_text( + "1\n00:00:00,000 --> 00:00:01,000\nhello\n", + encoding="utf-8", + ) + + monkeypatch.setattr(yt_dlp, "YoutubeDL", FakeYDL) + monkeypatch.setattr(download, "_base_opts", lambda: {}) + monkeypatch.setattr(download.config, "get", lambda key, default=None: 1080 if key == "download.max_height" else default) + + result = download.download_topic("u", tmp_path, sub_lang="en-US") + assert result["video"].name == "高清源视频.mp4" + assert result["subtitle"].read_text(encoding="utf-8").startswith("1") + + +def test_run_batch_existing_and_new(tmp_path, monkeypatch): + monkeypatch.setattr(run.config, "output_root", lambda: tmp_path) + + batch = tmp_path / "existing" + topic = batch / "01-topic" + topic.mkdir(parents=True) + utils.write_json(topic / "state.json", {"url": "u"}) + + downloaded = [] + monkeypatch.setattr(run, "_ensure_downloaded", lambda td, url, state_data, force: downloaded.append((td.name, url))) + monkeypatch.setattr(run, "process_topic", lambda *args, **kwargs: {"passed": True}) + result = run.run_batch(None, "existing", ["s2", "s3"], False, "default", None) + assert result[0]["passed"] is True + assert downloaded == [("01-topic", "u")] + + manifest = tmp_path / "manifest.txt" + manifest.write_text("u\n", encoding="utf-8") + monkeypatch.setattr(run.download, "read_manifest", lambda path: ["u"]) + monkeypatch.setattr( + run.download, + "verify_batch", + lambda urls, report: [ + { + "url": "u", + "vid": "v", + "title": "t", + "channel": "c", + "duration": 2000, + "height": 1080, + "sub_kind": "manual", + "sub_lang": "en", + } + ], + ) + monkeypatch.setattr(run, "_topic_name", lambda url, meta, idx: "01-new") + monkeypatch.setattr(run, "_ensure_downloaded", lambda *args, **kwargs: None) + monkeypatch.setattr(run, "process_topic", lambda *args, **kwargs: {"passed": True}) + result = run.run_batch(manifest, "new", None, False, "default", 1) + assert result == [{"topic": "01-new", "url": "u", "passed": True}] + + +def test_run_helpers_and_topic_stage(tmp_path, monkeypatch): + voice_dir = tmp_path / "配音" + voice_dir.mkdir() + audio = voice_dir / "0001.wav" + audio.write_bytes(b"x" * 2048) + utils.write_json( + voice_dir / "timing.json", + {"sentences": [{"index": 1, "audio": "0001.wav"}]}, + ) + assert run._timing_artifacts_ok(tmp_path) + + monkeypatch.setattr(run, "_stage_done", lambda *args, **kwargs: False) + monkeypatch.setattr(run, "_record_metric", lambda *args, **kwargs: None) + marks = [] + monkeypatch.setattr(run, "_mark", lambda *args, **kwargs: marks.append(args[2:4])) + run._run_topic_stage( + tmp_path, + {}, + "s3", + lambda: None, + force=False, + voice="default", + ) + assert marks + + monkeypatch.setattr(run, "_mark", lambda *args, **kwargs: None) + with pytest.raises(ValueError): + run._run_topic_stage( + tmp_path, + {}, + "s3", + lambda: (_ for _ in ()).throw(ValueError("bad")), + force=False, + voice="default", + ) + + +def test_state_all_stage_fingerprints(tmp_path, monkeypatch): + for folder in ("素材", "字幕", "文案", "配音", "发布", "封面"): + (tmp_path / folder).mkdir() + (tmp_path / "素材" / "高清源视频.mp4").write_bytes(b"v") + (tmp_path / "字幕" / "字幕.srt").write_text("s", encoding="utf-8") + (tmp_path / "文案" / "爆款口播稿.txt").write_text("p", encoding="utf-8") + utils.write_json(tmp_path / "配音" / "timing.json", {"x": 1}) + utils.write_json(tmp_path / "edit_decision.json", {"x": 1}) + (tmp_path / "成片.mp4").write_bytes(b"f") + (tmp_path / "发布" / "国内平台.txt").write_text("c", encoding="utf-8") + (tmp_path / "发布" / "海外平台.txt").write_text("e", encoding="utf-8") + (tmp_path / "发布" / "封面文案.txt").write_text("x", encoding="utf-8") + for name in ("9x16", "16x9", "1x1"): + (tmp_path / "封面" / f"封面-{name}.png").write_bytes(b"i") + + monkeypatch.setattr(state.config, "get", lambda key, default=None: default) + values = { + stage: state.stage_fingerprint(tmp_path, stage, voice="default", url="u") + for stage in ("s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9", "other") + } + assert len(set(values.values())) == len(values) + + +def test_tts_engine_fallbacks(tmp_path, monkeypatch): + out = tmp_path / "a.wav" + + settings = { + "voice.engine": "gpt_sovits", + "voice.fallback_engine": "edge_tts", + "voice.gpt_sovits.voices": {"special": {"ref_audio_path": "x"}}, + } + monkeypatch.setattr(tts.config, "get", lambda key, default=None: settings.get(key, default)) + monkeypatch.setattr(tts.gpt_sovits, "ensure_server", lambda: False) + + def edge_ok(text, path): + path.write_bytes(b"x") + return True + + monkeypatch.setattr(tts.edge, "synth", edge_ok) + result, engine = tts.synth("hello", out, voice="special") + assert result == out + assert engine == "edge_tts" + + settings["voice.engine"] = "unknown" + settings["voice.fallback_engine"] = "" + result, engine = tts.synth("hello", out, voice="special") + assert result is None + assert engine == "all_engines_failed" + + +def test_render_helpers_and_run(tmp_path, monkeypatch): + workdir = tmp_path / "work" + workdir.mkdir() + source = tmp_path / "source.mp4" + source.write_bytes(b"source") + + settings = { + "video.width": 640, + "video.height": 360, + "video.fps": 30, + "video.crf": 18, + "video.preset": "fast", + "video.prefer_gpu": False, + "voice.gap_seconds": 0.3, + } + monkeypatch.setattr(render.config, "get", lambda key, default=None: settings.get(key, default)) + + def fake_run(command, **kwargs): + target = Path(command[-1]) + if target.suffix: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"x") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(render.utils, "run", fake_run) + segments = render.cut_segments( + source, + [{"src_start": 0.0, "src_end": 2.0}, {"src_start": 3.0, "src_end": 5.0}], + workdir, + ) + assert len(segments) == 2 + assert render.concat_segments(segments, workdir).exists() + + ass = workdir / "subs.ass" + render.build_ass([{"start": 0.0, "end": 2.0, "text": "这是字幕测试。"}], ass) + assert "Dialogue:" in ass.read_text(encoding="utf-8") + + topic = tmp_path / "topic" + material = topic / "素材" + material.mkdir(parents=True) + (material / "高清源视频.mp4").write_bytes(b"source") + (topic / "配音").mkdir() + utils.write_json( + topic / "配音" / "timing.json", + {"sentences": [{"index": 1, "text": "甲", "audio": "1.wav", "duration": 1.0}]}, + ) + utils.write_json( + topic / "edit_decision.json", + {"items": [{"sentence_index": 1, "start": 0.0, "end": 2.0}]}, + ) + + monkeypatch.setattr(render.utils, "probe_video", lambda path: {"duration": 10.0}) + monkeypatch.setattr(render, "cut_segments", lambda *args: [workdir / "seg_0001.mp4"]) + monkeypatch.setattr(render, "concat_segments", lambda *args: workdir / "picture.mp4") + monkeypatch.setattr(render, "build_voice_track", lambda *args: workdir / "voice.wav") + monkeypatch.setattr(render, "build_ass", lambda subs, path: path) + monkeypatch.setattr(render, "_stage_font", lambda path: path / "fonts") + monkeypatch.setattr(render, "_pick_bgm", lambda path: None) + monkeypatch.setattr(render, "_video_codec_args", lambda: ("libx264", ["-crf", "18"])) + settings["bgm.enabled"] = False + + output = render.run(topic) + assert output.exists() + assert (topic / "成片工程" / "render_report.json").exists() From 97bdaee8103997c163be44432b79e8836d82de89 Mon Sep 17 00:00:00 2001 From: DaBao Date: Sat, 3 Oct 2026 23:35:42 +0800 Subject: [PATCH 07/12] test: fix pipeline branch test imports --- tests/test_pipeline_branches.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_pipeline_branches.py b/tests/test_pipeline_branches.py index 077bd35..41ccb4e 100644 --- a/tests/test_pipeline_branches.py +++ b/tests/test_pipeline_branches.py @@ -4,9 +4,8 @@ import pytest import yt_dlp -from autoup import run, state, utils +from autoup import run, state, tts, utils from autoup.stages import download, render -from autoup import tts def test_download_fetch_meta_and_verify_branches(monkeypatch): From 1380918fdfa030b7070e41dfd9c88105e2ae9628 Mon Sep 17 00:00:00 2001 From: DaBao Date: Sat, 3 Oct 2026 23:39:46 +0800 Subject: [PATCH 08/12] test: push coverage above quality gate --- tests/test_pipeline_branches.py | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/test_pipeline_branches.py b/tests/test_pipeline_branches.py index 41ccb4e..ff920ed 100644 --- a/tests/test_pipeline_branches.py +++ b/tests/test_pipeline_branches.py @@ -303,3 +303,43 @@ def fake_run(command, **kwargs): output = render.run(topic) assert output.exists() assert (topic / "成片工程" / "render_report.json").exists() + + +def test_render_plan_rejects_missing_or_duplicate_mapping(monkeypatch): + monkeypatch.setattr( + render.config, + "get", + lambda key, default=None: {"voice.gap_seconds": 0.3}.get(key, default), + ) + timing = { + "sentences": [ + {"index": 1, "text": "甲", "audio": "1.wav", "duration": 1.0}, + ] + } + with pytest.raises(ValueError, match="缺少句子"): + render.plan_timeline({"items": []}, timing, 10.0) + + duplicate = { + "items": [ + {"sentence_index": 1, "start": 0.0, "end": 2.0}, + {"sentence_index": 1, "start": 3.0, "end": 5.0}, + ] + } + with pytest.raises(ValueError, match="一语句一画面"): + render.plan_timeline(duplicate, timing, 10.0) + + +def test_state_voice_reference_tracks_file(tmp_path, monkeypatch): + reference = tmp_path / "voice.wav" + reference.write_bytes(b"voice-data") + monkeypatch.setattr( + state.config, + "get", + lambda key, default=None: str(reference) + if key == "voice.gpt_sovits.voices.special.ref_audio_path" + else default, + ) + result = state._voice_reference("special") + assert result is not None + assert result["exists"] is True + assert result["size"] == len(b"voice-data") From b4c96a3e6efeafb80febf992cf53f36f673a8a2a Mon Sep 17 00:00:00 2001 From: DaBao Date: Sat, 3 Oct 2026 23:44:09 +0800 Subject: [PATCH 09/12] docs: align repository guidance with AutoUP 0.2 pipeline --- README.md | 31 +++-- SKILL.md | 218 ++++++------------------------ agents/openai.yaml | 6 +- autoup/__init__.py | 3 +- docs/dev-notes.md | 44 ++++-- docs/requirements.md | 101 ++++++-------- docs/research-editing-pipeline.md | 93 +++++-------- references/content-style.md | 138 ++++--------------- references/downloads.md | 77 +++-------- references/env-facts.md | 32 +++-- references/tool-map.md | 89 +++++------- references/workflow-spec.md | 142 ++++++++----------- 12 files changed, 315 insertions(+), 659 deletions(-) diff --git a/README.md b/README.md index 7cbf818..7691fd7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![Python](https://img.shields.io/badge/Python-3.11%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/) [![FFmpeg](https://img.shields.io/badge/FFmpeg-required-007808?logo=ffmpeg&logoColor=white)](https://ffmpeg.org/) -[![License](https://img.shields.io/badge/license-MIT-2ea44f.svg)](LICENSE) +[![License](https://img.shields.io/badge/license-MIT-2ea44f.svg)](LICENSE)\n[![CI](https://github.com/DaBaoAgent/AutoUP/actions/workflows/ci.yml/badge.svg)](https://github.com/DaBaoAgent/AutoUP/actions/workflows/ci.yml) **纯开源 · 完全免费 · 本地运行 · 中间产物留档 · 失败可续跑** @@ -118,15 +118,11 @@ python -m autoup.run --manifest urls.txt --batch 20260911-A ```bash git clone https://github.com/DaBaoAgent/AutoUP.git cd AutoUP -python -m venv .venv -# Windows -.venv\Scripts\activate -# macOS / Linux -# source .venv/bin/activate -pip install -r requirements.txt +# 安装 uv 后: +uv sync --all-extras --locked ``` -准备 **Python 3.11+**、**FFmpeg/ffprobe**,并确认它们可以被程序找到。首次使用前,打开 [`autoup/config.yaml`](autoup/config.yaml),按本机环境调整路径和参数。 +准备 **Python 3.11+**、**FFmpeg/ffprobe**,并确认它们可以被程序找到。仓库默认配置保持可移植;需要本机绝对路径时,把 [`autoup/config.example.yaml`](autoup/config.example.yaml) 复制为 `autoup/config.local.yaml` 后修改。该文件已被 `.gitignore` 忽略。 ### 2. 配置模型 Key @@ -158,7 +154,7 @@ python -m autoup.run --batch 20260911-A --only s8 --force python -m autoup.run --manifest urls.txt --batch smoke --limit 2 ``` -同一批次再次运行时会读取 `state.json`。阶段状态和关键产物都会被核对,文件被删掉或不完整时会自动重新执行该阶段。 +同一批次再次运行时会读取 `state.json`。每个阶段保存输入/配置 fingerprint 与耗时;只有状态、产物和 fingerprint 全部一致才会复用。修改上游文案、音色、模型或渲染配置后,受影响阶段会自动失效并重跑。 ## 生产流程 @@ -179,7 +175,7 @@ flowchart LR ## 配置重点 -所有默认值集中在 [`autoup/config.yaml`](autoup/config.yaml): +所有**可移植默认值**集中在 [`autoup/config.yaml`](autoup/config.yaml),本机覆盖写入 `autoup/config.local.yaml`: | 配置 | 作用 | |---|---| @@ -197,7 +193,18 @@ flowchart LR - [`docs/requirements.md`](docs/requirements.md):产品规则与验收标准 - [`docs/research-editing-pipeline.md`](docs/research-editing-pipeline.md):剪辑策略与技术选型 - [`docs/dev-notes.md`](docs/dev-notes.md):开发记录与已知问题 -- [`autoup/config.yaml`](autoup/config.yaml):唯一配置事实来源 +- [`autoup/config.yaml`](autoup/config.yaml):可移植默认配置\n- [`autoup/config.example.yaml`](autoup/config.example.yaml):本机覆盖示例\n- [`pyproject.toml`](pyproject.toml) / `uv.lock`:依赖与可复现环境\n- [`tests/`](tests/) / [`.github/workflows/ci.yml`](.github/workflows/ci.yml):测试与 CI 门禁 + +## 开发与质量门禁 + +```bash +uv sync --all-extras --locked +uv run python -m compileall -q autoup tests +uv run ruff check autoup tests +uv run pytest -q --cov=autoup --cov-report=term-missing +``` + +CI 在 Linux 执行完整 lint/test/coverage,在 Windows 执行安装和核心 smoke test。完整交付以 S9 验收和 CI 同时通过为准。 ## 适用边界与合规 @@ -210,7 +217,7 @@ flowchart LR - [x] 字幕驱动的影子匹配与 FFmpeg 渲染 - [x] 国内/海外发布资料与三比例封面 - [x] 交付前自动验收 -- [ ] 更丰富的本地素材输入与镜头策略 +- [x] 阶段 fingerprint 断点失效与耗时 metrics\n- [x] Ruff + pytest + 70% coverage + Linux/Windows CI\n- [ ] 更丰富的本地素材输入与镜头策略 - [ ] 可视化任务面板与失败阶段重试 - [ ] 更多字幕语言和可插拔 TTS 引擎 diff --git a/SKILL.md b/SKILL.md index b7fc874..e2d2c54 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,200 +1,66 @@ --- -name: autoyy -description: Plan, produce, refresh, and validate documentary-explainer content packages from performance screenshots, topic research, and long-form source videos. Use when Codex needs to analyze multi-platform creator data; identify repeatable documentary topics; search and verify 30+ minute YouTube sources; prepare a download manifest; download authorized HD video and subtitles; write and humanize Chinese voiceover scripts; create or recursively bulk-refresh Douyin-ready publication metadata; generate photorealistic topic or collection covers; resume an interrupted batch; or audit whether every topic folder is complete. +name: autoup +description: Run, resume, diagnose and validate the AutoUP documentary-narration S1-S9 pipeline. --- -# Produce Documentary Content +# AutoUP -Build a traceable pipeline from creator data to one ready-to-edit folder per documentary topic. Keep facts tied to subtitles or reliable sources, preserve source metadata, and validate every deliverable before reporting completion. +Use this repository as a lightweight CLI pipeline. Do not revive legacy AutoYY paths or deleted helper scripts. -Use `D:\自动剪辑` as the default working root. If the user does not provide a project directory, create a dedicated project subdirectory such as `D:\自动剪辑\AutoYY-YYYYMMDD-项目名`. Inspect but never reorganize, rename, or overwrite unrelated files already present in the working root. +## Pipeline -> **⚠️ 目录分工(2026-08-14 体检补充)**: -> - **工作产出根 = `D:\自动剪辑`**:所有选题项目目录(`AutoYY-YYYYMMDD-选题名/`)、下载的视频/字幕/文案/封面都在这。 -> - **技能代码本体 = `D:\@kaifa\AutoYY`**(git 仓库 `DaBaoAgent/AutoYY`,SSH 443 同步):本技能目录是指向它的 junction,SKILL.md/scripts/assets/vendor 的修改直接进 git。**不要**把工作产出放这里。 +1. S1 verifies URL duration, resolution and English subtitle availability. +2. S2 downloads/remuxes source video and normalizes the selected subtitle to SRT. +3. S3 generates narration from the complete subtitle timeline in factual windows. +4. S4 creates per-sentence TTS with fingerprint-aware cache reuse. +5. S5 semantically maps every narration sentence to real source footage. +6. S6 renders picture, narration, ASS subtitles and optional BGM. +7. S7 generates validated domestic/overseas publication material and cover copy. +8. S8 creates 9:16, 16:9 and 1:1 covers. +9. S9 validates the complete delivery contract. -## Start +Never claim completion when the requested stage or S9 gate failed. -1. Determine which stages the user requested: performance audit, topic planning, source research, authorized downloads, text production, cover generation, full pipeline, resume, or audit. -2. Record configurable inputs instead of hardcoding prior values: input data, topic count, project directory under the default working root, target platforms, duration, resolution, subtitle languages, script length and tone, and cover mode. -3. Use a task plan for multi-topic or full-pipeline work. Resume from verified files rather than restarting. -4. Ask only for decisions that materially change the result or permission scope. Never assume authorization to use browser cookies, download copyrighted media, bypass access controls, overwrite approved assets, or publish externally. +## Setup -## Route to supporting skills and tools +Use Python 3.11+, FFmpeg/ffprobe and uv. -- Read `references/tool-map.md` when planning a full pipeline or when the user asks which tools and skills are used at each stage. -- Read every supplied screenshot. Use visual inspection and OCR; use the spreadsheet skill when consolidating or analyzing tabular records. -- Browse the web for current YouTube links, view counts, availability, duration, quality, and recommendations. Treat all volatile metrics as dated observations. -- Use Chrome control only when the user explicitly needs signed-in browser state. Use `--cookies-from-browser` only after explicit authorization. -- Before writing or revising any voiceover, read `vendor/blader-humanizer/SKILL.md` completely and apply its Embedded mode. This humanization pass is mandatory, including single-topic drafts, batch generation, and rewrites. -- Use the image-generation skill for all raster cover generation and edits. Load the relevant master cover from `assets/` as a style reference. -- Use `scripts/download_from_manifest.ps1` for repeatable Windows batch downloads. -- Use `scripts/validate_publication_info.py` to recursively audit publication files, including topic folders nested under archive directories such as `@已发`. -- Use `scripts/validate_deliverables.py` for the final folder audit. - -## Workflow - -### 1. Audit published performance - -Read all visible records, not just top performers. Preserve platform, date, canonical topic, displayed title, views/reads, impressions, likes, comments, duration, and screenshot source when available. - -Normalize title variants into a canonical topic without altering raw values. Deduplicate overlapping screenshots and cross-platform reposts before aggregation. Calculate platform medians, outliers, and cross-platform topic totals. Identify the repeatable audience promise behind each winner, such as extreme risk plus giant engineering, war relic plus salvage, counterintuitive animal behavior, or closed institutions. Do not infer missing metrics. - -### 2. Generate and score candidate topics - -Allocate a batch by default as 70% proven themes, 20% adjacent extensions, and 10% experiments. Score each candidate from 0–5 on: - -1. danger or conflict; -2. visual density; -3. counterintuitive insight; -4. numeric scale; -5. source quality and usable rights. - -Prioritize candidates scoring at least 20/25. Lower the score when the source only partially matches the planned angle. - -### 3. Research and verify long sources - -For every candidate, verify a direct watch URL and capture: - -- Chinese topic and translated Chinese video title; -- original title, channel, URL, upload date; -- duration, current view count, highest available resolution; -- original language and subtitle availability; -- match grade: exact, high, adjacent, or reject; -- copyright/license note and verification date. - -Default filters are duration at least 30 minutes and resolution at least 720p; prefer 1080p or 4K. Never fabricate view counts or quality. Reject inaccessible, misleading, synthetic, compilation-only, or weakly matched sources. - -Write approved rows to a UTF-8 CSV manifest using `assets/topic-manifest-template.csv`. - -### 4. Create the topic report - -Lead with evidence from the user's performance data. For each proposed topic include its validated audience promise, source link and dated metadata, translated source title, likely hook, intended platforms, match grade, and licensing caveat. Separate verified facts from editorial inference. - -### 5. Download authorized media and subtitles - -Read `references/downloads.md` before downloading. Create one Chinese-named subdirectory per topic. Use the manifest and run: - -```powershell -powershell -ExecutionPolicy Bypass -File scripts/download_from_manifest.ps1 ` - -Manifest ` - -OutputRoot +```bash +uv sync --all-extras --locked ``` -Prefer manual English subtitles, then automatic English, then another available language. Keep the original URL and metadata in the manifest. Do not defeat DRM, paywalls, regional access controls, or platform security. - -### 6. Write the Chinese voiceover - -Read the full subtitle or transcript before drafting. Use subtitles as the narrative backbone and reliable sources only for necessary context. Read `references/content-style.md` and `vendor/blader-humanizer/SKILL.md` completely before writing. - -Default requirements: +Repository defaults live in `autoup/config.yaml`. For a machine-specific setup copy `autoup/config.example.yaml` to `autoup/config.local.yaml`; that local file is ignored by Git. Keep API keys in environment variables. -- pure spoken Chinese with no production labels; -- approximately 4,500–5,500 non-whitespace Chinese characters unless the user sets another target; -- light, conversational popular-science tone; -- a strong opening hook, steady reveals, and a meaningful ending; -- short, speakable sentences with technical terms explained simply; -- no invented facts, unrelated filler, duplicated paragraphs, template transitions, or calls to follow/share. +## Run -After drafting: - -1. Keep the first draft under a versioned filename; do not promote it to `爆款口播稿.txt` yet. -2. Run the bundled Humanizer in Embedded mode on the full draft. Complete its draft rewrite, AI-pattern audit, fabrication audit, and final rewrite internally. -3. Preserve every supported fact, name, date, quantity, quotation, uncertainty, chronology, causal statement, target length, and the user's approved voice. Prefer local sentence or paragraph repairs when they solve the problem. -4. Recheck the result against the subtitle/transcript, then read it aloud for spoken rhythm, opening hook, ending, and character count. If new source-grounded material is added, run the Humanizer pass again. -5. Promote only the cleaned narrator-ready text to `爆款口播稿.txt`. Never include Humanizer notes, headings, audit bullets, or process labels in the script. -6. After the promoted file is non-empty and passes final validation, delete superseded voiceover copies in the same topic directory, including `爆款钩子文案.txt` and versioned `爆款口播稿-*` drafts. Leave only `爆款口播稿.txt` as the script deliverable. If validation fails, retain the drafts for repair. Never delete subtitles, source notes, publication information, or unrelated text files. - -Remove padding-based binary contrasts, ceremonial sequencing, abstract essence claims, assistant route markers, repeated template colons, over-even paragraph shapes, and generic engagement questions. Keep a contrast, sequence, or question when it carries necessary facts, causality, quoted speech, or a user-requested specific comment hook. - -Do not pad to length. If the source cannot support the requested duration, state the evidence gap and propose supplemental sources. If the Humanizer instructions cannot be loaded or the pass cannot be completed, mark the voiceover blocked rather than claiming completion. - -### 7. Create publication information - -Write `发布信息.txt` as: - -```text - -#标签1 #标签2 #标签3 #标签4 #标签5 +```bash +python -m autoup.run --manifest urls.txt --batch demo +python -m autoup.run --batch demo --only s4,s5,s6 +python -m autoup.run --batch demo --only s8 --force ``` -Use exactly two non-empty lines with no field labels, blank lines, emoji, publishing advice, platform notes, or extra copy. Count every Chinese character, digit, Latin letter, punctuation mark, and space toward the 25-character title limit. Use Douyin-style information gaps built from a factual number, contrast, consequence, or question, but do not exaggerate beyond the source. Use exactly five topic-specific hashtags on the second line. - -For a bulk refresh: - -1. Recursively find every existing `发布信息.txt`; include nested archive folders and do not create a file in a container-only directory. -2. Read each topic folder name and its existing publication text. Preserve the topic and any accurate, specific tags; rewrite only what is needed. -3. Produce a unique title for each topic. Do not reuse one template across the batch. -4. Overwrite only `发布信息.txt` after confirming the discovered file count equals the planned update count. Do not touch video, subtitles, voiceover, or covers. -5. Run `python scripts/validate_publication_info.py ` and scan for legacy fields such as `爆款标题:`, `匹配标签:`, `发布建议:`, and `版权提醒:`. - -### 8. Generate covers - -Read the cover section in `references/content-style.md` and use the relevant style-reference asset. +`--only` means exactly those stages. `--allow-partial` is the explicit opt-out from a non-zero batch exit code when some topics fail. -- Topic-cover mode: create 3:4 and 4:3 photorealistic covers. Put a six-character main title and eight-character subtitle in the upper half, each on one line; make the subtitle about two-thirds the width of the main title. -- Collection-cover mode: create 1:1 and 4:3 photorealistic covers. Put the exact four-character collection name on one centered line with no subtitle. +## Resume rules -For topic covers, match the approved reference typography precisely: an extra-large vivid golden-yellow rough brush-calligraphy main title with a thin black outline and strong soft black shadow, followed by a medium-large white rough brush-calligraphy subtitle with the same black outline and shadow. Center both lines in the upper half, keep the subtitle about two-thirds the main-title width, and leave safe margins around every character. Use high contrast and no extra text, logo, or watermark. Inspect every generated image for exact Chinese text and actual pixel ratio before saving. +Use `state.json`; do not infer completion from filenames alone. A completed stage is reusable only if its artifacts exist and its stored input fingerprint matches the current files/config. Stage elapsed times are stored under `metrics`. -#### 即梦 (Jimeng) cover-prompt mode +## Hard quality rules -When covers are generated externally via 即梦 instead of an in-pipeline image tool, write one `封面提示词-即梦.txt` per topic directory containing exactly two plain prompt paragraphs separated by a blank line — no instructions, headers, or usage notes inside the file: - -- Paragraph 1: `3:4竖版构图` prompt (portrait scene + text block placed at the upper-third). -- Paragraph 2: `4:3横版构图` prompt (landscape wide scene + text block placed upper-center). - -Shared typography per topic: a six-character main title in extra-large golden-yellow rough brush calligraphy (thin black outline, soft black shadow), plus an eight-character white rough brush-calligraphy subtitle at about two-thirds the main-title width. Portrait scenes favor depth/close-up compositions; landscape scenes favor wide establishing shots. Both paragraphs must render the same title/subtitle pair. - -Batch generation: fill the CSV template (see Resources) with one row per topic (`目录名,主标题,副标题,竖版画面,横版画面`) and run: - -```powershell -python scripts/gen_jimeng_cover_prompts.py --csv -``` - -The script validates 6-char/8-char title lengths, skips rows whose target directory is missing, and writes `封面提示词-即梦.txt` (UTF-8, two prompt paragraphs only). It also skips any topic directory that already contains covers: the check counts image files (png/jpg/jpeg/webp/bmp/gif) in the directory and treats >= 2 images as "covers already exist", regardless of their filenames (封面-3比4 / 封面-4比3 / 封面 / cover / arbitrary names all count). Derive the 6+8 cover text from the approved 发布信息 title and the script's factual content — do not reuse the full 25-char title verbatim. - -### 9. Validate and hand off - -Expected topic directory: - -```text -/ -├── 高清源视频. -├── 字幕.srt -├── 发布信息.txt -├── 爆款口播稿.txt -├── 封面-3比4. -└── 封面-4比3. -``` +- Never generate a script section without a subtitle fact window. +- Never fabricate a missing S5 match by assigning an unrelated free time slot. +- Never reuse TTS solely because a numbered WAV already exists. +- Never rename another video container to `.mp4` without a real remux/transcode. +- Never accept malformed publication data after repair attempts are exhausted. +- Never bypass S9 for a full delivery. +- Preserve Demo and historical cover assets in this repository. -Run: +## Development -```powershell -python scripts/validate_deliverables.py +```bash +uv run python -m compileall -q autoup tests +uv run ruff check autoup tests +uv run pytest -q --cov=autoup --cov-report=term-missing ``` -Do not report success until deterministic validation and manual checks agree. Report completed, incomplete, and blocked topics separately. - -## Resume and overwrite rules - -- Treat existing user files as authoritative unless they are known outputs of the current run. -- Skip complete video and subtitle files. -- Never replace an approved script or cover without explicit instruction. -- Keep logs and manifest state so a stopped batch can continue. -- Use versioned filenames while drafting; after a verified promotion, remove superseded voiceover copies and retain only the final standard filename. - -## Resources - -- Read `references/workflow-spec.md` for manifest fields, stage gates, and acceptance checks. -- Read `references/tool-map.md` for the end-to-end stage, tool, supporting-skill, and output map. -- Read `references/content-style.md` before writing scripts, publication information, or covers. -- Use `vendor/blader-humanizer/SKILL.md` in Embedded mode for every generated or revised voiceover. `vendor/blader-humanizer/LICENSE` records the bundled upstream license. -- Read `references/downloads.md` before media retrieval or cookie/proxy troubleshooting. -- Use `assets/topic-manifest-template.csv` as the batch manifest schema. -- Use `assets/topic-cover-3x4-approved.png` and `assets/topic-cover-4x3-approved.png` for the approved topic-cover typography, scale, color, outline, shadow, and layout. -- Use `assets/collection-cover-1x1.png` and `assets/collection-cover-4x3.png` for collection-cover style. -- Use `assets/jimeng-cover-template.csv` and `scripts/gen_jimeng_cover_prompts.py` for batch-generating 即梦 cover prompts (two plain paragraphs: 3:4 + 4:3). -- Use `scripts/download_from_manifest.ps1` for downloading. -- Use `scripts/validate_publication_info.py` for recursive two-line publication metadata validation. -- Use `scripts/validate_deliverables.py` for deterministic acceptance checks. +CI must pass on Linux quality gates and Windows smoke tests. diff --git a/agents/openai.yaml b/agents/openai.yaml index 6c98bbf..1dfb79a 100644 --- a/agents/openai.yaml +++ b/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: "AutoYY" - short_description: "从数据选题到下载、去AI味口播稿、封面和发布信息的一体化流程" - default_prompt: "Use $autoyy to run the documentary workflow in D:\\自动剪辑, including mandatory embedded Humanizer processing for every voiceover, then covers, publication metadata, and final validation." + display_name: "AutoUP" + short_description: "纪录片解说自动流水线:核验、下载、文案、配音、匹配、渲染、发布资料、封面与验收" + default_prompt: "Use $autoup to run or resume the AutoUP S1-S9 pipeline. Keep facts grounded in source subtitles, use fingerprinted stage state, and do not report completion unless the requested validation gates pass." diff --git a/autoup/__init__.py b/autoup/__init__.py index a6c7cca..3237efa 100644 --- a/autoup/__init__.py +++ b/autoup/__init__.py @@ -1,2 +1,3 @@ """AutoUP: 纪录片解说全自动生产线。""" -__version__ = "0.1.0" + +__version__ = "0.2.0" diff --git a/docs/dev-notes.md b/docs/dev-notes.md index 879aaeb..2797a93 100644 --- a/docs/dev-notes.md +++ b/docs/dev-notes.md @@ -1,13 +1,31 @@ -# 开发备忘(坑与修复) - -- yt-dlp curl 后端加载不了中文路径下的 certifi (curl:77) → download._fix_curl_ca() 已修 -- yt-dlp 合并格式需要 ffmpeg 在 PATH → download._ensure_ffmpeg_on_path() -- ffmpeg ass 滤镜参数含盘符冒号会被截断 → cwd=成片工程 用相对路径 subs.ass -- pydub 动态 which() 找 ffmpeg → utils.ensure_pydub_ffmpeg() 前置 PATH -- GLM-4.6 思考模型 JSON 模式 content 为空(在 reasoning_content) → llm.py 已带 thinking disabled + 兜底 -- yt-dlp 中途死会留 .vtt → download.py 有 vtt→srt 兜底; 多语言 srt 优先取 .en -- 本机 GPT-SoVITS 用 .venv/Scripts/python.exe (非官方 runtime/python.exe), api_v2 起服务后首次合成前有预热延迟 -- edge_tts 实测语速 ~0.25s/字: 4500 字 ≈ 19 分钟成片; 要压 15 分钟调 script.target_chars 或 voice 语速 -- S6 时间轴: 每句末尾段多剪 gap_seconds 当句间停顿画面; 未匹配句延续末段画面 -- 冒烟: python -m autoup.run --batch _smoke_e2e --set download.max_height=360 --set script.target_chars=300 (续跑) -- assets/bgm 是 CC-BY (Kevin MacLeod), 发布必须带 CREDITS.txt 里的署名 +# AutoUP development notes + +## Current invariants + +- S3 partitions the complete SRT by progress; no section is generated without a source subtitle window. +- S4 reuses sentence audio only when the text/voice/config/reference-audio fingerprint matches. +- S5 requires one semantic picture segment per narration sentence. Missing matches are retried; unresolved sentences fail instead of receiving arbitrary free footage. +- S6 refuses to render an incomplete S5 decision. +- S7 validates and repairs structured publication data, then fails closed if constraints still do not pass. +- S9 validates both lower and upper script length, TTS fingerprints, complete non-overlapping mapping, final video properties, publication formats and exact cover dimensions. +- Stage fingerprints invalidate stale checkpoints automatically. +- `state.json.metrics` records stage elapsed seconds for performance analysis. + +## Known platform details + +- yt-dlp/FFmpeg may need explicit local paths on Windows; use `autoup/config.local.yaml` instead of editing repository defaults. +- yt-dlp curl can fail with a CA path containing non-ASCII characters; S1/S2 copies the CA bundle to a temporary ASCII path when required. +- FFmpeg ASS filters on Windows are run from the work directory with relative paths to avoid drive-letter escaping issues. +- GPT-SoVITS is optional. If its configured voice is unavailable, the pipeline can fall back to Edge TTS. +- BGM selection in `random` mode is deterministic from the topic name for reproducible reruns. + +## Quality commands + +```bash +uv sync --all-extras --locked +uv run python -m compileall -q autoup tests +uv run ruff check autoup tests +uv run pytest -q --cov=autoup --cov-report=term-missing +``` + +CI runs full quality gates on Linux and a Windows smoke suite. diff --git a/docs/requirements.md b/docs/requirements.md index cba1f95..08b071f 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -1,61 +1,40 @@ -# AutoUP 需求纪要(2026-09-08 采访定稿) - -> 本文件是 AutoUP 迭代的唯一需求基准。开发过程中的取舍若偏离本文件,须先更新本文件再改代码。 -> 前身:AutoYY(DaBaoAgent/AutoYY,本仓库由其复制而来,已切 remote 至 DaBaoAgent/AutoUP)。 - -## 1. 定位 - -纪录片解说**全链路自动生产线**:从人工给定的选题/链接池出发,一键运行到「发布就绪文件夹」, -中途零人工,失败自动断点续传。 - -## 2. 核心决策(采访结论) - -| # | 决策项 | 结论 | -|---|--------|------| -| 1 | 运行形态 | 一键全自动,一条命令选题后跑到成片+发布素材,失败断点续传 | -| 2 | 成品规格 | **8-15 分钟横版长视频**,文案维持 4500-5500 字标准 | -| 3 | 配音引擎 | **双引擎可配置**:默认 GPT-SoVITS(管线自拉起,跑完可关),备用 edge-tts | -| 4 | 音色 | config 可配置多个纪录片解说音色,每次运行可指定 | -| 5 | 画面来源 | **只用源视频切片**:SRT 时间轴把解说句对齐到源片镜头(影子匹配,AutoDSJ 经验) | -| 6 | 解说与原声 | **纯解说压剪**:全程盖解说、不留原声,30min 源片压进对应解说时长 | -| 7 | 字幕 | 烧录中文字幕:描边白字、底部偏上、全程同步 | -| 8 | BGM | 要:本地免版权曲库低音量循环,解说段自动闪避(曲库由 AI 下载 20-30 首) | -| 9 | 平台 | 全平台矩阵:抖音/快手/视频号/小红书/百家号/B站/YouTube/X/IG/FB/TikTok | -| 10 | 发布信息 | **轻量两类**:国内平台(标题≤25字+5标签)、海外平台(英文标题+描述) | -| 11 | 封面 | **三比例全配**:9:16(抖/快/视/红/TK)+ 16:9(B/YT/西瓜)+ 1:1(IG/FB/X),同风格模板 | -| 12 | 自动发布 | 本期不做,但目录/数据结构预留接口(下期接 upload-post 或 playwright) | -| 13 | 选题 | **半自动**:人工给选题清单或 YouTube 链接池,下载环节起全自动 | -| 14 | 字幕兜底 | **只接受 YouTube 自带字幕**,没有英文字幕的选题直接拒,不做 whisper | -| 15 | 分辨率 | 统一 1080p 横版(1920x1080/30fps),源片不足 1080p 时放大 | -| 16 | 批量策略 | **串行**:一次一个选题,跑完再下一个 | -| 17 | 架构 | **混合**:机械环节纯 Python 脚本;LLM 环节(文案/去AI味/发布信息)封装为 API 与 Hermes 双模式 | -| 18 | LLM API | 用现有 GLM API key(zai) | -| 19 | 产出根 | `D:\AutoUP`(新建,与旧 AutoYY 产出 `D:\自动剪辑` 完全分开) | -| 20 | 仓库 | DaBaoAgent/AutoUP(已存在,保留远端 README 与 docs/covers,本地代码覆盖推送) | -| 21 | 调研 | 开发前先调研 MoneyPrinterTurbo / NarratoAI 等高星同类项目的剪辑合成实现,**调研报告先发群里等确认再动工** | - -## 3. 全链路阶段 - -``` -选题清单(人工) → ①源核验 → ②下载视频+SRT → ③提取SRT出爆款文案 → ④配音(TTS) - → ⑤影子匹配+合成剪辑 → ⑥烧字幕+BGM混音 → ⑦发布信息(两类) → ⑧封面(三比例) → ⑨验收打包 -``` - -- ①-② 沿用 AutoYY 已验证方案(manifest + yt-dlp + 断点续传,ffmpeg 在 D:\@佳康顺矩阵\@工具\ffmpeg\) -- ③ 沿用 Humanizer 去AI味强制流程(vendor/blader-humanizer) -- ④⑤⑥ 为本期新开发核心 -- 每阶段落盘产物 + 状态标记,支持从任意已完成阶段续跑 - -## 4. 目录约定 - -- 代码仓库:`D:\@kaifa\AutoUP`(本仓库,remote = DaBaoAgent/AutoUP) -- 产出根:`D:\AutoUP\<日期-批次>\\` -- 选题目录六件套演进为:高清源视频 / 字幕.srt / 配音.wav / 成片.mp4 / 发布信息(分平台) / 封面(三比例) - -## 5. 环境事实 - -- GPT-SoVITS:本机 2026-09-05 验证跑通(onnxruntime/vc_redist 14.44 已修) -- edge-tts 7.2.7 本机可用 -- ffmpeg:`D:\@佳康顺矩阵\@工具\ffmpeg\` -- GLM API(zai):Hermes .env 已有 key,生产脚本复用 -- gh CLI 2.100.0 已登录 DaBaoAgent +# AutoUP requirements + +## Product position + +AutoUP is a lightweight CLI pipeline that turns an approved long-form documentary source into a publish-ready narration package while keeping every intermediate artifact inspectable and rerunnable. + +The project intentionally avoids WebUI, database, Redis/Celery and other infrastructure until a concrete requirement justifies them. + +## Core requirements + +1. Input starts from a user-provided URL manifest. +2. S1 accepts only sources meeting configured duration/resolution thresholds with an available English subtitle. +3. S2 downloads source media and the exact subtitle language selected during verification. +4. S3 narration is grounded section-by-section in the complete subtitle timeline; unsupported padding is forbidden. +5. S4 supports GPT-SoVITS and Edge TTS, selectable by voice, with fingerprint-aware cache reuse. +6. S5 maps every narration sentence to one semantically relevant, real source-video interval. Missing coverage is a hard failure. +7. S6 produces 1920×1080/30fps by default, removes source audio, overlays narration/subtitles, and optionally ducks deterministic BGM. +8. S7 generates strict domestic and overseas publication material plus exact 6/8-character cover copy. +9. S8 outputs 9:16, 16:9 and 1:1 covers. +10. S9 is the delivery gate. A full batch returns a non-zero exit code when any topic fails unless partial success was explicitly allowed. +11. Every stage stores a content/config fingerprint and elapsed time in `state.json`. +12. Machine-specific paths and secrets are never committed. + +## Portability + +Repository defaults use commands such as `ffmpeg` and a relative `output` directory. Users may override them with `autoup/config.local.yaml`, `AUTOUP_*` environment variables or `--set`. + +## Engineering quality + +- Python 3.11+ +- `pyproject.toml` is the dependency/configuration source of truth. +- `uv.lock` provides reproducible dependency resolution. +- Ruff must pass. +- pytest must pass. +- Coverage must remain at or above the configured 70% gate. +- Linux full CI and Windows smoke CI must pass before delivery. + +## Repository assets + +Demo videos, README imagery and historical cover examples stay in the repository. They are reference/history assets; production code should not couple itself to individual historical files. diff --git a/docs/research-editing-pipeline.md b/docs/research-editing-pipeline.md index 4bad503..974821b 100644 --- a/docs/research-editing-pipeline.md +++ b/docs/research-editing-pipeline.md @@ -1,75 +1,44 @@ -# AutoUP 剪辑合成技术选型报告(2026-09-08) +# AutoUP engineering comparison and design choices -> 调研对象:GitHub 高星同类项目源码级深读。结论服务于 docs/requirements.md 第 5/6/7/8 环节(配音→影子匹配→合成剪辑→字幕/BGM)的实现选型。**本文档结论经用户确认后动工。** +AutoUP was compared with high-star projects in the same broad space, including MoneyPrinterTurbo, VideoLingo, NarratoAI and ShortGPT. -## 1. 全景对比 +## What AutoUP borrows conceptually -| 项目 | ⭐ | 许可 | 活跃 | 路线 | 对 AutoUP 的价值 | -|---|---|---|---|---|---| -| MoneyPrinterTurbo | 121k | MIT | 活跃 | 素材库混剪:LLM 文案→Pexels/本地素材→moviepy 合成 | 合成流程参考,但画面来源与我们不同 | -| NarratoAI | 11k | MIT | 活跃 | **影视解说压剪**:文案↔字幕时间戳匹配→FFmpeg 逐段剪切→拼接 | ★ 主参考,问题域完全一致 | -| VideoLingo | 18k | Apache-2.0 | 活跃 | 视频翻译配音:ASR→翻译→TTS 对齐原字幕时间轴 | ★ 配音工程参考(GPT-SoVITS 集成) | -| FunClip | 6.2k | MIT | 活跃 | FunASR 转写→LLM 选段→CLI 剪切 | 无 ASR 需求(我们有官方 SRT),仅思想参考 | -| auto-editor | 5.1k | Unlicense | 活跃 | 静音检测自动剪 | 不适用(我们是内容驱动剪辑) | -| ShortGPT | 7.9k | MIT | **2025-02 停更** | 素材库自动短视频 | 不选 | -| OpenCreator(原KrillinAI) | 11.3k | **无许可** | 活跃 | 一体化创作 | 无许可不可移植代码,仅产品形态参考 | +- **MoneyPrinterTurbo:** automated media workflow discipline, packaging and CI ideas. +- **NarratoAI:** subtitle-driven narration/video matching and FFmpeg-first composition. +- **VideoLingo:** robust TTS/dubbing integration patterns. +- **ShortGPT:** explicit pipeline stages and modular engines. -## 2. 三强核心发现(源码级) +## What AutoUP deliberately does not copy -### NarratoAI —— 剪辑路线的直接答案 -- **压剪模型**:`clip_video.py` 的 OST=0 段处理——「解说时长决定画面时长」:`end = start + TTS时长`,FFmpeg `-ss/-to` fast-seek 逐段剪切 + `-an` 去原声,与需求「纯解说压剪」完全同构。我们只需 OST=0 一种段型,比它更简单。 -- **工程健壮性**:编码器 fallback 链(NVENC→AMF→QSV→libx264)、`-avoid_negative_ts make_zero`、`+faststart`——直接移植。 -- **影子匹配 prompt**(`prompts/film_tv_narration/script_matching.py`):逐句切分解说→匹配 SRT 时间戳→JSON items;关键护栏:字幕索引校验(时间戳必须命中真实字幕块,防幻觉)、禁止匹配片头/广告/预告、**估时公式「字数/5 秒」**、同视频内时间段不得重叠、长句拆多段。 -- **总装一条流**(`generate_video.py:_build_ffmpeg_merge_command`):视频+配音+BGM 三输入,BGM `-stream_loop -1` 循环 + 尾部 `afade=t=out:d=3`,`amix normalize=0`(不做自动增益,音量全靠 config),字幕 `subtitles` 滤镜 → `drawtext` → PNG overlay 三级降级。 +AutoUP currently has a small, focused CLI core. It does not need a WebUI, API server, database, Redis, Celery, plugin marketplace or distributed worker layer. Those systems would increase failure surface and maintenance cost without improving the current single-machine batch goal. -### VideoLingo —— 配音对齐的工程细节 -- **GPT-SoVITS 集成**(`tts_backend/gpt_sovits_tts.py`):api_v2 协议 POST `127.0.0.1:9880/tts`(text/text_lang/ref_audio_path/prompt_text/speed_factor);**服务自拉起**:探端口→`runtime\python.exe api_v2.py -c 角色.yaml`→ping 等就绪。本机 `D:\@佳康顺矩阵\@工具\GPT-SoVITS` 含 `api_v2.py`,协议直接可用。 -- **参考音频三种模式**:①角色默认参考音频 ②用第 1 句克隆源 ③逐句用原片对应音频。我们用模式①+config 多音色。 -- **音节估时**(`estimate_duration.py`):中文按拼音音节×0.21s+标点停顿,预估每句配音时长,用于**配音前**的时长预算——比 NarratoAI 的「字数/5」更准。 -- **音频拼接**(`_11_merge_audio.py`):分段 wav→pydub→按时间轴插静音→合并;音量归一化后进成片。 +## Current architecture -### MoneyPrinterTurbo —— 不走 moviepy 的理由 -- MPT 用 moviepy 做合成(内存加载全部片段、8-15min 1080p 重编码慢、Windows 易 codec 坑——它自己都写了 codec fallback)。**结论:AutoUP 全程 FFmpeg 命令流,不引入 moviepy**,与 NarratoAI 同路线但更彻底(无 OST=1/2 混合段型,无需逐段重编码两次)。 -- 可借鉴:BGM 曲库管理、`get_audio_duration` 多来源兜底、断点状态机思想。 - -## 3. AutoUP 合成管线设计(推荐架构) - -``` -SRT+文案 → [S4 配音] 逐句 TTS(实测时长) → timing.json(每句: 音频文件/起始/时长) - ↓ GPT-SoVITS 自拉起(9880) → 失败/超时自动切 edge-tts - → [S5 影子匹配] 文案句 ×SRT 语义匹配(LLM) + 时长约束 → edit_decision.json - 护栏:时间戳必须命中字幕块/禁片头广告段/段间不重叠/画面时长≥配音时长 - → [S6 合成] 全 FFmpeg: - ① 逐段剪切(-ss/-to, 去原声) → segments/*.mp4 - ② concat demuxer 拼接 → picture.mp4 - ③ 配音轨:分段 wav 按累积时间轴 adelay+amix(或 pydub 拼接) - ④ BGM: -stream_loop 循环 + volume(config) + sidechaincompress 随解说闪避 + 尾部 afade - ⑤ 字幕: subtitles 滤镜+ASS force_style(描边白字/底部偏上, 参数在 config) - ⑥ 一次重编码出片: libx264/NVENC(自动探测) crf18 1080p30 + faststart +```text +manifest + -> S1 verify + -> S2 download + -> S3 subtitle-grounded script + -> S4 sentence TTS + timing + -> S5 semantic source matching + -> S6 FFmpeg render + -> S7 publication material + -> S8 three cover ratios + -> S9 delivery validation ``` -**代码移植清单**(MIT/Apache 兼容,vendor 目录注明出处): -| 来源 | 移植内容 | 许可 | -|---|---|---| -| VideoLingo | gpt_sovits 集成、音节估时、音频拼接 | Apache-2.0 | -| NarratoAI | FFmpeg 剪切/编码器 fallback/总装命令/匹配 prompt 思想 | MIT | -| MPT | BGM 管理、时长兜底 | MIT | - -**模块划分**:`autoup/{config.yaml, run.py(状态机断点续传), stages/s1~s9}`,产出 `D:\AutoUP\<批次>\<选题>\{源视频, 字幕, 文案, segments/, timing.json, edit_decision.json, 成片.mp4, 发布信息, 封面×3, state.json}`。每阶段产物落盘,state.json 记录断点。 - -**文案阶段新增约束**(S3 与 S4/S6 联动):4500-5500 字 → 按 0.21s/音节预算 ≈ 13-15min 成片,文案生成时 LLM 拿到目标时长反馈,避免成片超 15min。 +The important boundary is between probabilistic LLM output and deterministic Python validation. S3 retains source-window provenance; S5 retries missing semantic matches and fails closed; S7 validates formatting; S9 acts as a final contract. -## 4. 需要确认的决策点 +## Performance strategy -| # | 决策点 | 推荐 | 备选 | -|---|---|---|---| -| 1 | 影子匹配质量路线 | 一期纯 LLM 文本匹配(SRT 语义+时长约束,快/零额外成本),预留视觉复核接口 | 一期就叠加视觉帧复核(AutoDSJ 模式,慢且耗 API) | -| 2 | 编码器 | 自动探测 NVENC→libx264 fallback(NarratoAI 链) | 固定 libx264 | -| 3 | 配音句间间隙 | 保留 0.2-0.4s 自然停顿(画面同步多剪对应时长) | 全部零间隙拼接 | -| 4 | BGM 曲库 | Pixabay 免版权直链拉 20-30 首入 `assets/bgm/` | 用户提供曲库路径 | +Performance work is intentionally low-complexity and measurable: -## 5. 工作量预估(确认后) +- bounded concurrency for independent S1 metadata requests; +- one persistent HTTP session for LLM calls; +- cached NVENC capability detection; +- fingerprinted TTS/stage caches to avoid unnecessary recomputation; +- deterministic BGM selection for reproducible rendering; +- per-stage elapsed-time metrics in `state.json`. -- S4-S6 核心(配音+匹配+合成):3-5 个工作日 -- S7/S8 改造(发布信息两类+封面三比例)+ S1/S2 改造 + run.py 状态机:2-3 个工作日 -- 全链路联调 + 首条真实选题端到端验收:1-2 个工作日 +A monolithic FFmpeg filter graph or distributed queue is not introduced until profiling shows that the simpler pipeline is insufficient. diff --git a/references/content-style.md b/references/content-style.md index c866161..f4a32a3 100644 --- a/references/content-style.md +++ b/references/content-style.md @@ -1,130 +1,38 @@ -# Content and cover style +# AutoUP content and cover rules -## Contents +## Voiceover -- Voiceover objective -- Voiceover structure -- Language rules -- Prohibited patterns -- Mandatory Humanizer pass -- Publication information -- Topic covers -- Collection covers -- Cover quality control +- Output only narrator-ready Chinese prose. +- Facts must come from the subtitle window supplied to that generation section. +- Do not invent names, numbers, events, motives or causal links. +- Prefer short and medium spoken sentences with concrete verbs and nouns. +- Avoid production labels, editing instructions, generic engagement calls and formulaic AI transitions. +- Default target is controlled by `script.target_chars` and `script.tolerance`; validation enforces both lower and upper bounds. +- When the source cannot support the requested length, fail rather than padding with unsupported material. -## Voiceover objective +S3 writes `script_map.json` so each generated section retains its source time range. -Write text that can be pasted directly into text-to-speech or read by a narrator. Sound like a knowledgeable friend telling a surprising true story, not a paper, editing brief, lesson plan, or AI outline. +## Publication material -Default target: 4,500–5,500 non-whitespace Chinese characters for a roughly 15-minute finished piece. Adjust to narration speed and requested duration. Substance has priority over length. - -## Voiceover structure - -- Open within 80–150 characters with danger, contradiction, scale, or a result that demands explanation. -- Establish the central question quickly. -- Move chronologically or causally; introduce one idea per paragraph. -- Renew curiosity every 500–800 characters through a concrete discovery, consequence, or reversal. -- Explain numbers by comparison when useful. -- Resolve the opening question and connect the ending to people, choices, or present-day meaning. - -Do not announce structural beats. The listener should feel them, not hear their labels. - -## Language rules - -- Prefer short and medium sentences that are easy to say in one breath. -- Use everyday verbs and concrete nouns. -- Explain specialist terms immediately. -- Rewrite tongue-twisters, stacked modifiers, and dense abstractions. -- Use cautious wording for disputed claims. -- Keep names, dates, quantities, and causal statements consistent with checked sources. - -## Prohibited patterns - -Do not include: - -- `【开场钩子】`, `【核心悬念】`, `【推进】`, `【结尾升华】`, or `【补充叙事】`; -- “镜头切到”“画面来到”“字幕里”等 editing directions; -- “这段视频最容易省略的一层”“回看整条因果链”“前面三条线索共同指向”等 meta-commentary; -- generic filler copied between topics; -- repeated complete paragraphs; -- unrelated systems or engineering language inserted into an animal, history, or culture topic; -- “点赞、关注、收藏、转发”等 calls to action unless explicitly requested; -- fabricated dialogue, motives, facts, numbers, or conclusions. - -Do not use a fixed transition library to expand every script. When the source is thin, find another reliable source or shorten the piece. - -## Mandatory Humanizer pass - -Every generated or revised voiceover must pass `vendor/blader-humanizer/SKILL.md` in Embedded mode before it is saved as final. Run its draft, AI-pattern audit, fabrication audit, and final-rewrite loop internally. The output file must contain only the final spoken Chinese text. - -During humanization: - -- preserve all source-supported facts, names, dates, quantities, quotations, and causal claims; -- preserve the user's approved voice and useful specific details; -- remove formulaic signposting, inflated significance, promotional wording, forced groups of three, false contrasts, synonym cycling, generic conclusions, manufactured punchlines, and repetitive transitions; -- vary sentence length naturally without turning the script into stacked dramatic fragments; -- remove em and en dashes unless an approved user writing sample clearly requires them; -- never add a fact merely to make a sentence sound more human. - -Apply the bundled Humanizer patterns to Chinese as well. Remove or repair these high-priority shells when they are padding rather than necessary meaning: - -- 「不是A,而是B」「并非A,而是B」「不只是A,更是B」「与其说A,不如说B」; -- 「先A,再B」「第一步…第二步…」when the order does not change the outcome; -- 「真正重要的是」「本质上」「核心在于」「底层逻辑」; -- 「接下来」「我们可以看到」「值得注意的是」「不可否认的是」「总的来说」「说白了」「划重点」; -- repeated 「观点:解释」paragraphs, mechanically even paragraph lengths, and three or more clauses with identical grammar; -- generic ending questions such as 「你觉得呢?」 or 「是不是很震撼?」 unless the user explicitly requests a specific comment hook. - -After the pass, compare the final script with the source material and read it aloud. Repair awkward pronunciation, unsupported additions, factual drift, and accidental omissions. If substantial material is added after this check, run the Humanizer pass again. A script that skips this gate is not final. - -Keep versioned drafts during revision. After the approved text has been promoted to `爆款口播稿.txt`, read it back and confirm it is non-empty and still passes factual, length, speakability, and Humanizer checks. Then delete superseded voiceover files in that topic directory, including `爆款钩子文案.txt` and versioned `爆款口播稿-*` drafts. Keep the drafts when promotion or validation fails. Do not treat subtitles, research notes, publication information, or unrelated text documents as disposable drafts. - -## Publication information - -Use exactly: +Domestic output: ```text - -#标签1 #标签2 #标签3 #标签4 #标签5 +<title no longer than 25 characters> +#tag1 #tag2 #tag3 #tag4 #tag5 ``` -Write exactly two non-empty lines. Do not add `标题:`, `爆款标题:`, `标签:`, section headings, blank lines, emoji, publishing advice, platform notes, cover copy, or interaction prompts. - -Count every Chinese character, digit, Latin letter, punctuation mark, and space toward the 25-character title limit. The title should follow Douyin's high-click logic while remaining factual: build an information gap with a checked number, contrast, consequence, or concise question. Prefer a concrete causal question or measurable scale over a proper noun alone. Never fabricate or exaggerate beyond the source. - -Put exactly five space-separated, topic-specific hashtags on the second line. - -## Topic covers - -Use `assets/topic-cover-3x4-approved.png` and `assets/topic-cover-4x3-approved.png` as the approved style references. +Rules are enforced in code: non-empty title, exactly five unique tags, no spaces inside tags, and no `#` inside model-returned tag values. -- Ratios: 3:4 portrait and 4:3 landscape. -- Background: photorealistic documentary image tied to the exact topic. -- Type: rough handwritten Chinese brush lettering with strong contrast. -- Main title: exactly six Chinese characters, one line. -- Subtitle: exactly eight Chinese characters, one line. -- Placement: upper half. -- Width: subtitle about two-thirds the main-title width. -- Main-title treatment: extra-large vivid golden-yellow brush calligraphy, thin crisp black outline, and strong soft black drop shadow; nearly span the available safe width. -- Subtitle treatment: medium-large white brush calligraphy, thin crisp black outline, and strong soft black drop shadow; center directly below the main title. -- Keep both lines visually centered with clear spacing and safe margins; do not crop a stroke. -- No extra text, English, logo, watermark, or decorative badge. +Overseas output uses an English title no longer than 90 characters plus an English description. Chinese characters in the English material fail validation. -## Collection covers +## Covers -Use `assets/collection-cover-1x1.png` and `assets/collection-cover-4x3.png` as style references. +AutoUP generates three delivery ratios: -- Ratios: 1:1 and 4:3. -- Background: one iconic photorealistic scene expressing the collection. -- Title: exact four-character collection name, one centered line. -- Type: warm antique-gold rough brush calligraphy with a subtle dark shadow. -- No subtitle or other text. +- 9:16 — 1080×1920 +- 16:9 — 1920×1080 +- 1:1 — 1080×1080 -## Cover quality control +Cover main text must be exactly six Chinese characters and subtitle exactly eight. The renderer uses the bundled/configured font, golden main title, white subtitle, dark outline/shadow, and recalculates text bounds after font fitting to avoid off-center long text. -1. Visually read every Chinese character. -2. Confirm no unwanted words or pseudo-text. -3. Measure pixel dimensions and aspect ratio. -4. Confirm the scene is realistic and topic-relevant. -5. Compare both variants for consistent identity. -6. Save drafts with versioned names; promote only approved files to final names. +Demo images and historical covers are retained in the repository as reference/history assets; runtime code must not depend on historical cover filenames. diff --git a/references/downloads.md b/references/downloads.md index 3803ce9..e6a9ba6 100644 --- a/references/downloads.md +++ b/references/downloads.md @@ -1,72 +1,27 @@ -# Authorized download workflow +# AutoUP authorized download workflow -## Contents +Only process media the user is authorized to download and reuse. AutoUP does not bypass DRM, paywalls, private-video access, geographic restrictions or account security. -- Preconditions -- Dependencies -- Manifest use -- Cookie and proxy handling -- Subtitle selection -- Failure handling -- Copyright boundary +## S1 verification -## Preconditions +`yt-dlp` metadata inspection records: -Download only when the user has authorized the action and has a lawful basis to use the source. Public viewing does not imply permission to download, republish, or monetize. +- source URL and video ID; +- title/channel; +- duration and highest available height; +- exact selected English subtitle language; +- whether that subtitle is manual or automatic. -Never bypass DRM, paywalls, private-video access, regional controls, bot protections, or account security. Do not install credential helpers or browser automation packages without explicit approval. +The batch verifier can use bounded concurrency via `download.verify_workers`. -## Dependencies +## S2 download -The download script expects PowerShell 5.1+, `yt-dlp` on `PATH` or supplied with `-YtDlp`, and `ffmpeg` on `PATH` or supplied with `-FfmpegLocation`. +The exact subtitle language selected in S1 is passed into S2. Video download is capped by `download.max_height`. -Use an up-to-date `yt-dlp`. Record the version in troubleshooting notes. +If the resulting container is not MP4, AutoUP uses FFmpeg to remux with stream copy. If stream copy cannot produce a valid MP4, it transcodes. It never changes a `.webm` or `.mkv` extension to `.mp4` without a real container conversion. -## Manifest use +Subtitles are normalized to `字幕/字幕.srt`. If a download leaves VTT only, FFmpeg conversion is used as a bounded fallback. -Start with: +## Local settings -```powershell -powershell -ExecutionPolicy Bypass -File scripts/download_from_manifest.ps1 ` - -Manifest topics.csv ` - -OutputRoot "D:\自动剪辑\AutoYY-20260101-示例项目" ` - -PlanOnly -``` - -Review the plan, then remove `-PlanOnly` to download. Optional parameters: - -```powershell --YtDlp "D:\Tools\yt-dlp.exe" --FfmpegLocation "D:\Tools\ffmpeg\bin" --CookiesFromBrowser "chrome" --Proxy "socks5://127.0.0.1:1080" --MaxHeight 1080 --MinHeight 720 -``` - -Do not hardcode a proxy, browser, executable, or disk path into the manifest. - -## Cookie and proxy handling - -Use browser cookies only after explicit authorization. Ask the user to close Chrome if the cookie database is locked. Never print, copy, or persist cookie contents in logs or output folders. - -Use a proxy only when the user supplies or authorizes it. Report proxy failures separately from availability. - -## Subtitle selection - -Preference: manual English, automatic English, manual Chinese or another language, then automatic subtitles in another available language. - -Normalize the selected output to `字幕.srt`. Record the selected language. Do not claim English subtitles when a fallback was used. - -## Failure handling - -- Resume partial media; do not delete it automatically. -- Skip an existing playable source and SRT. -- Distinguish authentication, geo restriction, missing formats, missing subtitles, disk space, and network failures. -- Retry transient failures with bounded retries. -- If metadata succeeds but media fails, retain the verified manifest row and mark the topic blocked. -- Do not lower resolution below the user's threshold silently. - -## Copyright boundary - -Preserve source URLs, channel names, verification dates, and licensing notes. Prefer owned, licensed, Creative Commons, public-domain, or explicitly permitted material. When permission is unclear, provide research and metadata but flag production use for rights review. +Use `autoup/config.local.yaml`, environment variables or `--set` for local FFmpeg paths, proxies and output locations. Do not commit browser cookies, API keys or machine-specific paths. diff --git a/references/env-facts.md b/references/env-facts.md index f377594..ac4a581 100644 --- a/references/env-facts.md +++ b/references/env-facts.md @@ -1,21 +1,25 @@ -# AutoYY 项目关键环境事实(2026-08) +# AutoUP 环境约定 -内存条目已精简,完整事实存此。修改/维护 AutoYY 时先读本文件。 +AutoUP 不绑定任何开发者电脑路径。仓库提交的 `autoup/config.yaml` 只包含可移植默认值。 -## 项目位置与同步 +## 配置优先级 -- 项目根目录:`D:\@kaifa\AutoYY` -- Git 仓库:`DaBaoAgent/AutoYY`(GitHub,HTTPS 被墙,走 SSH 443 端口) -- 本机 codex + hermes 均通过 **junction 指向**同一份源码 —— 任意一端 commit + push 即多端同步;另一台机器只 pull 即可 -- 注意:junction 方案下不要用删除目录的方式重装,会破坏链接 +1. `autoup/config.yaml`:仓库默认值; +2. `autoup/config.local.yaml`:本机覆盖,已加入 `.gitignore`; +3. `AUTOUP_*` 环境变量; +4. 命令行 `--set key=value`:仅本次运行覆盖。 -## 文案标准 +可从 `autoup/config.example.yaml` 复制一份本机配置。 -- 口播文案:4500-5500 字 / 纯中文 / 去 AI 味 -- 具体标准见本技能 `references/content-style.md`(或 creative 下文案类技能) +## 必要环境 -## 已知坑 +- Python 3.11+ +- FFmpeg / ffprobe +- 一个 OpenAI 兼容的 LLM API;默认配置使用 GLM +- Edge TTS 可直接作为免费 TTS;GPT-SoVITS 为可选本地引擎 -- `read_file` 对中文 txt 会误判为二进制 → 用 python 读(`open(..., encoding='utf-8')`) -- 委托子代理输出路径必须写 E 盘(曾误写 `D:\@油管二创素材` 建出空骨架需清理) -- delegation 已放宽:1800s / 150 轮 +API Key 使用环境变量,例如 `GLM_API_KEY`,不要提交到仓库。 + +## 开发与验收 + +依赖与工具统一由 `pyproject.toml` + `uv.lock` 管理。CI 在 Linux 做完整 lint/test/coverage,在 Windows 做安装和核心 smoke test。 diff --git a/references/tool-map.md b/references/tool-map.md index 1610e6a..70c6407 100644 --- a/references/tool-map.md +++ b/references/tool-map.md @@ -1,56 +1,33 @@ -# End-to-end workflow and tool map - -## Contents - -- Operating principles -- Complete stage map -- Supporting skills -- Local tools and resources - -## Operating principles - -- Inspect first, mutate only the requested scope, and validate before reporting completion. -- Use current web checks for volatile YouTube metadata and record the verification date. -- Ask for explicit authorization before browser cookies, copyrighted downloads, package installation, proxy use, overwriting approved assets, or external publishing. -- Keep raw source facts separate from editorial inference. -- Resume from verified artifacts instead of restarting a batch. - -## Complete stage map - -| Stage | Main action | Tools | Supporting skills | Output or gate | -|---|---|---|---|---| -| 0. Scope and inventory | Resolve roots, requested stages, counts, formats, permissions, and existing files | PowerShell, `rg`, task plan | AutoYY | Confirmed scope with no accidental overwrite | -| 1. Performance audit | Read every platform screenshot, transcribe visible metrics, deduplicate overlap, normalize topics | Image viewer, visual OCR, filesystem search; spreadsheet tooling for tables | Spreadsheets when aggregation is substantial | Audited raw records and platform/topic summary | -| 2. Topic planning | Extract winning audience promises; allocate 70% proven, 20% adjacent, 10% experiments; score candidates | Structured analysis, spreadsheet formulas or scripts | AutoYY, Spreadsheets when useful | Topics scoring 20/25 or a documented exception | -| 3. Source research | Find direct YouTube videos, verify duration, views, resolution, subtitles, match, channel, date, and rights note | Web search/open, browser inspection, metadata-only `yt-dlp` checks | In-app Browser; Chrome only for explicitly authorized signed-in state | Verified 30+ minute source rows | -| 4. Report and manifest | Translate titles, write the topic report, and create the UTF-8 download manifest | File editing, CSV tooling | AutoYY, Spreadsheets for large manifests | Approved report and manifest | -| 5. Authorized download | Plan, download, merge HD media, select subtitles, normalize filenames, and resume partials | PowerShell, `scripts/download_from_manifest.ps1`, `yt-dlp`, `ffmpeg` | Chrome only when cookies are explicitly authorized | One playable HD source and one SRT per topic, or a blocked reason | -| 6. Voiceover writing | Read the full transcript, draft factual spoken Chinese, run the mandatory Humanizer Embedded-mode pass, then recheck facts and oral rhythm | Filesystem text reading, reliable web sources for needed context, bundled `vendor/blader-humanizer/SKILL.md` | AutoYY, Humanizer | Pure 4,500–5,500-character final voiceover with no production labels, audit notes, fabricated details, or obvious AI patterns | -| 7. Publication information | Write or recursively refresh a factual Douyin-style title and five tags | File editing, PowerShell for safe bulk rewrites, `scripts/validate_publication_info.py`, `rg` for legacy fields | AutoYY | Exactly two lines; title ≤25 characters; exactly five hashtags | -| 8. Cover production | Create matching 3:4 and 4:3 topic covers or 1:1 and 4:3 collection covers | Image generation, image viewer, dimension inspection, approved assets | Imagegen | Exact Chinese text, approved typography, correct ratios | -| 9. Package validation | Check media, subtitles, scripts, publication files, covers, ratios, duplicates, and zero-byte files | `scripts/validate_deliverables.py`, `scripts/validate_publication_info.py`, manual visual review | AutoYY | Complete/incomplete/blocked counts | -| 10. Handoff and resume | Report output paths, manifest, exceptions, and safe restart point | Filesystem inventory and logs | AutoYY | Traceable handoff without redoing completed work | - -## Supporting skills - -- **AutoYY:** Orchestrate the entire documentary workflow and enforce stage gates. -- **Humanizer:** Mandatory Embedded-mode rewrite and audit for every generated or revised voiceover; preserve facts and output only the final spoken text. -- **Imagegen:** Generate or edit raster covers from approved 3:4, 4:3, and collection references. -- **Spreadsheets:** Consolidate screenshot metrics, calculate medians/outliers, score topics, and manage large manifests. -- **In-app Browser:** Inspect public pages and visible YouTube metadata. -- **Chrome control:** Use existing signed-in state only when necessary and explicitly authorized. -- **Skill creator:** Update AutoYY itself, add reusable resources, and validate the skill package. - -## Local tools and resources - -- **PowerShell:** Inventory Windows folders, perform exact-path batch operations, run download and validation scripts. -- **`rg`:** Find files and detect legacy publication fields or prohibited script phrases. -- **Web search/open:** Verify current source links, public views, availability, and official facts. -- **`yt-dlp`:** Inspect formats and download authorized source media and subtitles. -- **`ffmpeg`:** Merge video/audio and normalize playable outputs. -- **`scripts/download_from_manifest.ps1`:** Execute resumable downloads from the approved manifest. -- **`scripts/validate_publication_info.py`:** Recursively validate strict two-line publication metadata. -- **`scripts/validate_deliverables.py`:** Validate complete topic packages. -- **`vendor/blader-humanizer/SKILL.md`:** Bundled Humanizer v2.9.1 rules and Embedded-mode process used as the mandatory voiceover editorial gate. -- **`vendor/blader-humanizer/LICENSE`:** MIT license for the bundled upstream Humanizer skill. -- **Approved cover assets:** Lock typography, colors, outlines, shadows, spacing, and aspect-ratio identity. +# AutoUP tool map + +AutoUP deliberately stays a small Python CLI rather than introducing a database, queue, WebUI or distributed worker system. + +| Stage | Main implementation | External dependency | +|---|---|---| +| S1/S2 | `autoup/stages/download.py` | yt-dlp, FFmpeg | +| S3 | `autoup/stages/script.py` | OpenAI-compatible LLM | +| S4 | `autoup/stages/dub.py`, `autoup/tts/` | Edge TTS or GPT-SoVITS | +| S5 | `autoup/stages/match.py` | OpenAI-compatible LLM | +| S6 | `autoup/stages/render.py` | FFmpeg, pydub | +| S7 | `autoup/stages/publish.py` | OpenAI-compatible LLM | +| S8 | `autoup/stages/cover.py` | Pillow, FFmpeg | +| S9 | `autoup/stages/validate.py` | Pillow, ffprobe | + +## Design constraints + +- Source subtitles are the factual boundary for generated narration. +- LLM output is always treated as untrusted structured input and validated in Python. +- Missing semantic matches fail closed rather than receiving arbitrary footage. +- TTS cache reuse is fingerprint-based, not filename-based. +- BGM random mode is deterministic per topic. +- NVENC capability probing is cached; CPU encoding remains the portable fallback. +- Source verification can use bounded concurrency, while full topic production remains serial by default. +- Every expensive stage writes elapsed seconds to `state.json.metrics`. + +## Development + +```bash +uv sync --all-extras --locked +uv run ruff check autoup tests +uv run pytest --cov=autoup +``` diff --git a/references/workflow-spec.md b/references/workflow-spec.md index 2857a46..f5c8121 100644 --- a/references/workflow-spec.md +++ b/references/workflow-spec.md @@ -1,85 +1,57 @@ -# Workflow specification - -## Contents - -- Stage gates -- Manifest schema -- Performance audit fields -- Topic scoring -- Source acceptance -- Folder acceptance -- Batch handoff - -## Stage gates - -1. **Audit complete:** every readable screenshot is processed, overlap is removed, missing values stay blank. -2. **Topic approved:** the topic has a clear audience promise and an evidence-backed score. -3. **Source approved:** URL, metadata, duration, resolution, content match, subtitle status, and rights note are verified. -4. **Media ready:** a playable HD source and an SRT exist, or the topic is explicitly marked blocked. -5. **Text ready:** the script is grounded, speakable, within target length, has completed the bundled Humanizer Embedded-mode pass, and passes a post-humanization fact check. The verified `爆款口播稿.txt` is the only retained voiceover copy; superseded script drafts are removed after successful promotion. -6. **Publication ready:** every `发布信息.txt` has exactly two non-empty lines, a factual Douyin-style title of at most 25 characters, and exactly five topic-specific hashtags. -7. **Cover ready:** both ratios, exact Chinese text, and reference style pass visual inspection. -8. **Package complete:** deterministic validation and manual relevance checks pass. - -## Manifest schema - -Use UTF-8 CSV with these columns: - -| Field | Meaning | -|---|---| -| `id` | Stable two-digit sequence | -| `folder_name` | `NN-中文选题` | -| `topic_cn` | Canonical Chinese topic | -| `video_title_cn` | Accurate Chinese translation | -| `original_title` | YouTube title verbatim | -| `url` | Direct watch URL | -| `channel` | Channel name | -| `duration_seconds` | Integer | -| `max_height` | Highest verified video height | -| `view_count` | Dated public view count | -| `verified_at` | ISO date | -| `source_language` | Primary language | -| `subtitle_language` | Preferred or available language | -| `match_grade` | `exact`, `high`, `adjacent`, or `reject` | -| `rights_note` | License/permission status | -| `notes` | Editorial or download note | - -## Performance audit fields - -Keep raw and normalized values separate: platform, screenshot, capture time, displayed date, raw title, canonical topic, views/reads, impressions, likes, comments, duration, and visible notes. - -For each platform calculate sample count, total visible consumption, median, and top outliers. Explain that cross-platform totals combine different metrics and are not unique-user counts. - -## Topic scoring - -- **Danger/conflict:** clear stakes or failure cost. -- **Visual density:** new usable evidence or imagery every 10–20 seconds. -- **Counterintuitive insight:** one sentence overturns a common assumption. -- **Numeric scale:** money, time, people, distance, or size clarifies stakes. -- **Source/rights:** credible, high-quality, accessible source with usable authorization. - -Use 20–25 as production-ready, 16–19 as conditional, and below 16 as reject or research-only. - -## Source acceptance - -Accept only when the URL opens as a single video, duration and resolution meet thresholds, the main narrative supports the proposed angle, source credibility is cross-checkable, subtitle availability is known, and the rights note is recorded. - -An adjacent source may supply footage but cannot be the only factual basis for an exact-topic script. - -## Folder acceptance - -Confirm: - -- exactly one primary source video under a standard name; -- at least one readable SRT; -- one publication title of at most 25 characters and exactly five hashtags on a separate second line; -- no headings, production notes, calls to action, repeated long paragraphs, assistant route markers, or unjustified AI-template sentence shells in the voiceover; -- exactly one retained voiceover deliverable named `爆款口播稿.txt`, with no superseded `爆款钩子文案.txt` or versioned `爆款口播稿-*` copies; -- the voiceover contains only final spoken text and shows no skipped Humanizer gate, exposed audit notes, fabricated details, or obvious template language; -- both requested covers have correct ratios and exact in-image text; -- no zero-byte files; -- no silently overwritten approved assets. - -## Batch handoff - -Report complete and incomplete counts, missing items, blocked reasons, subtitle fallback languages, script character range, cover ratio results, manifest path, and output root. +# AutoUP workflow specification + +## S1-S9 stage gates + +| Stage | Input | Output | Hard gate | +|---|---|---|---| +| S1 source verify | URL manifest | verification CSV | duration, resolution and English subtitle availability pass | +| S2 download | approved URL | source video + `字幕/字幕.srt` | real playable video and selected subtitle exist | +| S3 script | SRT | `文案/爆款口播稿.txt` + `script_map.json` | every generated section is grounded in its subtitle window and target length passes | +| S4 dub | script | sentence WAVs + `timing.json` | every sentence audio exists and carries an input fingerprint | +| S5 match | SRT + timing | `edit_decision.json` | one semantic picture segment per sentence, all covered, chronological, non-overlapping | +| S6 render | source + timing + edit decision | `成片.mp4` | complete S5 mapping required; video/audio/subtitle render succeeds | +| S7 publication | script | CN/EN publication files + cover copy | strict format validation passes; otherwise fail closed | +| S8 cover | source + edit decision + cover copy | 9:16 / 16:9 / 1:1 PNGs | exact 6/8-character cover copy and exact pixel dimensions | +| S9 validate | all outputs | `验收报告.json` | every delivery contract passes | + +## Resume semantics + +`state.json` stores each stage status, input fingerprint, completion time, failure detail and measured stage duration. A stage is reusable only when: + +- its status is `done`; +- required artifacts still exist; +- the current input/config fingerprint equals the stored fingerprint. + +Changing an upstream file, model setting, voice, rendering config or other stage dependency invalidates the affected stage automatically. This avoids silently reusing stale outputs. + +## Partial execution + +`--only s3,s4,...` runs exactly the selected stages. S9 is not implicitly forced for a partial rerun. A full run includes S1-S9. + +A batch exits non-zero if any selected topic fails, unless `--allow-partial` is explicitly supplied. + +## Topic output + +```text +<batch>/<NN-topic>/ +├── 素材/高清源视频.mp4 +├── 字幕/字幕.srt +├── 文案/爆款口播稿.txt +├── 文案/script_map.json +├── 配音/*.wav +├── 配音/timing.json +├── edit_decision.json +├── 成片工程/ +├── 成片.mp4 +├── 发布/ +│ ├── 国内平台.txt +│ ├── 海外平台.txt +│ ├── 封面文案.txt +│ └── 发布信息.json +├── 封面/ +│ ├── 封面-9x16.png +│ ├── 封面-16x9.png +│ └── 封面-1x1.png +├── state.json +└── 验收报告.json +``` From a3a9bf89cd7182b5dfb0071cd6bcb72469d40e81 Mon Sep 17 00:00:00 2001 From: DaBao <xxx139139@gmail.com> Date: Sat, 3 Oct 2026 23:44:55 +0800 Subject: [PATCH 10/12] build: generate reproducible uv lockfile --- .github/workflows/ci.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86af425..24524b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: pull_request: permissions: - contents: read + contents: write jobs: quality: @@ -29,6 +29,16 @@ jobs: run: uv run ruff check autoup tests - name: Tests run: uv run pytest -q --cov=autoup --cov-report=term-missing --cov-report=xml + - name: Commit generated lockfile + if: success() && github.event_name == 'push' && github.ref_name == 'refactor/phase1-5-cleanup' + run: | + git add uv.lock + if ! git diff --cached --quiet; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "build: lock dependencies" + git push + fi - name: Upload lockfile if: always() uses: actions/upload-artifact@v4 From b9ba72ba8213ff6166284500761ccfa3ba6b07b3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:45:17 +0000 Subject: [PATCH 11/12] build: lock dependencies --- uv.lock | 1363 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1363 insertions(+) create mode 100644 uv.lock diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a6bcfb3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1363 @@ +version = 1 +revision = 5 +requires-python = ">=3.11" + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +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/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]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "autoup" +version = "0.2.0" +source = { editable = "." } +dependencies = [ + { name = "edge-tts" }, + { name = "pillow" }, + { name = "pydub" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "yt-dlp" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "edge-tts", specifier = ">=7.0.0" }, + { name = "pillow", specifier = ">=10.0" }, + { name = "pydub", specifier = ">=0.25.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "requests", specifier = ">=2.31" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.12" }, + { name = "yt-dlp", specifier = ">=2026.8.19" }, +] +provides-extras = ["dev"] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6", size = 370642, upload-time = "2026-09-30T04:35:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5", size = 259280, upload-time = "2026-09-30T04:35:08.448Z" }, + { url = "https://files.pythonhosted.org/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74", size = 245335, upload-time = "2026-09-30T04:35:10.104Z" }, + { url = "https://files.pythonhosted.org/packages/74/75/77c1c479b09ecd751d1e767b251ea5c14d4d50ff757bf404afab2692f600/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab", size = 291707, upload-time = "2026-09-30T04:35:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0d/363f78cacb70f58f15f4b083961bbd9d292f335d3f5c66fc4f1cfe69cb90/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c", size = 286747, upload-time = "2026-09-30T04:35:13.022Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ed/cf505d3011ffceb12c2067a7a5d3cfe92b875d4d44bb0ff0d69375e2c184/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f", size = 269972, upload-time = "2026-09-30T04:35:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/15/d8/f0a93a431d170e7ca681d4f6650fee3de934d18560e474e7267eb4b0f987/charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288", size = 269339, upload-time = "2026-09-30T04:35:16.087Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/9b2bd1c5b7af02462c9752d33994834ff972a96b4c483eefde9e594488e2/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400", size = 261683, upload-time = "2026-09-30T04:35:17.488Z" }, + { url = "https://files.pythonhosted.org/packages/76/a5/cac540ab0fd61f3fec88ad3dbb64509e71424593d73cfdfff5ab3e4db279/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd", size = 248143, upload-time = "2026-09-30T04:35:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/ff467301deef2089fad87f72df9e000a26a78fec7acbb18e1999371b8369/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37", size = 292358, upload-time = "2026-09-30T04:35:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/ad/77/22d7e785d1e210afc2e2f58600dd1799d17a35665faf84383f002826c5f8/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac", size = 268766, upload-time = "2026-09-30T04:35:21.72Z" }, + { url = "https://files.pythonhosted.org/packages/ae/91/e8e946267f1c2d9e2bd651726e2fbd2addf02c4d36cea5069e32ca9d7bb5/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a", size = 287432, upload-time = "2026-09-30T04:35:23.273Z" }, + { url = "https://files.pythonhosted.org/packages/4e/88/7561d8a88d555e7df6623abe7c0070b4baf47549b9408783a2ae0a1a6cf7/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640", size = 272470, upload-time = "2026-09-30T04:35:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/35/7e/578c702301ec036f01455f30744a08d2b42f6ab35b9b2d4bf8cae0ef2a80/charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d", size = 186688, upload-time = "2026-09-30T04:35:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fc/fdf8cf52ff21cd5bf158f20978991cf985325842f74283eb6df26c8a39d8/charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96", size = 214932, upload-time = "2026-09-30T04:35:27.796Z" }, + { url = "https://files.pythonhosted.org/packages/97/66/3e45a506d8110b632541faf9a9470185aa9878f1ed44020f31346c1c5e5b/charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1", size = 202828, upload-time = "2026-09-30T04:35:29.259Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/fa/ce3baf63d85b730398d92a7162f486f3a5e4e2cc3382a02488b3943725ba/coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d", size = 223797, upload-time = "2026-09-27T12:25:54.756Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/9ba29c2aac7f756d479f03d45762120060f0f988788001001bf36e0e6fca/coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5", size = 224303, upload-time = "2026-09-27T12:25:56.214Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/0d6d60906dca7d28cc1e3fce12a9861801c4fbb6cbf220ad78cd059c9467/coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b", size = 254715, upload-time = "2026-09-27T12:25:57.755Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b8/9198b865679379fb165c689c64f6e11105ef380f6bd1c7673e83f73d9f5c/coverage-7.16.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db5f8394e17f877a625b257f2ba0ce8e728a499c2c1579ad66220272cd3df510", size = 256627, upload-time = "2026-09-27T12:25:59.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/79/9521462cb6072fe394701bc8974b74afd576c9c9355156c7844e1a86a42b/coverage-7.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3146d2317c75f70df2509066d979dadd941f7021cdf9b5db4bcd8568258e25", size = 258724, upload-time = "2026-09-27T12:26:00.691Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/8d7d5d633db9fe0f3182fedc731bf09f9bcf2366055735152504ad614677/coverage-7.16.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d0ced76318bab499693ff25f64faa343415187cb2e4d7befdfdd391a1cf6a", size = 260692, upload-time = "2026-09-27T12:26:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a7/76cb09c89ba46d74d37428bf93251fc14fb0bbe9e05cc2a5ef61773d318a/coverage-7.16.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:af98ad5ed9d6daaca956201e00bb429a7eb2b080426686f70a20353e0f9839f5", size = 254800, upload-time = "2026-09-27T12:26:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/72/b6/2351c1979aaeb5b4a8091a75b90ca997ad60de36e181ddba267cf61dac97/coverage-7.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d56e4d21c56d2046447733f8b118409597db48c01efe898ee9ac24e858ec2d6", size = 256423, upload-time = "2026-09-27T12:26:04.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/ad9a4f8b5cb2d494fa9452b546fe742ed2f9d3847cc14c05e36279a3e649/coverage-7.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1d5d0e3b660506fb84f995814e3118a21efdc0c8eb80127da1be627d90093c17", size = 254487, upload-time = "2026-09-27T12:26:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1f/a520470472f3e8b01169bf42162b1470c9ba992230432f62ca36269bf3a0/coverage-7.16.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:17228fbca0f22976f797be94e975dcd237799c657d49551c7de1e0654d1202e9", size = 258259, upload-time = "2026-09-27T12:26:07.513Z" }, + { url = "https://files.pythonhosted.org/packages/09/d2/ff26d5938274745855fa61cfcba0245c88ccc10d98d2cbd96064f16cd5a7/coverage-7.16.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc0b0ac781d489304b741269857f1f8338b7a26b1b89c06c0344658001ec0035", size = 254216, upload-time = "2026-09-27T12:26:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/5d832d3b06785d9f53267e4f2724a9f60c312eee6ebed9063a461d0d3b45/coverage-7.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bf1bd822ec4e387ed245bed0d71151582cf7be9e5309bc4145eefe36083d5878", size = 255125, upload-time = "2026-09-27T12:26:10.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/4d/1d33edbc2fcf7d99e384e393e712aa5a2ebbbd8409825357815982207976/coverage-7.16.2-cp311-cp311-win32.whl", hash = "sha256:7ed238d227e23cc300c3d464babdaf9f6ddc740aa1b15a77ae96136e6a7c4516", size = 225898, upload-time = "2026-09-27T12:26:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7c/676df4882118756c4f8f560c954eddb93e166d84dda8c5f0b6a829689bde/coverage-7.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:a90700f743e29aa3d75a6ff5f01953176a889c00e526194bc4d281731b88d99d", size = 226369, upload-time = "2026-09-27T12:26:13.375Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0e/a457f4a461b3c5610d845137fdd45fa465e011a64c25af440518ab1f4e41/coverage-7.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:a336eec40e3520d369b8a6cdabb4f596e69a8b42927ca074aa1452fed943238a", size = 226344, upload-time = "2026-09-27T12:26:15.127Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969, upload-time = "2026-09-27T12:26:16.753Z" }, + { url = "https://files.pythonhosted.org/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328, upload-time = "2026-09-27T12:26:18.15Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832, upload-time = "2026-09-27T12:26:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571, upload-time = "2026-09-27T12:26:21.199Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680, upload-time = "2026-09-27T12:26:22.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945, upload-time = "2026-09-27T12:26:24.336Z" }, + { url = "https://files.pythonhosted.org/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191, upload-time = "2026-09-27T12:26:25.89Z" }, + { url = "https://files.pythonhosted.org/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610, upload-time = "2026-09-27T12:26:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757, upload-time = "2026-09-27T12:26:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822, upload-time = "2026-09-27T12:26:30.515Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325, upload-time = "2026-09-27T12:26:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191, upload-time = "2026-09-27T12:26:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034, upload-time = "2026-09-27T12:26:35.021Z" }, + { url = "https://files.pythonhosted.org/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569, upload-time = "2026-09-27T12:26:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372, upload-time = "2026-09-27T12:26:38.406Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" }, + { url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" }, + { url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" }, + { url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" }, + { url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" }, + { url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" }, + { url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" }, + { url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" }, + { url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" }, + { url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" }, + { url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" }, + { url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" }, + { url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" }, + { url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "edge-tts" +version = "7.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "certifi" }, + { name = "tabulate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/60/afbf548b43c78355e03926c6b1fff7500303a2da4d84db9e1324119e21ae/edge_tts-7.2.8.tar.gz", hash = "sha256:fcf185a0d527a0d2d003f9d5841facc1d5e0e7b3b88d5df9c32990402c6b8cd0", size = 27875, upload-time = "2026-03-22T19:57:50.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/2b/a8cb687b92a2690d2ad171f0c2fd1c8f18690363cca7618bab2bbe4cdf2b/edge_tts-7.2.8-py3-none-any.whl", hash = "sha256:361fe48ce7ef613adbe30f664e3765dd71029c6cb57427279eff8ad6df2eb211", size = 31026, upload-time = "2026-03-22T19:57:49.672Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "multidict" +version = "6.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/24/3823efc330630a1f132bcd0a9b182ddec3c53f452f551c8e8d3aaf5a2d20/multidict-6.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4", size = 96739, upload-time = "2026-09-21T17:54:42.815Z" }, + { url = "https://files.pythonhosted.org/packages/6a/69/36331fe1d3aeb0c525a9972d6cf82791075a395aa96f026f060009d691ae/multidict-6.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987", size = 58514, upload-time = "2026-09-21T17:54:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/5860651f782078ef13f2761c2593f4ba066d76f626b2f30f23a7f838b775/multidict-6.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c", size = 57303, upload-time = "2026-09-21T17:54:45.304Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9f/c56e2fa223cb5d182660e1a09eec38880f96b7cd22769582f74447c60418/multidict-6.9.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1", size = 320076, upload-time = "2026-09-21T17:54:46.826Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/eda4fe0cad51f96096363ed32d3e0f8df28dab00beff4d92adc9db517726/multidict-6.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07", size = 315489, upload-time = "2026-09-21T17:54:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/14/10b1ded0a4085a51c3054125f854da4f47ce4fec724a2d2503231b83f3db/multidict-6.9.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1", size = 296716, upload-time = "2026-09-21T17:54:49.749Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/04d155dd18528443cb9009f3a9f35f7417773797d072c95104ff17b8b9b7/multidict-6.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3", size = 330244, upload-time = "2026-09-21T17:54:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/8b/90/284d10b3a9e5b312a8ec5b7a175fc7d560eefdc99886320449891a197992/multidict-6.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80", size = 331288, upload-time = "2026-09-21T17:54:52.595Z" }, + { url = "https://files.pythonhosted.org/packages/27/50/f420de3683f9b047fc5588064d84043ef57a7152ef451b8373dfc7068d22/multidict-6.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387", size = 320663, upload-time = "2026-09-21T17:54:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/01/ab/0120a650d7ce4fe167299c13a13cc4ee2dc72458a9c0e9e9330b824b73c9/multidict-6.9.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345", size = 291139, upload-time = "2026-09-21T17:54:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/c66342f73ee5cd2c9b1dda6cfce89b5f348c48921b8c8b6e59aafbbfe31a/multidict-6.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd", size = 310044, upload-time = "2026-09-21T17:54:56.745Z" }, + { url = "https://files.pythonhosted.org/packages/05/0d/e44b90d9e77e44ec3473c4c7ac7ea95764d7e8fbf6d5a085ef9bff7ce0c2/multidict-6.9.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f", size = 303279, upload-time = "2026-09-21T17:54:58.178Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/7741f7c23c211fae31ab546f0ba41569dae7671cf7ca30d5afb59e9d92aa/multidict-6.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b", size = 326177, upload-time = "2026-09-21T17:54:59.825Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/0cdbfbbe36316b2ef4b8e011afaca2cf291a73fe4fabf34dd0b22e3ee48a/multidict-6.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82", size = 323526, upload-time = "2026-09-21T17:55:01.43Z" }, + { url = "https://files.pythonhosted.org/packages/98/05/0c86e9678e78f4596df0ed7f59d2aa755107da3f2a92f1b7fa150e94cbb8/multidict-6.9.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953", size = 290278, upload-time = "2026-09-21T17:55:02.993Z" }, + { url = "https://files.pythonhosted.org/packages/95/8e/71bd7f43c4883cba6c5f96db88bd2311d52e74ad7680be5915938490d56f/multidict-6.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a", size = 321142, upload-time = "2026-09-21T17:55:04.48Z" }, + { url = "https://files.pythonhosted.org/packages/2d/00/bf59d6bb22a4c152e4039574c29490caf3c65107715121807b4bd7cde244/multidict-6.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13", size = 319045, upload-time = "2026-09-21T17:55:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/f27f4f045198bf1c4bcd6eb3b6cf06be2dceaceb6e67222b903fce7a6749/multidict-6.9.1-cp311-cp311-win32.whl", hash = "sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f", size = 51309, upload-time = "2026-09-21T17:55:07.531Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/c2064aef64efc007bc89a571f843e5772f27946eb9bc018731bf8016e319/multidict-6.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e", size = 59148, upload-time = "2026-09-21T17:55:08.936Z" }, + { url = "https://files.pythonhosted.org/packages/45/a1/3bab1827edd813dfb90712af5cbb7fc1473ed4d9c871d103df4f4bb950c2/multidict-6.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d", size = 54258, upload-time = "2026-09-21T17:55:10.191Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" }, + { url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" }, + { url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" }, + { url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" }, + { url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" }, + { url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" }, + { url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" }, + { url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" }, + { url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" }, + { url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" }, + { url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" }, + { url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" }, + { url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" }, + { url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" }, + { url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" }, + { url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" }, + { url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" }, + { url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" }, + { url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" }, + { url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" }, + { url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" }, + { url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/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]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/40/14b21e505b7921617466576423f188a5c9caddfdaa1cf4b2b8a83d8fe216/propcache-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:897d1ddf6716e8f47200f7aad9a0efa6cc7586df66c6defa572f9eab379c078e", size = 86393, upload-time = "2026-09-16T00:14:06.9Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4b/5a52e1a7b43563f7d408814194bb23cc8bf214eb6b86639b667a33a8d0d0/propcache-0.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9cbfff4423eef4cc6cafc021469641a2b835f610b2647a6c5281903e21b8670d", size = 50431, upload-time = "2026-09-16T00:14:08.025Z" }, + { url = "https://files.pythonhosted.org/packages/05/cf/b5248180bf056cc76acc60c9c6e8c0ebbfdbd1c6cffd31fd14996927b7c8/propcache-0.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fc24f209c1b7f7f688b66b98293954f5504279760999b58920ee12dd8471c1d", size = 52116, upload-time = "2026-09-16T00:14:09.114Z" }, + { url = "https://files.pythonhosted.org/packages/86/a8/7c6cd6bfead1a11f2e411e688640e6d26574cb0bde7dcaa7423b0b65ed7a/propcache-0.5.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62530ca89187827e4a4fe733f971abe81a7542eeea48ff61995f19b64d7199c8", size = 238729, upload-time = "2026-09-16T00:14:10.357Z" }, + { url = "https://files.pythonhosted.org/packages/5c/b4/442715b2e980df51be52d203549279e027728f24c80b00b5e525e31cd5ea/propcache-0.5.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fc3f7599528db40b1efa0889a620116e2704144495273d66066e8164e45838", size = 246121, upload-time = "2026-09-16T00:14:11.735Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5d/df0684fc2b1732a01a7bec26d7897369022712422d25b09c37ce7dbc88a2/propcache-0.5.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f2d880ff60f45898f4acfa152aac8d04e3ee627d90ff4003491bf92239d5757", size = 251735, upload-time = "2026-09-16T00:14:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/c7/06/519a5ebb48b6f94beb48396e55c905f12246a25c3a3608a7ec7bceabf50e/propcache-0.5.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9368e87a3efc285e559131092c5db643eb8e56de4ee42064d5baec22ef2bb5", size = 235381, upload-time = "2026-09-16T00:14:14.398Z" }, + { url = "https://files.pythonhosted.org/packages/3c/07/1e0a9bb310830f2245edbd5cd3c6d24a783c053c4efd8e08e386e513c940/propcache-0.5.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:004e685b315646c410771836e72a44f143bbe624f29653a42687815069a303d5", size = 208973, upload-time = "2026-09-16T00:14:15.715Z" }, + { url = "https://files.pythonhosted.org/packages/62/5c/9324fab27d6088eecc47fe4332bf7aaf8c1ded93c36f558391e8a06d41a7/propcache-0.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:594eb4c6ec35e7179b058481f4e9f02521b56de16fa577c4b85c76fb1bf8a9f8", size = 233897, upload-time = "2026-09-16T00:14:17.25Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a3/570d92fc952eae93b676f3a1568f4b89264102abd3c982ab6a9ebec58dcf/propcache-0.5.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2dba2f02d2d5c09ef8a0e6c1a42aeaa451f4be9898cb00b04fe98717da2eb23b", size = 223512, upload-time = "2026-09-16T00:14:18.87Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/26810d889d89bba31db397e6a88f8984af775f5ed6bad0a29dce84324cff/propcache-0.5.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c3ef2818d63bc86071e9d2989ae75a1bc32b8f7059cfd9f5abbbee70c32e2ed6", size = 239043, upload-time = "2026-09-16T00:14:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/f9c47691aa024c8299a3afacd78d22a01ab57eb627b481b6089708e71017/propcache-0.5.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:dd2ac8f5b643454c2cc6b6118b13da16e88f4a6434fc3ba61aca384029f04f36", size = 208218, upload-time = "2026-09-16T00:14:21.801Z" }, + { url = "https://files.pythonhosted.org/packages/74/6b/d510c0c378cabbf9d0ac7b663af6d00f2e9074073d93b20c85f24aa5c071/propcache-0.5.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4054acf80d40456a0537f2913b349718649d8d6458a14ab7f48d0ce28c30869d", size = 240301, upload-time = "2026-09-16T00:14:23.121Z" }, + { url = "https://files.pythonhosted.org/packages/3d/80/c80f6adaaa1e51f0db2dce8c9b3714d94ec45e358a21f9a1910b10b40a80/propcache-0.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40e94adb1e7d39ff28a8bd8d8b8fbd1df6b9f40976dbe379134f1ce058e532dd", size = 230785, upload-time = "2026-09-16T00:14:24.458Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e2/c32a7df3f39caa7f11b2eb37ea5b6960a6946f2bc7c4b8ff97bbdf6d6b6e/propcache-0.5.4-cp311-cp311-win32.whl", hash = "sha256:9f86f7259efe2c951f43e57d471c9b41daa5bfc7db9f67189059cf1ae6d77fd9", size = 42747, upload-time = "2026-09-16T00:14:25.715Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8a/3db6a3543d8101263b4c52978b6276a04ead2caff2c5ab880d934f47bd89/propcache-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:e904d4d01f36bd6e197590be1533c44e06058771e0746dd073a8ebb3ef880858", size = 46268, upload-time = "2026-09-16T00:14:26.996Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/5222e2665bbf6e45847492ecbf3b9f3e4975a0ae300e5fd465df7d48ce55/propcache-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:d42a9a856a4a6e2f6c10f1318c07e7daa498d6593abe745c71dae4521a26ca39", size = 43547, upload-time = "2026-09-16T00:14:28.143Z" }, + { url = "https://files.pythonhosted.org/packages/71/cd/348d58f142aebc4873345c6b31087629182ca6e0f2b3caeaa528cf882eba/propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429", size = 87285, upload-time = "2026-09-16T00:14:29.362Z" }, + { url = "https://files.pythonhosted.org/packages/df/f4/f3ffaee281b276da854ac1d7a6a506d26cbc62ea2e623756f1d0a4a1ba1a/propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8", size = 50984, upload-time = "2026-09-16T00:14:30.473Z" }, + { url = "https://files.pythonhosted.org/packages/25/88/1d7df7201750b37765ef2b23bc1c526c028dadde80afa0f57a118fc01182/propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21", size = 52460, upload-time = "2026-09-16T00:14:31.692Z" }, + { url = "https://files.pythonhosted.org/packages/83/4f/48865bd02a16ee5236bc46166b2946f37b93e07b0eae355dac0be0b216ca/propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef", size = 251768, upload-time = "2026-09-16T00:14:32.908Z" }, + { url = "https://files.pythonhosted.org/packages/b0/19/3742a5eed62317b03b4002ee865dc9fd720308bdd0da1f29a5786c630311/propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f", size = 257723, upload-time = "2026-09-16T00:14:34.267Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/ee6350fb0be9122bb6c67082a876d34b90d980d100c106af4b81023e04f4/propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546", size = 265597, upload-time = "2026-09-16T00:14:35.56Z" }, + { url = "https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b", size = 250424, upload-time = "2026-09-16T00:14:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/33/2c/a763a8251f50fba042af0fb1f02bfec4b31381e40aff760db2be7b2e1f84/propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab", size = 216748, upload-time = "2026-09-16T00:14:38.369Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e2/4d11bea8fd6a777149c6c20645f873952eab5de3a2497aa11648ec9ab6ab/propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc", size = 246533, upload-time = "2026-09-16T00:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/9f/36/6683597de4907e70c717e3588c541202c66086a72ff3db58be49de66e72c/propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937", size = 238173, upload-time = "2026-09-16T00:14:41.259Z" }, + { url = "https://files.pythonhosted.org/packages/85/84/cb08d79f1762daafeb2b030c470cd0c725c97b8ad67412457c6f35c53e9d/propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526", size = 251128, upload-time = "2026-09-16T00:14:42.652Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0d/41b848036db6621370c1f2e5471a7da8149c730f8552a5257567721f4576/propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944", size = 214821, upload-time = "2026-09-16T00:14:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/adfae4bf9c63bccf12e2d9690a175c6579047a6eec3b5a6a5f51428c15e2/propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031", size = 254793, upload-time = "2026-09-16T00:14:45.429Z" }, + { url = "https://files.pythonhosted.org/packages/51/6f/eeca9647245d5f92e87d53e5f14335bb42fce1a7e6842c8045b364eded8b/propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161", size = 247134, upload-time = "2026-09-16T00:14:46.976Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a9/424e38838793d37160b4379c702f61c74c598fc6cd17204adbe3c554f7a8/propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8", size = 43073, upload-time = "2026-09-16T00:14:48.338Z" }, + { url = "https://files.pythonhosted.org/packages/58/7b/6e8ef26f6d510a7916064fec68d55fcbfbdf7eb01e377480d66a122152d8/propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb", size = 46190, upload-time = "2026-09-16T00:14:49.99Z" }, + { url = "https://files.pythonhosted.org/packages/08/b9/72028c5b56ced97f456de6aefa79435ca64d7f77af78ea8cf3c76fc5195f/propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737", size = 43075, upload-time = "2026-09-16T00:14:51.155Z" }, + { url = "https://files.pythonhosted.org/packages/78/4c/3b1365d58a667689e067e13d055fcd92bdf8d9a2fca3d9201b47ed5b3631/propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba", size = 85290, upload-time = "2026-09-16T00:14:52.342Z" }, + { url = "https://files.pythonhosted.org/packages/8f/61/5f9c29c3aa67c30238c4eadf95149b1d983a48f69b86b0cff927a7d6df13/propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0", size = 50027, upload-time = "2026-09-16T00:14:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/25/7d/c1ab1ef09e9d4d835be5d58c0a32a1e1de8397abaa4e502a9d4141328cad/propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111", size = 51425, upload-time = "2026-09-16T00:14:54.826Z" }, + { url = "https://files.pythonhosted.org/packages/73/36/0093091ebb270fcd1bc1f6e095f93b2e0ed7f1011c28837dc2dbe5f96b99/propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c", size = 233595, upload-time = "2026-09-16T00:14:56.09Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/0de9d4c8e05ce0be71b436919a216bd7fc5cc6e2691c0602295efb22b9ed/propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867", size = 240318, upload-time = "2026-09-16T00:14:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/2b35e91455209b85ee98f7859583e0814fab57d3af0f2381aaee34c37304/propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9", size = 246649, upload-time = "2026-09-16T00:14:59.352Z" }, + { url = "https://files.pythonhosted.org/packages/ed/74/08e6c1faf26ee2732023a3828787ba535557122774f4a386b1f715cbd8e0/propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572", size = 234316, upload-time = "2026-09-16T00:15:00.696Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/08385733c9321c9bb78039d3ff31045e4fca962d9665023c4eb70f998819/propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8", size = 204666, upload-time = "2026-09-16T00:15:02.019Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f4/e87bc7629af9a14a752b218764a78742d73c2c563ac58315da6841f0cbe4/propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894", size = 225900, upload-time = "2026-09-16T00:15:03.394Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6d/11014938d3fe9bea2ea2dcf930f26ed565bfb2f5be3c756362ea48c92636/propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9", size = 219988, upload-time = "2026-09-16T00:15:04.811Z" }, + { url = "https://files.pythonhosted.org/packages/dc/72/fbf17c589f92c0b3bbf6709a425661f8ef2ed0d46b38985a7d7b5a0f6b91/propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47", size = 233611, upload-time = "2026-09-16T00:15:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/55/7e/dbd637572a279692e5518d117274a9331bf5faac59f191d30e82521a3ec7/propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c", size = 204333, upload-time = "2026-09-16T00:15:07.961Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5a/f99c92068f1e0f5c886899ce0e4a619db376ca98c5279d93f95bd86906af/propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1", size = 235177, upload-time = "2026-09-16T00:15:09.334Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/95456fabd2daf6be89049a13fbf03341756014d2959c83d12957d4c49694/propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c", size = 228982, upload-time = "2026-09-16T00:15:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bb/df90f62c9cf7c93ea235f6f9405143bba802914607317266dd81fc8d737e/propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25", size = 42611, upload-time = "2026-09-16T00:15:12.111Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/e0a7b84af04ec02d73a48aa71f091e1e4a2107e3074b7ce12195b66901f4/propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77", size = 45342, upload-time = "2026-09-16T00:15:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/9a/70/50b031cafe72a5c1878b903ee87303f71313345566bf3d6ec202e5ddc9ec/propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177", size = 42408, upload-time = "2026-09-16T00:15:14.788Z" }, + { url = "https://files.pythonhosted.org/packages/33/c9/07e227b930c8ae513b8ef1aae3793499be097bffcdf7aee4fb8b33db4cd1/propcache-0.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565", size = 85933, upload-time = "2026-09-16T00:15:16.073Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e1/6710bb44510c4e4a8e0f004bbaf3cecfd048141309c77bae56d4e5a6ebc1/propcache-0.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993", size = 50179, upload-time = "2026-09-16T00:15:17.377Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/b533b493d7025456f44518b33e53e000021a20fe7c27b88cf3d341df7186/propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b", size = 51942, upload-time = "2026-09-16T00:15:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/f1/74/70ac8430e28f21e442c7bcb964eb46c4363f6881ade4aa0e978bfd8d503a/propcache-0.5.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc", size = 232647, upload-time = "2026-09-16T00:15:19.905Z" }, + { url = "https://files.pythonhosted.org/packages/72/95/f222f13b6fe623310be0eb61a673bf26df439ce27e563ca8e422d0818777/propcache-0.5.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d", size = 241541, upload-time = "2026-09-16T00:15:21.3Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3e/763e370340db16115c5e63ad46e21ef0770a7f06928b3d3b62d8f8edfca4/propcache-0.5.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f", size = 245332, upload-time = "2026-09-16T00:15:22.802Z" }, + { url = "https://files.pythonhosted.org/packages/96/d3/e97cd6f5de2176bd90ed4076c7a9b5e09d0f0b9687d00a576507988bb62c/propcache-0.5.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9", size = 232757, upload-time = "2026-09-16T00:15:24.374Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4c/6766e5f60bcda26d244333aa71d0a702c1c9b21b251d543c7af5953d1eee/propcache-0.5.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf", size = 204389, upload-time = "2026-09-16T00:15:25.667Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5e/ec4bb09a70b26ea99d76a8292c3383b960b296de2b347ac9986678f1761c/propcache-0.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d", size = 228217, upload-time = "2026-09-16T00:15:27.11Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7d/b53922ba7d9e5bf797324e63aa05906ec240871899f779628df068743e2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3", size = 216947, upload-time = "2026-09-16T00:15:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/b62eee45e5ea4de094a258cbb3b01c1e856ca51ddfd95b43135c5effd1eb/propcache-0.5.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc", size = 233457, upload-time = "2026-09-16T00:15:29.977Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a9/feec61ed296d993db9dd097e0f6723e3f576a647722367547495e4c5b05c/propcache-0.5.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d", size = 204131, upload-time = "2026-09-16T00:15:31.74Z" }, + { url = "https://files.pythonhosted.org/packages/92/4d/411ef380cddad28dc001f1c6d75ec72c76cd3817030f68ec1ccfba0ec6c1/propcache-0.5.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792", size = 234820, upload-time = "2026-09-16T00:15:33.087Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/c988229753629ef1cfd5198337a83e624780ea2b3787efe9e747c05aad2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b", size = 228350, upload-time = "2026-09-16T00:15:34.533Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/5ef1c5cf98591da3c5b952b39e6a298084cc1ce353bc70f85e82397a5036/propcache-0.5.4-cp314-cp314-win32.whl", hash = "sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea", size = 43578, upload-time = "2026-09-16T00:15:35.957Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9e/a0ac821a2229186af5e2e3c3635a78abb23cfddca57f38513ab5d70420f3/propcache-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983", size = 46304, upload-time = "2026-09-16T00:15:37.655Z" }, + { url = "https://files.pythonhosted.org/packages/a1/19/c8d0d36a9d16cba5dcee67d389c9333b988c8986a653a61c00a451817a46/propcache-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac", size = 43440, upload-time = "2026-09-16T00:15:39.091Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e9/42f1da77cacfc184e6ec929557ef653b7961bbf6f1da460b9221273948b3/propcache-0.5.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0", size = 90672, upload-time = "2026-09-16T00:15:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2f/4b79940908c6ab8c795097c102999d7bc1f7e0b8604dfd1c232f9d99d67a/propcache-0.5.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5", size = 52586, upload-time = "2026-09-16T00:15:41.575Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/02196ae6320c110235bb343f90dbd34be41f8b8964a3ee30db84ec12579e/propcache-0.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d", size = 54335, upload-time = "2026-09-16T00:15:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/44/f48b9a131985659924df5fa5093f68fe72c7ee375329802989ba3126efc6/propcache-0.5.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788", size = 297567, upload-time = "2026-09-16T00:15:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/418d956d2735139f77fc35262179f1f52c23aa666de5a8ab3819c1ae7854/propcache-0.5.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56", size = 297477, upload-time = "2026-09-16T00:15:46.048Z" }, + { url = "https://files.pythonhosted.org/packages/69/fd/ff811fdb6d3d3e67fd9bbfb75881675d34a42d0ef29a45d33e3e233dde07/propcache-0.5.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574", size = 302669, upload-time = "2026-09-16T00:15:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/fc/57/527910c455b5ec62f6871bef45d4f79fea16cb8c966ba0d4a07f0339ddc4/propcache-0.5.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370", size = 287908, upload-time = "2026-09-16T00:15:48.99Z" }, + { url = "https://files.pythonhosted.org/packages/1d/86/f69ab82707534a0cb2057bdca04f9200a71214c7551800f9d34d6ac39e4f/propcache-0.5.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320", size = 249804, upload-time = "2026-09-16T00:15:50.486Z" }, + { url = "https://files.pythonhosted.org/packages/27/19/60677af50d93be4256213de7cd487f056944c048b9c0b6f2e45b3a30f666/propcache-0.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9", size = 282344, upload-time = "2026-09-16T00:15:52.029Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f7/a0057808a91fb3b6a5f3602b528f0cdcb3d53e0ff8315d73fabdfdf8fec4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7", size = 270167, upload-time = "2026-09-16T00:15:53.466Z" }, + { url = "https://files.pythonhosted.org/packages/83/c8/f4a865490df0dc0c8531d4e59ac411cb6dc24bb255d2396a6f1c60a368f4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4", size = 286551, upload-time = "2026-09-16T00:15:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/b0/67/b4faebde9da4e8173d0e5a30e8cd31335914af7ef350b988f27fec588cfd/propcache-0.5.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9", size = 249595, upload-time = "2026-09-16T00:15:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/52e1dd5636e9f5a27f6b5a4b4e2f33c322fd72afe956c397d82523ec4a80/propcache-0.5.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e", size = 286700, upload-time = "2026-09-16T00:15:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0e/30b2b324b93ff31a0bab539c102aae59e84e444031b2742150a7646aa1bb/propcache-0.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57", size = 280500, upload-time = "2026-09-16T00:15:59.487Z" }, + { url = "https://files.pythonhosted.org/packages/64/36/721bb59f682ff060d0c8df64274fca8cd0521b1a54506c2eedaef795b7f5/propcache-0.5.4-cp314-cp314t-win32.whl", hash = "sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97", size = 46121, upload-time = "2026-09-16T00:16:01.349Z" }, + { url = "https://files.pythonhosted.org/packages/c1/86/0b1b80fa1ac3a0aac44e2922a6964fbe9cd52af5eab8fa933bf9e90b030c/propcache-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12", size = 49154, upload-time = "2026-09-16T00:16:02.901Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/9fe6f05a47cb550c823155052116f710064b6be5c6e8ec4e9faae7e18115/propcache-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827", size = 46005, upload-time = "2026-09-16T00:16:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/58/25/895a11d1e4c5c2acc6d816e2bece34e02d9dc92f2182ae276cd819e9e804/propcache-0.5.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:45bf2e730ab8905d0527fe05a86500f406e64305c34cc81ebe64b4617cab9760", size = 85634, upload-time = "2026-09-16T00:16:05.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/41/c0acd69271de7a1cf439e77d5d60c18575fd09bad56e798b95fa23458ea4/propcache-0.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:31eb43ba2edc704ab2ec27815315dd8a19def0fb16215be4cfe8d32fe78ffd51", size = 50084, upload-time = "2026-09-16T00:16:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae", size = 51692, upload-time = "2026-09-16T00:16:08.956Z" }, + { url = "https://files.pythonhosted.org/packages/e9/07/057bdd3a9609ffad59b06239cceee784b047f6c720247bfaa36d2103e138/propcache-0.5.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e337653721d20ead710da33bf44487fbe8a0db8782714b60306481e9f95b51", size = 232947, upload-time = "2026-09-16T00:16:10.466Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dd/d36ad35986718530498a65e45e3713f9f0e6a580f192ef02d2ef7cae9b52/propcache-0.5.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d21d0d2c82bbfeb1677a9711f38df968f9837576102bb4add1bd449d28d88f1", size = 241250, upload-time = "2026-09-16T00:16:12.056Z" }, + { url = "https://files.pythonhosted.org/packages/fb/81/f1459415cdb6c10d46942779de39bb59a77b38e5a76bb1def9227962eb45/propcache-0.5.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccf4f7a79e26bb7efb06ecd50c177833b71df05cbc748701372325e6bcc17f6f", size = 245150, upload-time = "2026-09-16T00:16:13.596Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4e/58b9b1460afc97a4c0b17ee89af701c4011d4d7f46470eba3aaff76a8069/propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c", size = 232166, upload-time = "2026-09-16T00:16:15.126Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c6/5a79e0eda3e7b6987d03d8c622ff6d52a42165a12e8418eb37694b9cc4b4/propcache-0.5.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e738ab81179510ce79b2eac9a6ecf47feffd9e76d1c72e403005dddb6e36c06c", size = 206085, upload-time = "2026-09-16T00:16:16.713Z" }, + { url = "https://files.pythonhosted.org/packages/4e/72/940aed42c73f9da345ca2de0f6e835c726498159abca5f1ef14fb0a2af8a/propcache-0.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a419ee85e654927baabda3929c03c0cc1112bf472ff0dfd6142f4e3a81ca4162", size = 228460, upload-time = "2026-09-16T00:16:18.352Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/3f54e1535c8f323d91ba566044d7c2b39ff6f6a2f1d0bd9071779d07b9b3/propcache-0.5.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b61805357d966680acf68b3b6d49772631ed9df44ebece10ff1460e117a7da8a", size = 218350, upload-time = "2026-09-16T00:16:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/025890cc389ac3ec485ecec607d4a7ca47e15bfa2a465746ab98af602536/propcache-0.5.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:58134228927cee6c047d626c08e60a81be604a20578a12ce752cc5c9a84d4826", size = 233156, upload-time = "2026-09-16T00:16:21.624Z" }, + { url = "https://files.pythonhosted.org/packages/04/29/b39cae08c87c140d3d274f0a2c058cb5588e836175c3309e260b230ab07d/propcache-0.5.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:350b272b2279f4135a64fc0c304a5d08e28a137c9573442c606152446638a831", size = 206206, upload-time = "2026-09-16T00:16:23.204Z" }, + { url = "https://files.pythonhosted.org/packages/18/61/e16462ef18a87247dc9ebbd5c606f46d5ce67e708bd9cc734dd0d9222564/propcache-0.5.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:45bebbe252550fec975ba3b62bc6f931643cfd3b5464ef47619cf3fef154e01c", size = 234469, upload-time = "2026-09-16T00:16:24.841Z" }, + { url = "https://files.pythonhosted.org/packages/9f/84/b6a1490922427204fc47df920ed002eec709621de6b79b11592bf45c623a/propcache-0.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ada748108a43d29b7c328ba7db3755327cd94f028bcc1a7ee3f0addcfacd9c38", size = 227311, upload-time = "2026-09-16T00:16:26.549Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5c/5a59527582e9bcb694b2f08b9894134b65a0f5f79dbff174f054f5f74ed0/propcache-0.5.4-cp315-cp315-win32.whl", hash = "sha256:ee19113bce2f3acd46432050688b70f61acd6857d75abb9ec96341b7e9ced123", size = 43512, upload-time = "2026-09-16T00:16:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/07/93cf699ed363681e754d7c3fad587fb09ef6b65618ee193332ad16a68d7b/propcache-0.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ceb3e879afac028f93d272c957814695dc5569e4904262dbee92f6c41bd5e4a3", size = 46264, upload-time = "2026-09-16T00:16:29.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/fef04fbdcd44a4a163cb5ff5674599c6d6fdefd64a5a459438f9ad2ba042/propcache-0.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:c83acbce9f2b5e3f5f5eda9e53d2001fed22fcdfef81274a9e02d8fd53b70a30", size = 43395, upload-time = "2026-09-16T00:16:31.5Z" }, + { url = "https://files.pythonhosted.org/packages/70/f6/7e2f4dab0b92ab46111bd48cee9ee1e5f519514c44e3779ede5358d7ada0/propcache-0.5.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:a5e8ef588c109725dc713ba69aadcac00a1ef90c2ce9c0a8c7075128f569f47f", size = 89825, upload-time = "2026-09-16T00:16:43.115Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8b/dfeff925cb6ced97ede701d5c6a99998da963c6f2e06abbf879c9dac5b54/propcache-0.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4d86476a935c88963d9b8e1a9a0d38188790e9622169bfbafa173046846709d3", size = 52159, upload-time = "2026-09-16T00:16:44.754Z" }, + { url = "https://files.pythonhosted.org/packages/24/6c/924c810be5b7cf218ef47e707cf06d34adb4e3f3a31e3c24c55c6d945a88/propcache-0.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5470694918830da62fac9e69133b53d23b736d7070e587b27a4a2be37e08e68", size = 53956, upload-time = "2026-09-16T00:16:46.762Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b6/9ed0a5c939b58b6bed740a05b5d0f919f0b318d03284b4b6d81a0fe8a29a/propcache-0.5.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10ef33a68a61ce317e095fd2e202a592ea92392b90944a78c993f0d9a73ab06c", size = 295235, upload-time = "2026-09-16T00:16:48.577Z" }, + { url = "https://files.pythonhosted.org/packages/5a/eb/5ce886e902a2e781dddf110993d5329458a9b1a8626b876c65e5e25bf413/propcache-0.5.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cacf3c9efd09df409dc33654dd077e1c245ba8fb747b0f0236ef41b7c49b589", size = 294463, upload-time = "2026-09-16T00:16:50.539Z" }, + { url = "https://files.pythonhosted.org/packages/f2/88/c98f49183ecd3e5b204a556f0ca47baa02c2206a500fe8c7ec1726297b0a/propcache-0.5.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:770e8209d018175fc0063936fa9583b6d27e88c5ad31543f3383d66080efdd62", size = 300081, upload-time = "2026-09-16T00:16:52.423Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/c5090f9e6f67cbc30a2b744c7bb0f8006dcba5ec1b0d82f866ae1cc7c5c4/propcache-0.5.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03969626faf0783a592dfa17e28eac06018bd0b44dafae6943d53b92421a7f72", size = 285360, upload-time = "2026-09-16T00:16:54.141Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9c/34a55396910583ed07926669ab309dde2213a2dec05a7e946bb90ad66908/propcache-0.5.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef3b928d9c984322b5c44e6964d8dbc653da87d2d8ee1647fa6da43072e650a9", size = 248014, upload-time = "2026-09-16T00:16:56.062Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b5/c0a142b656093ca397039dd3fe166cbb87c945712b534546514a24cd2611/propcache-0.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7177c43eddf10a0893c4fec52ebb408fdcd7f7d63962caace9180d8f81b14ece", size = 280662, upload-time = "2026-09-16T00:16:58.044Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/fab2809c2e337fe26becea9648e84d5cef46075c91b826acb13e4f9dd04e/propcache-0.5.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:420162a77f94eb1cf5ef7893f500016dabd548e73de956785a1dd899cc73006a", size = 266149, upload-time = "2026-09-16T00:16:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/3a/11/7ddf336288b2678a5f054f8da2e2bd1a719f5d4b7de714d9c6bd588a2313/propcache-0.5.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3eb2e820e8e2101407da93f17c57cbb7d225461955fc60105daaba14cd421ee2", size = 283097, upload-time = "2026-09-16T00:17:01.459Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ae/351b1a5225f5473c411d9a612a229ae147cf0cf65c72ad838b87219ea8e8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:13e52b6e0bde97dee98ab66552dbff2931649c96f1ac432eac299fe689ec373b", size = 248160, upload-time = "2026-09-16T00:17:03.298Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d0/7f79f061e30d135bb615c9782c94a74652033d00b49254edbbf35a9165a8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12682126712ddc19b70ff819debbd279e58adf1f0c8f8f8138c18ade2044b284", size = 283036, upload-time = "2026-09-16T00:17:05.238Z" }, + { url = "https://files.pythonhosted.org/packages/53/3c/016f1cad8bf4c428d748cf399b2bac603026fbfd6966e6a5579b5c5b6956/propcache-0.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3af0c8642b2da4815d86e631232ac8286e17644fad907c19508aa8e7cb4ba8ad", size = 279350, upload-time = "2026-09-16T00:17:06.881Z" }, + { url = "https://files.pythonhosted.org/packages/ea/60/d8f72cb24b412487ed4c397f539117d3b74c3c33dd32020e91fe00a958a8/propcache-0.5.4-cp315-cp315t-win32.whl", hash = "sha256:1df8d8561b21465c5dd56110a01caf897e026d065b4b84e98a488209094272ec", size = 45874, upload-time = "2026-09-16T00:17:08.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ef/8bae0a316d406644450522f2f3d44a4e19632f5f3bb60d1d0e6c53842616/propcache-0.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:02c0a34f16889cf800f10f0247a564d8ce6eeab6ffcd7c87198f769067eb8432", size = 48574, upload-time = "2026-09-16T00:17:10.077Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/bcc053f66a97355683884b448198e79580fae8e8fa4d96b9bb01614e9913/propcache-0.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:dc4242ca653c9b30ab51c5f8193323e7bc0928f897ee9103201e59a43abcb72e", size = 45625, upload-time = "2026-09-16T00:17:11.377Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" }, +] + +[[package]] +name = "pydub" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, +] + +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "yarl" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/b3/2cea721d495ca57f8f414aa4867ee263f486b274e07463158cf52f02ba7f/yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865", size = 143794, upload-time = "2026-09-15T19:30:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/55/e6/cd145cff8e5cf60b8b3c41fbecfa2a45028a8dec3fbc52bec03595ff3d3b/yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d", size = 103993, upload-time = "2026-09-15T19:30:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7c/fbb40fe2d53747c40aa36a9e2bd2178a202f942bda0b670f3306d4aefbce/yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d", size = 104010, upload-time = "2026-09-15T19:30:31.069Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0b/5a516f70641092283f57cf3670bdb75e7327bcc0dcb5038697e4dfbfd569/yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420", size = 116444, upload-time = "2026-09-15T19:30:32.894Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/d1a627f827b0a404ae0f5647cab0534959081cde13b0756a783469fb3b5e/yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7", size = 107565, upload-time = "2026-09-15T19:30:35.188Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cf/c8e0aaec886840a6c4480fe44eaec7cd4319f79a563b79321473be79c56f/yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3", size = 125006, upload-time = "2026-09-15T19:30:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/50/26/0cce366d54a93cdc8342965dc7663385e4db161625cc1e8b18e786753d24/yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492", size = 128717, upload-time = "2026-09-15T19:30:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/a71501bbc1a674ff72c4d6c2b75f4d9a5af819f5244c3a7558080a8802c5/yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2", size = 117728, upload-time = "2026-09-15T19:30:40.809Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6f/c3267ca01defeed9ed9c4ff9b17bd54915432c405945233265b707475d1c/yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17", size = 116224, upload-time = "2026-09-15T19:30:42.818Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/fad57ee52d648431718ee0f1f68966a99c1352c3924688ffbdcc9d3fe51a/yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de", size = 116299, upload-time = "2026-09-15T19:30:44.966Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d4/6a8c1e29f33338687ca278cba0a8fbf6525a322c2c02a9a500ccbe041152/yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345", size = 108625, upload-time = "2026-09-15T19:30:46.874Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d2/3a35ae791c9cb6522c106923ff25c3230d999091e5e65511bca23bbd9914/yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40", size = 124515, upload-time = "2026-09-15T19:30:49.082Z" }, + { url = "https://files.pythonhosted.org/packages/be/fd/2b022109a6b4af0f7dc371cf7500af380b0d4f034010243e1b0ce218dc93/yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258", size = 115711, upload-time = "2026-09-15T19:30:51.208Z" }, + { url = "https://files.pythonhosted.org/packages/18/59/f7586271136c3ddb0126bbfe661844699369b76b555fe38c4efe86870b2e/yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1", size = 122751, upload-time = "2026-09-15T19:30:53.535Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0b/07f7a2d881f7e16c385b47fc1753382600847cad305dcc7fa0c25828acf8/yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b", size = 117983, upload-time = "2026-09-15T19:30:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9d/8cdceec66a9b940700cb45931741403f045b162afd79bcb93c41cadd0972/yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e", size = 102894, upload-time = "2026-09-15T19:30:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/17/f1/7ec357db1d3ad2863542d71e8fe64a126bbec17b134cd7d30951196809a5/yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35", size = 98609, upload-time = "2026-09-15T19:30:59.705Z" }, + { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" }, + { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" }, + { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" }, + { url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" }, + { url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" }, + { url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" }, + { url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" }, + { url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" }, + { url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" }, + { url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" }, + { url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" }, + { url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3b/4f51eab40c2eabea6c3d5b121dff4b8988dc35087732ffede12d2be8b8dd/yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc", size = 143875, upload-time = "2026-09-15T19:33:33.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/05/bbd58fc063f5f299a883f810760b265ca26c8167c91cb9a494d0fe2387e1/yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc", size = 104028, upload-time = "2026-09-15T19:33:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b", size = 104041, upload-time = "2026-09-15T19:33:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/884beab53ed88c7247d1671972b5ef116f7351fe0c7e6de8c3558372cb16/yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459", size = 116018, upload-time = "2026-09-15T19:33:44.265Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/1a91b685afb55cc18608443ace95280e96e263a97565811732b6788d3269/yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d", size = 107033, upload-time = "2026-09-15T19:33:46.45Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/58b379fcb1d68d907b7fcf75200c44321896509b2a6a74abbb4b19d864d2/yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26", size = 123257, upload-time = "2026-09-15T19:33:48.631Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2d/1fe96cf5c2aeab10095e48f38585cf5a8451fb7253234822398e52aa5336/yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273", size = 126745, upload-time = "2026-09-15T19:33:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf", size = 117255, upload-time = "2026-09-15T19:33:53.456Z" }, + { url = "https://files.pythonhosted.org/packages/2f/72/0faa30e02605d56127d42bb987dcc97da3863b7bf70b9bfbf5f739c05e30/yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57", size = 115166, upload-time = "2026-09-15T19:33:55.669Z" }, + { url = "https://files.pythonhosted.org/packages/8c/90/9a46eac564c437e128285c5c1d7bb385d268394e9209d84f6bf4a14471ef/yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3", size = 116082, upload-time = "2026-09-15T19:33:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/4b1a686a3758878f93d2f1ea943f5a165f3555769cd16e761cfd0efdca17/yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2", size = 108048, upload-time = "2026-09-15T19:34:00.25Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/f348eb967f31a58348612a2b93bd8a2ab664548e2b5e879cac7f592f7201/yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9", size = 122775, upload-time = "2026-09-15T19:34:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e9/4f7b79700f88cb9e8bb66f8b54f9bce1844c013a2c39fdc47112e9334c95/yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f", size = 115096, upload-time = "2026-09-15T19:34:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/cf/37/f9cb020331997d3eb887bd28d5410ecfd3d80bf163c23d7cec490d78dade/yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b", size = 120655, upload-time = "2026-09-15T19:34:07.382Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/52fceb22891a41f168db7ec22fd1d81e06b6a0b8d9f70921bd3e785defd0/yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c", size = 117488, upload-time = "2026-09-15T19:34:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/ce6c4ff1247fcbe4b33d462c23a097106d909b173fde7042bc52290466e2/yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee", size = 103434, upload-time = "2026-09-15T19:34:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/766a906b0704fb26d52b19dc22bed48a8ba0544203b70dcf44da350e8194/yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d", size = 99155, upload-time = "2026-09-15T19:34:15.132Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d3/a1d09b32cb6ab14f66b44939f5b4255b8b9e747aef3974af1d5d80ccc2fd/yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8", size = 149284, upload-time = "2026-09-15T19:34:17.452Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/fe554d879692650bca70bfbc0df124e82e4d2bb7456f698c7756f1279a96/yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4", size = 106399, upload-time = "2026-09-15T19:34:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4b/e7af56177ac8d40094c82d7728224c0b8472157d50d362e5fb3b014b2bc8/yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec", size = 106968, upload-time = "2026-09-15T19:34:23.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/4f/2df41fd738d46f23ef829ae8b4468d94bb6070038fc6dfab6165ed44fea8/yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487", size = 114717, upload-time = "2026-09-15T19:34:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/69/ea/002b66df53bbd1aed1c23358ff99c9bdc744fe3f4d2740e1b2fdd7192885/yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8", size = 105198, upload-time = "2026-09-15T19:34:28.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7c/95c8bc0c8f97d71e59c94525ad60d76f5c57d3f2820f08137ca8b9f0542a/yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75", size = 120271, upload-time = "2026-09-15T19:34:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/6fe9da56ec77927baa669fd86c39c567ce6205bab53d581082c6744c8ae7/yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3", size = 123572, upload-time = "2026-09-15T19:34:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/8b/83/35f222d17fa70a14c7c74fdf112ccf5515e0c2a87082b1f9b99f7693bf57/yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225", size = 115228, upload-time = "2026-09-15T19:34:35.344Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/fbaaf619423578a7d898d0f226ae47bc1906c293865c416d83c17b828b0e/yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209", size = 111618, upload-time = "2026-09-15T19:34:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/ba/78/7383278f1b3cf8e0496bd95b3281a7b09b89217b6b428db24c6b99b3deca/yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3", size = 114839, upload-time = "2026-09-15T19:34:40.099Z" }, + { url = "https://files.pythonhosted.org/packages/62/49/5506e5b6d29aab91bd845cc9016d88c3d3f81b81bc242b8100bdd5737825/yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d", size = 106212, upload-time = "2026-09-15T19:34:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/19877c193b7c5929f4b07118c18bbe390f3fadd0f59dd98c0cd12b31fa5c/yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c", size = 119985, upload-time = "2026-09-15T19:34:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6c/0a46fbbf9ecbcbd0cc20d2193394254b9e19817f22c814aab60f99847400/yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde", size = 112081, upload-time = "2026-09-15T19:34:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/93f5d471230c74ccd06255d0842739f86551f937f9e63a5e947853c6244a/yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64", size = 116995, upload-time = "2026-09-15T19:34:49.972Z" }, + { url = "https://files.pythonhosted.org/packages/2b/80/c386593035ee3f9c6c6af0847b5578f2830c674794a9d7701b744a3ebd42/yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f", size = 115570, upload-time = "2026-09-15T19:34:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/38/02/eef443559563ef8f2e10469387b8b1e97cb5efee95b288a56da60801f7ee/yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd", size = 106811, upload-time = "2026-09-15T19:34:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, +] + +[[package]] +name = "yt-dlp" +version = "2026.8.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/e0/832fa4ca334b766a06933a196066edc3dba37cdb6f14cd98d59bcc69a4b4/yt_dlp-2026.8.19.tar.gz", hash = "sha256:9e213e48cea35c66b378e4447903f118f6392a5fa380a2b6d7070ec86f4e0af1", size = 3052025, upload-time = "2026-08-19T23:48:59.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/8cd1613f56eed7ceb64fbd4df3f1c01246bfb098e6f398228bafda22b80b/yt_dlp-2026.8.19-py3-none-any.whl", hash = "sha256:1d57897e94c6665a0a6f9bc54b34e584284e32c034ffab3a7df25d8f7b24eedf", size = 3185533, upload-time = "2026-08-19T23:48:56.925Z" }, +] From 271b52e84dd213d9fc24649f5525ba4f7875e73e Mon Sep 17 00:00:00 2001 From: DaBao <xxx139139@gmail.com> Date: Sat, 3 Oct 2026 23:46:09 +0800 Subject: [PATCH 12/12] ci: enforce locked reproducible builds --- .github/workflows/ci.yml | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24524b0..9e17b51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: pull_request: permissions: - contents: write + contents: read jobs: quality: @@ -19,33 +19,16 @@ jobs: with: python-version: "3.11" enable-cache: true - - name: Resolve dependencies - run: uv lock + - name: Verify lockfile + run: uv lock --check - name: Install - run: uv sync --all-extras + run: uv sync --all-extras --locked - name: Compile run: uv run python -m compileall -q autoup tests - name: Ruff run: uv run ruff check autoup tests - name: Tests run: uv run pytest -q --cov=autoup --cov-report=term-missing --cov-report=xml - - name: Commit generated lockfile - if: success() && github.event_name == 'push' && github.ref_name == 'refactor/phase1-5-cleanup' - run: | - git add uv.lock - if ! git diff --cached --quiet; then - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "build: lock dependencies" - git push - fi - - name: Upload lockfile - if: always() - uses: actions/upload-artifact@v4 - with: - name: uv-lock - path: uv.lock - if-no-files-found: error windows-smoke: runs-on: windows-latest @@ -56,6 +39,6 @@ jobs: python-version: "3.11" enable-cache: true - name: Install - run: uv sync --all-extras + run: uv sync --all-extras --locked - name: Smoke tests run: uv run pytest -q tests/test_smoke.py tests/test_config.py tests/test_utils.py