diff --git a/music_downloader/_aria2.py b/music_downloader/_aria2.py index 82b2bce..da589f7 100644 --- a/music_downloader/_aria2.py +++ b/music_downloader/_aria2.py @@ -12,15 +12,18 @@ 由调用方回落到内置的 core.http_client 流式通道; - aria2 没有「单文件体积上限」选项,故用轮询 tellStatus 实现体积守卫与取消响应。 -异步口径:aria2p 客户端本身是同步阻塞实现(内部走 urllib),故每次 RPC 调用 -(提交/查询/移除)都经 ``asyncio.to_thread`` 卸载;轮询间隔改 ``asyncio.sleep`` -让出事件循环。进程启动(``_try_start``)最长阻塞 8s,调用方同样经 to_thread 触发。 +异步口径:RPC 由本模块直接打 aria2 的 JSON-RPC(HTTP POST + JSON-RPC 2.0), +全程 await——原先依赖的 aria2p 内部是 requests + websocket 的同步实现,每次 +RPC 都要经 asyncio.to_thread 卸载,是本项目最后一处「把同步 HTTP 藏在 to_thread +里」的地方。轮询间隔用 asyncio.sleep 让出事件循环。剩下的 to_thread 只有两处, +都是真正的阻塞卸载:进程创建(subprocess.Popen)与强杀回收(kill + wait)。 """ from __future__ import annotations import asyncio import atexit +import contextlib import secrets import shutil import socket @@ -32,6 +35,7 @@ from pathlib import Path from typing import Any, Protocol from core.http_client import UpstreamNetworkError +from core.http_client import client as _http from ._http import DEFAULT_UA from ._log import logger @@ -45,6 +49,8 @@ class _ProcessHandle(Protocol): 也让测试可以用轻量替身驱动生命周期逻辑。 """ + returncode: int | None + def poll(self) -> int | None: ... def terminate(self) -> None: ... def kill(self) -> None: ... @@ -64,11 +70,30 @@ _DEFAULT_MAX_BYTES = 500 * 1024 * 1024 _SPLIT = 5 _MIN_SPLIT_SIZE = '1M' -_lock = threading.Lock() +# 单次 RPC 超时:本机回环,正常在毫秒级;给足余量以免大库时误判网络失败 +_RPC_TIMEOUT = 10.0 + +# tellStatus 返回的**终态**:任务已不在下载队列,只能清结果表 +_TERMINAL_STATUSES = frozenset({'complete', 'error', 'removed'}) + _backend: Aria2Backend | None = None _unavailable = False # 启动失败后置位,不再反复重试 +# 启动单飞锁按事件循环隔离:asyncio.Lock 绑定创建它的 loop,应用主 loop 与测试 +# 各自的 loop 并存,跨 loop 复用会抛 "bound to a different event loop"。 +_START_LOCKS: dict[int, asyncio.Lock] = {} + + +def _start_lock() -> asyncio.Lock: + key = id(asyncio.get_running_loop()) + lock = _START_LOCKS.get(key) + if lock is None: + lock = asyncio.Lock() + _START_LOCKS[key] = lock + return lock + + def _free_port() -> int: """向内核要一个空闲端口(bind 后立即释放,交给 aria2c 监听)。""" with socket.socket() as s: @@ -76,11 +101,86 @@ def _free_port() -> int: return int(s.getsockname()[1]) +class Aria2RpcError(RuntimeError): + """aria2 在 JSON-RPC 响应体里回报的 error 对象(协议层失败,非 HTTP 层)。""" + + +class _Aria2Rpc: + """aria2 JSON-RPC 极简客户端(只覆盖本模块用到的 5 个方法)。 + + 替代 aria2p:后者内部是 requests + websocket 的同步实现,每个 RPC 都得 + to_thread 卸载;而 aria2 的 RPC 就是朴素的 HTTP POST + JSON-RPC 2.0, + 直接复用 core.http_client 的进程级连接池即可,且连接池本就为异步而生。 + + 一律走 ``request_once``(**不重试**),与 aria2p 口径一致:addUri / + forceRemove 这类变更调用若被重发可能重复提交,RPC 抖动由上层 ``fetch`` + 收敛成 UpstreamNetworkError、交给下载器的退避重试统一处理。 + """ + + def __init__(self, port: int, secret: str) -> None: + self._url = f'http://127.0.0.1:{port}/jsonrpc' + self._token = f'token:{secret}' + self._seq = 0 + + async def _call(self, method: str, *params: Any) -> Any: + self._seq += 1 + body = { + 'jsonrpc': '2.0', + 'id': str(self._seq), + 'method': f'aria2.{method}', + 'params': [self._token, *params], + } + resp = await _http.request_once( + 'POST', self._url, json_body=body, timeout=_RPC_TIMEOUT) + data = resp.json() + if not isinstance(data, dict): + raise Aria2RpcError(f'aria2 RPC 响应不是 JSON 对象: {resp.text[:200]!r}') + err = data.get('error') + if err: + raise Aria2RpcError(f'aria2 RPC {method} 失败: {err}') + return data.get('result') + + async def get_version(self) -> str: + """探活(启动时就绪判定)。""" + return str(await self._call('getVersion')) + + async def add_uri(self, url: str, options: dict[str, str]) -> str: + """提交下载,返回 GID。""" + return str(await self._call('addUri', [url], options)) + + async def tell_status(self, gid: str) -> dict[str, Any]: + """查询任务状态(不传 keys,取全量字段)。""" + res = await self._call('tellStatus', gid) + return res if isinstance(res, dict) else {} + + async def force_remove(self, gid: str) -> str: + """停掉在途任务(不做联系 tracker 等耗时动作)。""" + return str(await self._call('forceRemove', gid)) + + async def remove_download_result(self, gid: str) -> str: + """清掉已终结任务在结果表里的条目(否则结果表只增不减)。""" + return str(await self._call('removeDownloadResult', gid)) + + +class _Rpc(Protocol): + """aria2 RPC 的最小接口。 + + 与 _ProcessHandle 同理:只声明本模块真正调用的方法,既不绑死具体实现, + 也让测试能用轻量替身驱动整套状态机(不必起 HTTP 假服务器)。 + """ + + async def get_version(self) -> str: ... + async def add_uri(self, url: str, options: dict[str, str]) -> str: ... + async def tell_status(self, gid: str) -> dict[str, Any]: ... + async def force_remove(self, gid: str) -> str: ... + async def remove_download_result(self, gid: str) -> str: ... + + class Aria2Backend: """aria2c RPC 后端。请通过 get_backend() 获取单例,不要直接构造。""" - def __init__(self, api: Any, proc: _ProcessHandle, port: int) -> None: - self._api = api + def __init__(self, rpc: _Rpc, proc: _ProcessHandle, port: int) -> None: + self._rpc = rpc self._proc = proc self.port = port self._closed = False @@ -88,14 +188,15 @@ class Aria2Backend: # ---------- 生命周期 ---------- def close(self) -> None: - """关闭 RPC 客户端并终止 aria2c 进程(幂等)。""" + """终止 aria2c 进程(幂等)。 + + **同步**:由 atexit 调用,那时已经没有事件循环,且进程回收本就是阻塞操作。 + 这里不关 RPC 连接——它走 core.http_client 的**进程级共享连接池**,关掉会 + 连带影响内置流式通道(原先 aria2p 关的是它自己私有的 requests 会话)。 + """ if self._closed: return self._closed = True - try: - self._api.client.close() - except Exception: - pass # 客户端关闭失败不影响后续进程回收 if self._proc.poll() is None: self._proc.terminate() try: @@ -142,52 +243,79 @@ class Aria2Backend: options['referer'] = referer try: - download = await asyncio.to_thread(self._api.add_uris, [url], options=options) + gid = await self._rpc.add_uri(url, options) except Exception as e: raise UpstreamNetworkError(f'aria2 提交任务失败: {e}') from e deadline = time.monotonic() + timeout + status = '' while True: if cancel_event is not None and cancel_event.is_set(): - await asyncio.to_thread(self._abort, download, drop_files=True) + await self._abort(gid, target, drop_files=True, status=status) raise DownloadCanceled('下载已取消') try: - await asyncio.to_thread(download.update) + st = await self._rpc.tell_status(gid) except Exception as e: # RPC 抖动按可重试的网络失败上报,交给外层退避重试 raise UpstreamNetworkError(f'aria2 状态查询失败: {e}') from e - done = int(download.completed_length or 0) - total = int(download.total_length or 0) + status = str(st.get('status') or '') + done = int(st.get('completedLength') or 0) + total = int(st.get('totalLength') or 0) if progress_cb is not None: try: progress_cb(done, total) except Exception: pass # 进度回调绝不影响下载主流程 if done > max_bytes: - await asyncio.to_thread(self._abort, download, drop_files=True) + await self._abort(gid, target, drop_files=True, status=status) raise DownloadException( f'文件超过大小上限 {max_bytes // (1024 * 1024)}MB,已中止') - if download.is_complete: + if status == 'complete': self._drop_control_file(target) return - if download.has_failed: - # 保留残片与控制文件,等待外层重试时续传 + if status == 'error': + # 保留残片与控制文件,等待外层重试时续传(**不调 _abort**) raise UpstreamNetworkError( - f'aria2 下载失败: {download.error_message or download.status}') + f'aria2 下载失败: {st.get("errorMessage") or status}') if time.monotonic() > deadline: - await asyncio.to_thread(self._abort, download, drop_files=False) + await self._abort(gid, target, drop_files=False, status=status) raise UpstreamNetworkError('aria2 下载超时') await asyncio.sleep(_POLL_INTERVAL) # ---------- 内部 ---------- - def _abort(self, download: Any, *, drop_files: bool) -> None: - """强制移除任务。drop_files=True 时连同已下载字节一起删除。""" + async def _abort( + self, gid: str, target: Path, *, drop_files: bool, status: str, + ) -> None: + """移除任务。与 ``aria2p.remove(force=True, files=drop_files, clean=True)`` + 逐条对齐(该库的默认行为就是还要清控制文件): + + - 已终结(complete/error/removed)→ 只能清结果表(removeDownloadResult); + 在途 → forceRemove 先停掉,再顺手清结果表; + - 控制文件(target.aria2)**总是**删除——这是 aria2p 的 clean=True 默认值, + 也是「取消 / 体积超限」后下轮必须重新下载的依据; + - 只有 drop_files 才删已落地的数据文件(超时场景保留残片供续传)。 + 任一步失败只告警、不影响主流程(与 aria2p 的 logger.exception 同口径), + 且与 aria2p 一致:主移除动作失败时**不**删数据文件。 + """ + removed = True try: - self._api.remove([download], force=True, files=drop_files) + if status in _TERMINAL_STATUSES: + await self._rpc.remove_download_result(gid) + else: + await self._rpc.force_remove(gid) + with contextlib.suppress(Exception): + await self._rpc.remove_download_result(gid) except Exception as e: + removed = False logger.warning(f'终止 aria2 任务失败(不影响主流程): {e}') + self._drop_control_file(target) + if drop_files and removed: + try: + target.unlink(missing_ok=True) + except OSError as e: + logger.warning(f'删除 aria2 残片失败(不影响主流程): {e}') @staticmethod def _drop_control_file(target: Path) -> None: @@ -199,39 +327,45 @@ class Aria2Backend: pass -def get_backend() -> Aria2Backend | None: +async def get_backend() -> Aria2Backend | None: """返回 aria2 后端单例;aria2c 不可用/启动失败时返回 None(调用方降级)。 - 首次调用尝试拉起常驻进程(最长阻塞 _RPC_READY_TIMEOUT 秒),之后命中缓存; - 失败结果进程内记忆,不反复重试。**同步阻塞**——异步调用方须经 - ``asyncio.to_thread(get_backend)`` 触发,避免首次启动期间冻住事件循环。 + 首次调用尝试拉起常驻进程并等 RPC 就绪(最长 ``_RPC_READY_TIMEOUT`` 秒, + 等待用 ``await asyncio.sleep`` 让出事件循环,故本函数是**原生异步**、 + 不需要调用方再包 to_thread);之后命中缓存。失败结果进程内记忆, + 不反复重试。并发首调由启动单飞锁收敛,只拉起一个进程。 """ global _backend, _unavailable if _unavailable: return None if _backend is not None: return _backend - with _lock: + async with _start_lock(): if _unavailable: return None if _backend is None: - _backend = _try_start() + _backend = await _try_start() if _backend is None: _unavailable = True return _backend -def _try_start() -> Aria2Backend | None: +def _reap(proc: _ProcessHandle) -> None: + """强杀并回收子进程(阻塞)。只应由 asyncio.to_thread 调用。""" + try: + proc.kill() + except OSError: + return + with contextlib.suppress(Exception): + proc.wait(timeout=5) + + +async def _try_start() -> Aria2Backend | None: """拉起 aria2c --enable-rpc 并等待 RPC 就绪;任何失败都返回 None。""" exe = shutil.which(ARIA2_BIN) if not exe: logger.info('未检测到 aria2c,下载走内置流式通道') return None - try: - import aria2p - except ImportError as e: - logger.info(f'aria2p 不可用,下载走内置流式通道: {e}') - return None port = _free_port() secret = secrets.token_urlsafe(24) @@ -246,33 +380,34 @@ def _try_start() -> Aria2Backend | None: '--quiet=true', ] try: + # 进程创建是真阻塞调用(Windows CreateProcess 数十毫秒), + # 且本函数在事件循环内被 await,故经 to_thread 卸载(真正的阻塞卸载)。 # exe 来自 shutil.which,参数全部由本模块构造,不经任何用户输入 - proc = subprocess.Popen( - cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + proc = await asyncio.to_thread( + subprocess.Popen, cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except OSError as e: logger.warning(f'aria2c 启动失败,降级内置下载: {e}') return None - client = aria2p.Client(host='http://127.0.0.1', port=port, secret=secret) + rpc = _Aria2Rpc(port, secret) deadline = time.monotonic() + _RPC_READY_TIMEOUT while True: if proc.poll() is not None: logger.warning(f'aria2c 进程提前退出(code={proc.returncode}),降级内置下载') return None try: - client.get_version() + await rpc.get_version() break except Exception: if time.monotonic() > deadline: logger.warning(f'aria2c RPC {_RPC_READY_TIMEOUT:.0f}s 内未就绪,降级内置下载') - proc.kill() - proc.wait(timeout=5) + await asyncio.to_thread(_reap, proc) return None - time.sleep(0.2) + await asyncio.sleep(0.2) atexit.register(_shutdown) logger.info(f'aria2c 常驻 RPC 已就绪(127.0.0.1:{port},{_SPLIT} 连接/文件)') - return Aria2Backend(aria2p.API(client), proc, port) + return Aria2Backend(rpc, proc, port) def _shutdown() -> None: diff --git a/music_downloader/downloader.py b/music_downloader/downloader.py index 89f243b..aa0f42d 100644 --- a/music_downloader/downloader.py +++ b/music_downloader/downloader.py @@ -477,9 +477,9 @@ class MusicDownloader: if not await self._download_semaphore.acquire_cancellable(cancel_event, poll=0.5): raise DownloadCanceled("下载已取消") try: - # get_backend 首次调用要拉起 aria2c 并等 RPC 就绪(最长 8s), - # 经 to_thread 卸载,不在事件循环上原地等 - aria2_backend = await asyncio.to_thread(get_backend) + # get_backend 首次调用要拉起 aria2c 并等 RPC 就绪(最长 8s); + # 它已是原生异步(等待用 await asyncio.sleep 让出事件循环),直接 await + aria2_backend = await get_backend() if aria2_backend is not None: # aria2c 通道:多连接 + 原生断点续传 await self._fetch_via_aria2(aria2_backend, music_info, part_path, diff --git a/pyproject.toml b/pyproject.toml index e85b405..e49a35b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ dependencies = [ "qrcode>=7.0,<8.3", "python-multipart>=0.0.9", "filetype>=1.2.0", - "aria2p>=0.12.1", "mutagen>=1.48.1", ] @@ -67,11 +66,14 @@ ignore = [ # 不是「用 sleep 当超时」的反模式。 "core/http_client.py" = ["ASYNC109"] # ASYNC109:timeout 是**总下载时限**(交给 aria2 轮询循环判定),不是「用 sleep 当超时」。 -"music_downloader/_aria2.py" = ["ASYNC109"] +# ASYNC240:搬迁后才出现的阻塞文件操作只有 unlink(删残片/控制文件)——目录项删除, +# 微秒级且不碰网络,卸载到线程的调度开销大于操作本身,与 downloader.py 同口径。 +"music_downloader/_aria2.py" = ["ASYNC109", "ASYNC240"] # ASYNC230/240:残留的阻塞文件系统调用都是**本地元数据/短写**——exists()/stat()/ # os.replace()/往 .part 顺序写分块,微秒级且不碰网络,卸载到线程的调度开销大于操作 # 本身。真正的重活(mutagen 全量重写标签、zipfile 整段拷贝、shutil.rmtree、同步 -# SQLite 落库、aria2p 的同步 RPC)都已各自 to_thread 卸载。 +# SQLite 落库)都已各自 to_thread 卸载;aria2 的 RPC 已改为手写原生异步实现 +# (music_downloader/_aria2.py),不再是阻塞调用。 "music_downloader/downloader.py" = ["ASYNC230", "ASYNC240"] "services/podcast.py" = ["ASYNC230", "ASYNC240"] "services/batch.py" = ["ASYNC240"] diff --git a/tests/test_aria2.py b/tests/test_aria2.py index 86d36ea..85f6e75 100644 --- a/tests/test_aria2.py +++ b/tests/test_aria2.py @@ -1,73 +1,78 @@ # -*- coding: utf-8 -*- -"""aria2 下载后端的单元测试(用假 api 驱动,不需要真实 aria2c 二进制)。 +"""aria2 下载后端的单元测试(用假 RPC 驱动,不需要真实 aria2c 二进制)。 覆盖: - aria2c 缺失时 get_backend 静默降级且失败结果被记忆 -- 正常完成路径的进度回调与自动清理 +- 正常完成路径的进度回调、控制文件兜底清理与选项下发 - 取消 / 体积超限 连带删除残片;任务失败 / 总超时 保留残片供续传 - 提交失败与 RPC 抖动统一上报为可重试异常 +- 已终结任务只清结果表(removeDownloadResult),在途任务才 forceRemove +- JSON-RPC 信封(方法名 / token / params)与 error 对象的收敛 + +替身边界:本模块已不再依赖 aria2p——RPC 由 ``_Aria2Rpc`` 直接打 +core.http_client。故替身只替换 ``_Aria2Rpc`` 这一层的四个方法, +以及(信封测试里)``_http.request_once``。 """ from __future__ import annotations +import json import threading import pytest import music_downloader._aria2 as a2 -from core.http_client import UpstreamNetworkError +from core.http_client import HTTPResult, UpstreamNetworkError from music_downloader import DownloadCanceled, DownloadException -class _FakeDownload: - """按预设步进产出状态,模拟 aria2 的 tellStatus 轮询。""" +def _st(**kw): + """构造一条 tellStatus 结果(aria2 的数值字段都是字符串)。""" + base = {'status': 'active', 'completedLength': '0', 'totalLength': '100'} + base.update(kw) + return base - def __init__(self, steps): - self.gid = 'g1' + +class _FakeRpc: + """按预设步进产出 tellStatus 结果,并记录移除类调用。""" + + def __init__(self, steps, *, fail_add=False, fail_tell=False): self._steps = list(steps) self._i = 0 - self.completed_length = 0 - self.total_length = 100 - self.is_complete = False - self.has_failed = False - self.error_message = '' - self.status = 'active' + self._fail_add = fail_add + self._fail_tell = fail_tell + self.added: tuple[str, dict[str, str]] | None = None + self.removed: list[tuple[str, str]] = [] # ('forceRemove'|'removeDownloadResult', gid) - def update(self): - idx = min(self._i, len(self._steps) - 1) - done, complete, failed = self._steps[idx] - self._i += 1 - self.completed_length = done - self.is_complete = complete - self.has_failed = failed - if failed: - self.status = 'error' - self.error_message = 'boom' + async def get_version(self) -> str: + return "1.36.0" - -class _FakeAPI: - def __init__(self, download, fail_submit=False, fail_update=False): - self.download = download - self.removed: list[tuple[str, bool, bool]] = [] - self.submitted: tuple[list[str], dict[str, str]] | None = None - self._fail_submit = fail_submit - self._fail_update = fail_update - - def add_uris(self, uris, options=None): - if self._fail_submit: + async def add_uri(self, url: str, options: dict[str, str]) -> str: + if self._fail_add: raise RuntimeError('rpc down') - self.submitted = (list(uris), options or {}) - return self.download + self.added = (url, options) + return 'g1' - def remove(self, downloads, force=False, files=False): - self.removed.append((downloads[0].gid, force, files)) + async def tell_status(self, gid: str) -> dict: + if self._fail_tell: + raise RuntimeError('rpc jitter') + idx = min(self._i, len(self._steps) - 1) + self._i += 1 + return dict(self._steps[idx]) + async def force_remove(self, gid: str) -> str: + self.removed.append(('forceRemove', gid)) + return gid -class _FailUpdateDownload(_FakeDownload): - def update(self): - raise RuntimeError('rpc jitter') + async def remove_download_result(self, gid: str) -> str: + self.removed.append(('removeDownloadResult', gid)) + return 'OK' class _FakeProc: + """已退出的假进程:poll() 返回非 None,故 _try_start 会立即判定「提前退出」。""" + + returncode: int | None = 0 + def poll(self): return 0 @@ -81,99 +86,341 @@ class _FakeProc: return 0 +class _LiveProc: + """仍在运行的假进程:poll() 返回 None(用于 _try_start 的就绪等待)。""" + + returncode: int | None = None + + def poll(self): + return None + + def terminate(self): + pass + + def kill(self): + pass + + def wait(self, timeout=None): + return 0 + + def _backend(monkeypatch, steps, **kw): # fetch 的轮询改 asyncio.sleep(不再 time.sleep):把间隔压到 0 让状态机快速走完 monkeypatch.setattr(a2, '_POLL_INTERVAL', 0.0) - dl = _FailUpdateDownload([]) if kw.get('fail_update') else _FakeDownload(steps) - api = _FakeAPI(dl, fail_submit=kw.get('fail_submit', False)) - return a2.Aria2Backend(api, _FakeProc(), 6800), api + rpc = _FakeRpc(steps, fail_add=kw.get('fail_add', False), + fail_tell=kw.get('fail_tell', False)) + return a2.Aria2Backend(rpc, _FakeProc(), 6800), rpc # ---------- 降级 ---------- -def test_get_backend_degrades_without_binary(monkeypatch): +async def test_get_backend_degrades_without_binary(monkeypatch): monkeypatch.setattr(a2.shutil, 'which', lambda _n: None) monkeypatch.setattr(a2, '_backend', None) monkeypatch.setattr(a2, '_unavailable', False) - assert a2.get_backend() is None - assert a2.get_backend() is None # 失败结果被记忆,不反复拉起进程 + assert await a2.get_backend() is None + assert await a2.get_backend() is None # 失败结果被记忆,不反复拉起进程 def test_free_port_is_in_valid_range(): assert 0 < a2._free_port() < 65536 +async def test_try_start_waits_for_rpc_ready(monkeypatch): + """RPC 就绪前的探活失败要被容忍并重试;就绪后返回可用后端(不需真 aria2c)。 + + 这条覆盖的是「首次调用最长阻塞 8s」那条路径——它已从「同步 sleep + to_thread」 + 改成「await asyncio.sleep 让出事件循环」,用例钉住状态机不变。 + """ + probes = {'n': 0} + + class _RpcStub: + def __init__(self, port, secret): + self.port = port + self.secret = secret + + async def get_version(self): + probes['n'] += 1 + if probes['n'] < 2: + raise RuntimeError('not ready yet') + return '1.36.0' + + monkeypatch.setattr(a2.shutil, 'which', lambda _n: 'aria2c') + monkeypatch.setattr(a2, '_free_port', lambda: 6800) + monkeypatch.setattr(a2, '_Aria2Rpc', _RpcStub) + monkeypatch.setattr(a2.subprocess, 'Popen', lambda *a, **kw: _LiveProc()) + monkeypatch.setattr(a2.atexit, 'register', lambda _f: None) + monkeypatch.setattr(a2, '_RPC_READY_TIMEOUT', 3.0) + + be = await a2._try_start() + assert be is not None + assert be.port == 6800 + assert probes['n'] == 2 # 第一次没就绪、重试后才成功 + + +async def test_try_start_gives_up_when_rpc_never_ready(monkeypatch): + """超过上限仍未就绪:返回 None 走降级,并回收子进程(不留下孤儿 aria2c)。""" + killed = [] + proc = _LiveProc() + monkeypatch.setattr(proc, 'kill', lambda: killed.append(1)) + + class _NeverReady: + def __init__(self, port, secret): + pass + + async def get_version(self): + raise RuntimeError('not ready') + + monkeypatch.setattr(a2.shutil, 'which', lambda _n: 'aria2c') + monkeypatch.setattr(a2, '_free_port', lambda: 6800) + monkeypatch.setattr(a2, '_Aria2Rpc', _NeverReady) + monkeypatch.setattr(a2.subprocess, 'Popen', lambda *a, **kw: proc) + monkeypatch.setattr(a2.atexit, 'register', lambda _f: None) + monkeypatch.setattr(a2, '_RPC_READY_TIMEOUT', 0.0) + + assert await a2._try_start() is None + assert killed == [1] # 已强杀回收 + + +async def test_try_start_returns_none_when_process_exits_early(monkeypatch): + """aria2c 进程提前退出:立即降级,不等满超时。""" + + class _RpcStub: + def __init__(self, port, secret): + pass + + async def get_version(self): + raise RuntimeError('should not matter') + + monkeypatch.setattr(a2.shutil, 'which', lambda _n: 'aria2c') + monkeypatch.setattr(a2, '_free_port', lambda: 6800) + monkeypatch.setattr(a2, '_Aria2Rpc', _RpcStub) + monkeypatch.setattr(a2.subprocess, 'Popen', lambda *a, **kw: _FakeProc()) + monkeypatch.setattr(a2.atexit, 'register', lambda _f: None) + + assert await a2._try_start() is None # _FakeProc.poll() != None → 提前退出 + + # ---------- 完成路径 ---------- async def test_fetch_reports_progress_and_completes(monkeypatch, tmp_path): - be, api = _backend(monkeypatch, [(50, False, False), (100, True, False)]) + be, rpc = _backend(monkeypatch, [ + _st(completedLength='50'), + _st(status='complete', completedLength='100'), + ]) + target = tmp_path / 'y.part' + ctrl = tmp_path / 'y.part.aria2' + ctrl.write_bytes(b'aria2 ctrl') # 预置控制文件,验证兜底清理 + seen = [] - await be.fetch('http://x/y.mp3', tmp_path / 'y.part', + await be.fetch('http://x/y.mp3', target, progress_cb=lambda done, total: seen.append((done, total))) + assert seen == [(50, 100), (100, 100)] - assert api.removed == [] # 完成不需要中止 - assert not (tmp_path / 'y.part.aria2').exists() # 控制文件已兜底清理 - submitted = api.submitted - assert submitted is not None - options = submitted[1] - assert options['continue'] == 'true' # 续传选项已下发 + assert rpc.removed == [] # 完成不需要中止 + assert not ctrl.exists() # 控制文件已兜底清理 + assert rpc.added is not None + url, options = rpc.added + assert url == 'http://x/y.mp3' + assert options['continue'] == 'true' # 续传选项已下发 assert options['max-connection-per-server'] == str(a2._SPLIT) + assert options['out'] == 'y.part' + assert options['auto-file-renaming'] == 'false' # 与 .part 语义不冲突 async def test_progress_callback_exception_is_swallowed(monkeypatch, tmp_path): - be, _api = _backend(monkeypatch, [(100, True, False)]) + be, _rpc = _backend(monkeypatch, [_st(status='complete', completedLength='100')]) def boom(_d, _t): raise RuntimeError('cb') - await be.fetch('http://x/y.mp3', tmp_path / 'y.part', progress_cb=boom) # 不应抛出 + # 不应抛出 + await be.fetch('http://x/y.mp3', tmp_path / 'y.part', progress_cb=boom) + + +async def test_referer_is_forwarded_when_given(monkeypatch, tmp_path): + be, rpc = _backend(monkeypatch, [_st(status='complete', completedLength='100')]) + await be.fetch('http://x/y.mp3', tmp_path / 'y.part', referer='http://ref/') + assert rpc.added is not None + assert rpc.added[1]['referer'] == 'http://ref/' # ---------- 取消 / 守卫 / 失败 ---------- async def test_fetch_maps_cancel_and_drops_partial(monkeypatch, tmp_path): - be, api = _backend(monkeypatch, [(10, False, False)]) + be, rpc = _backend(monkeypatch, [_st(completedLength='10')]) + target = tmp_path / 'y.part' + target.write_bytes(b'partial') + ctrl = tmp_path / 'y.part.aria2' + ctrl.write_bytes(b'aria2 ctrl') + ev = threading.Event() ev.set() with pytest.raises(DownloadCanceled): - await be.fetch('http://x/y.mp3', tmp_path / 'y.part', cancel_event=ev) - assert api.removed == [('g1', True, True)] + await be.fetch('http://x/y.mp3', target, cancel_event=ev) + + # 在途分支:先 forceRemove 停掉,再清结果表(与 aria2p 的 453-454 行一致) + assert rpc.removed == [('forceRemove', 'g1'), + ('removeDownloadResult', 'g1')] + assert not target.exists() # 残片一并删除 + assert not ctrl.exists() # 控制文件总是删除 async def test_fetch_guards_size_and_drops_partial(monkeypatch, tmp_path): - be, api = _backend(monkeypatch, [(700, False, False)]) + be, rpc = _backend(monkeypatch, [_st(completedLength='700')]) + target = tmp_path / 'y.part' + target.write_bytes(b'x' * 10) + with pytest.raises(DownloadException): - await be.fetch('http://x/y.mp3', tmp_path / 'y.part', max_bytes=500) - assert api.removed == [('g1', True, True)] + await be.fetch('http://x/y.mp3', target, max_bytes=500) + + assert rpc.removed == [('forceRemove', 'g1'), + ('removeDownloadResult', 'g1')] + assert not target.exists() + + +async def test_size_guard_after_complete_clears_result_only(monkeypatch, tmp_path): + """体积守卫在「完成」判定之前:任务已终结时只能清结果表,不是 forceRemove。 + + 这条分支与 aria2p.remove() 的 if download.is_complete 分支对齐—— + 对已终结的 GID 调 forceRemove 会报错,故必须走 removeDownloadResult。 + """ + be, rpc = _backend(monkeypatch, [_st(status='complete', completedLength='700')]) + target = tmp_path / 'y.part' + target.write_bytes(b'x' * 10) + + with pytest.raises(DownloadException): + await be.fetch('http://x/y.mp3', target, max_bytes=500) + + assert rpc.removed == [('removeDownloadResult', 'g1')] + assert not target.exists() async def test_fetch_failure_is_retryable_and_keeps_partial(monkeypatch, tmp_path): - be, api = _backend(monkeypatch, [(10, False, True)]) + be, rpc = _backend(monkeypatch, [ + _st(status='error', completedLength='10', errorMessage='boom')]) + target = tmp_path / 'y.part' + target.write_bytes(b'partial') + ctrl = tmp_path / 'y.part.aria2' + ctrl.write_bytes(b'aria2 ctrl') + with pytest.raises(UpstreamNetworkError): - await be.fetch('http://x/y.mp3', tmp_path / 'y.part') - assert api.removed == [] # 残片保留,供下轮续传 + await be.fetch('http://x/y.mp3', target) + + assert rpc.removed == [] # 不调 _abort:残片与控制文件都保留,供下轮续传 + assert target.exists() + assert ctrl.exists() -async def test_fetch_timeout_is_retryable(monkeypatch, tmp_path): - be, api = _backend(monkeypatch, [(10, False, False)]) # 永不完成 +async def test_fetch_timeout_is_retryable_and_keeps_partial(monkeypatch, tmp_path): + be, rpc = _backend(monkeypatch, [_st(completedLength='10')]) # 永不完成 + target = tmp_path / 'y.part' + target.write_bytes(b'partial') + ctrl = tmp_path / 'y.part.aria2' + ctrl.write_bytes(b'aria2 ctrl') + with pytest.raises(UpstreamNetworkError): - await be.fetch('http://x/y.mp3', tmp_path / 'y.part', timeout=-1) - assert api.removed == [('g1', True, False)] # 超时中止但保留残片 + await be.fetch('http://x/y.mp3', target, timeout=-1) + + assert rpc.removed == [('forceRemove', 'g1'), + ('removeDownloadResult', 'g1')] + assert target.exists() # 超时保留残片(与 aria2p 的 files=False 对齐) + assert not ctrl.exists() # 但控制文件仍删(aria2p 的 clean=True 默认) async def test_fetch_submit_failure_is_retryable(monkeypatch, tmp_path): - be, _api = _backend(monkeypatch, [], fail_submit=True) + be, _rpc = _backend(monkeypatch, [], fail_add=True) with pytest.raises(UpstreamNetworkError): await be.fetch('http://x/y.mp3', tmp_path / 'y.part') async def test_fetch_update_failure_is_retryable(monkeypatch, tmp_path): - be, _api = _backend(monkeypatch, [], fail_update=True) + be, _rpc = _backend(monkeypatch, [], fail_tell=True) with pytest.raises(UpstreamNetworkError): await be.fetch('http://x/y.mp3', tmp_path / 'y.part') +async def test_abort_failure_does_not_delete_partial(monkeypatch, tmp_path): + """主移除动作失败时**不**删数据文件(与 aria2p 的 `files and result[-1]` 对齐)。""" + be, rpc = _backend(monkeypatch, [_st(completedLength='10')]) + target = tmp_path / 'y.part' + target.write_bytes(b'partial') + + async def boom(_gid): + raise RuntimeError('rpc down') + + monkeypatch.setattr(rpc, 'force_remove', boom) + with pytest.raises(UpstreamNetworkError): + await be.fetch('http://x/y.mp3', target, timeout=-1) + + assert target.exists() + + def test_close_is_idempotent(monkeypatch): - be, _api = _backend(monkeypatch, []) + be, _rpc = _backend(monkeypatch, []) be.close() be.close() + + +# ---------- JSON-RPC 信封 ---------- + +class _FakeHTTP: + """捕获 request_once 的入参,并按预设响应体返回。""" + + def __init__(self, payload): + self.payload = payload + self.calls: list[dict] = [] + + async def __call__(self, method, url, **kw): + self.calls.append({'method': method, 'url': url, **kw}) + return HTTPResult(status=200, url=url, text=json.dumps(self.payload)) + + +async def test_rpc_envelope_shape(monkeypatch): + """信封必须是 aria2 要求的 {"jsonrpc","id","method","params":["token:…",…]}。""" + fake = _FakeHTTP({'jsonrpc': '2.0', 'id': '1', 'result': 'gid-1'}) + monkeypatch.setattr(a2._http, 'request_once', fake) + + rpc = a2._Aria2Rpc(6800, 'SEC') + gid = await rpc.add_uri('http://u/a.mp3', {'out': 'a.part'}) + + assert gid == 'gid-1' + assert len(fake.calls) == 1 + call = fake.calls[0] + assert call['method'] == 'POST' + assert call['url'] == 'http://127.0.0.1:6800/jsonrpc' + body = call['json_body'] + assert body['jsonrpc'] == '2.0' + assert body['method'] == 'aria2.addUri' + assert body['params'][0] == 'token:SEC' # secret 以 token: 前缀入参 + assert body['params'][1] == ['http://u/a.mp3'] + assert body['params'][2] == {'out': 'a.part'} + + +async def test_rpc_call_impl_does_not_retry(monkeypatch): + """变更类 RPC 必须走 request_once(不重试),避免 addUri 被重复提交。""" + fake = _FakeHTTP({'jsonrpc': '2.0', 'id': '1', 'result': 'OK'}) + monkeypatch.setattr(a2._http, 'request_once', fake) + rpc = a2._Aria2Rpc(6800, 'SEC') + await rpc.force_remove('g1') + assert len(fake.calls) == 1 + + +async def test_rpc_error_object_becomes_rpc_error(monkeypatch): + """aria2 的协议层错误是 HTTP 200 + error 对象,必须收敛成 Aria2RpcError。""" + fake = _FakeHTTP({'jsonrpc': '2.0', 'id': '1', + 'error': {'code': 1, 'message': 'GID not found'}}) + monkeypatch.setattr(a2._http, 'request_once', fake) + + rpc = a2._Aria2Rpc(6800, 'SEC') + with pytest.raises(a2.Aria2RpcError) as ei: + await rpc.tell_status('nope') + assert 'GID not found' in str(ei.value) + + +async def test_rpc_non_object_response_is_rpc_error(monkeypatch): + fake = _FakeHTTP([1, 2, 3]) # 合法 JSON 但不是对象 + monkeypatch.setattr(a2._http, 'request_once', fake) + rpc = a2._Aria2Rpc(6800, 'SEC') + with pytest.raises(a2.Aria2RpcError): + await rpc.get_version() diff --git a/tests/test_downloader.py b/tests/test_downloader.py index a9fa001..d8e41b8 100644 --- a/tests/test_downloader.py +++ b/tests/test_downloader.py @@ -14,7 +14,7 @@ ZIP 都经它,约 205 行 / 圈复杂度 47,此前**零直接测试覆盖** ``async open(method, url, **kw)``,返回带 ``status``/``headers``/``content`` 的未关闭响应;分块读取走**真实的** ``iter_chunks``,顺带钉住「读取期异常 收敛为 UpstreamNetworkError」这条边界。 -- aria2:``get_backend``(返回 None = 强制走内置 HTTP 通道) +- aria2:``get_backend``(**协程**替身,返回 None = 强制走内置 HTTP 通道) - 内容嗅探:``_sniff_audio_header``(默认放行,按需置 False) - 元数据写入:``MusicDownloader._write_metadata``(不真跑 mutagen) """ @@ -136,7 +136,13 @@ def env(tmp_path, monkeypatch): return True monkeypatch.setattr('music_downloader.downloader._http', client) - monkeypatch.setattr('music_downloader.downloader.get_backend', lambda: None) + + # get_backend 已是原生异步(调用点直接 await)——替身也必须是协程, + # 同步 lambda 会被 await 成 None 而报 "object NoneType can't be used in 'await'" + async def _no_aria2(): + return None + + monkeypatch.setattr('music_downloader.downloader.get_backend', _no_aria2) monkeypatch.setattr('music_downloader.downloader._sniff_audio_header', lambda _p: state['sniff']) monkeypatch.setattr(MusicDownloader, '_write_metadata', _record_meta) diff --git a/uv.lock b/uv.lock index 1deedfc..f5fc9aa 100644 --- a/uv.lock +++ b/uv.lock @@ -173,21 +173,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] -[[package]] -name = "aria2p" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "loguru" }, - { name = "platformdirs" }, - { name = "requests" }, - { name = "websocket-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f7/d5/68f38791f44e5caf5c7c9576aea61d77cf940a060135358dcadb5345dd9c/aria2p-0.12.1.tar.gz", hash = "sha256:1521ccba4a4bb164a023f4c4213513dc3e6f47b91927f5e49dd50678da8c1294", size = 270595, upload-time = "2024-12-25T17:06:34.571Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/95/1ec57df6c52152fb4c776da5fbb2896f19af52de3776899d91e7140e7654/aria2p-0.12.1-py3-none-any.whl", hash = "sha256:96d31c1edd3c9e0edf725b7adeb0b9fb9b6ae102f3c228040d773b43837913a2", size = 88242, upload-time = "2024-12-25T17:06:30.995Z" }, -] - [[package]] name = "attrs" version = "26.1.0" @@ -291,137 +276,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, ] -[[package]] -name = "charset-normalizer" -version = "3.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, - { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, - { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, - { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, - { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, - { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, - { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, - { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, - { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, - { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, - { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, - { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, - { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, - { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, - { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, - { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, - { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, - { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, - { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, - { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, - { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, - { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, - { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, - { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, - { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, - { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, - { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, - { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, - { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, - { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, - { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, - { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, - { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, - { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, - { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, - { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, - { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, - { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, - { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, - { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, - { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, - { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, - { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, - { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, - { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, - { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, - { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, - { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, - { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, - { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, - { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, - { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, - { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, - { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, - { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, - { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, - { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, - { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, - { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, - { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, - { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, - { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, - { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, - { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, - { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, - { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, - { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, - { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, - { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, - { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, - { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, - { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, - { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, - { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, - { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, - { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, - { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, - { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, - { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, - { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, - { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, - { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, - { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, - { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, - { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, - { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, - { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, - { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, - { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, - { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, - { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, - { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, - { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, -] - [[package]] name = "click" version = "8.5.0" @@ -743,19 +597,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, ] -[[package]] -name = "loguru" -version = "0.7.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "win32-setctime", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, -] - [[package]] name = "multidict" version = "6.9.1" @@ -921,15 +762,6 @@ 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 = "platformdirs" -version = "4.11.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/e0/7c20b5d0e0f147c40a934e8f9e9f717bd6e3e372c4d58ca5c2fcce2b1652/platformdirs-4.11.15.tar.gz", hash = "sha256:d7419e973b2b740d428200130f80c0e79304d1c71081001db911e15b26a3a6d4", size = 46059, upload-time = "2026-09-26T02:04:58.142Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/83/246d3052d043683cc5f400ab5748bd07a99fbdf311dc771cfad7e3f9e7e0/platformdirs-4.11.15-py3-none-any.whl", hash = "sha256:84508421c77d3c6462e71dd8c0d2776c2ace0e94eb006dc4fd383bc1dcf5a176", size = 26689, upload-time = "2026-09-26T02:04:56.71Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -1276,21 +1108,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, ] -[[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.9" @@ -1359,15 +1176,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] -[[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 = "uvicorn" version = "0.54.0" @@ -1462,15 +1270,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" }, ] -[[package]] -name = "websocket-client" -version = "1.9.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" }, -] - [[package]] name = "websockets" version = "17.1" @@ -1600,15 +1399,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, ] -[[package]] -name = "win32-setctime" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, -] - [[package]] name = "yarl" version = "1.25.1" @@ -1733,7 +1523,6 @@ dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, { name = "aiosqlite" }, - { name = "aria2p" }, { name = "cryptography" }, { name = "fastapi" }, { name = "filetype" }, @@ -1759,7 +1548,6 @@ requires-dist = [ { name = "aiofiles", specifier = ">=24.1" }, { name = "aiohttp", specifier = ">=3.10,<4.0" }, { name = "aiosqlite", specifier = ">=0.20,<1.0" }, - { name = "aria2p", specifier = ">=0.12.1" }, { name = "cryptography", specifier = ">=50.0,<51.0" }, { name = "fastapi", specifier = ">=0.115,<1.0" }, { name = "filetype", specifier = ">=1.2.0" },