From 0cae770ace8482f56d641f2b1b2de03e88eed3a4 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:12:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E6=94=AF=E6=8C=81=20Granian=20?= =?UTF-8?q?=E4=B8=8E=20rloop/uvloop/winloop=20=E4=BA=8B=E4=BB=B6=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/serve.py 启动器:server auto/uvicorn/granian,循环可用性与平台校验(显式不可用直接报错) - pyproject 增加 granian 可选 extra(granian[reload,rloop,uvloop,winloop],平台 marker 由 granian 维护) - tests/test_serve.py 覆盖服务器/循环选择与命令拼装;README 补充启动方式、循环矩阵与多 worker 限制 --- README.md | 36 +++++++++- pyproject.toml | 16 ++++- scripts/serve.py | 171 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_serve.py | 67 +++++++++++++++++ uv.lock | 69 ++++++++++++++++++ 5 files changed, 355 insertions(+), 4 deletions(-) create mode 100644 scripts/serve.py create mode 100644 tests/test_serve.py diff --git a/README.md b/README.md index 2bb66fb..fbbb118 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,39 @@ uv run uvicorn app.main:app --host 0.0.0.0 --port 16688 # 默认端口 16688;也可用 PORT 环境变量 + --port $PORT uv run ruff check app && uv run ruff format --check app # 规范检查 uv run pyright # 类型检查(基础模式) +uv run pytest # 单元测试(tests/) ``` 环境变量(`PORT`、`AUTH_KEY`、`RATE_LIMIT`、`DATA_DIR` 等)可写入项目根目录的 `.env` 文件(已 gitignore),启动时自动加载;已存在的真实环境变量优先于 `.env` 文件值。登录凭据持久化在 `data/auth.json`(0600,原子写入)。 Node/TypeScript 版(v1.x,`master` 分支):`pnpm install && pnpm start`(需 Node 22.18+,直接运行 `.ts`)。 +### 服务器与事件循环(uvicorn / granian) + +`scripts/serve.py` 统一启动入口:默认 `--server auto`(装了 granian 就用 granian,否则 uvicorn), +事件循环可用 `auto / asyncio / rloop / uvloop / winloop`,参数与环境变量等价 +(`SERVER` / `LOOP` / `HOST` / `PORT` / `WORKERS` / `RELOAD`)。 + +```bash +uv sync --extra granian # Granian + rloop/uvloop/winloop + watchfiles +uv run python scripts/serve.py # auto:granian(若已安装),loop=auto +uv run python scripts/serve.py --server granian --loop rloop # 仅 Unix(Rust 事件循环) +uv run python scripts/serve.py --server granian --loop uvloop # 仅 Unix +uv run python scripts/serve.py --server granian --loop winloop # 仅 Windows +uv run python scripts/serve.py --server uvicorn --loop uvloop # 仅 Unix +uv run python scripts/serve.py --server granian --reload # 热重载(强制单 worker) +``` + +| 事件循环 | 平台 | 说明 | +| --- | --- | --- | +| `asyncio` | 全平台 | 标准库,始终可用 | +| `rloop` | Unix | granian 的 Rust 事件循环(`rloop` 包,Windows 无 wheel) | +| `uvloop` | Unix | libuv 事件循环 | +| `winloop` | Windows | uvloop 的 Windows 实现 | +| `auto` | 全平台 | granian:rloop → uvloop → winloop → asyncio;uvicorn:uvloop → asyncio | + +显式请求当前平台不可用的循环会直接报错并列出可用项(不会静默降级)。 + ## HTTP 接口 接口统一为路径参数风格(`/search`、`/find` 搜索类使用 query 参数)。 @@ -173,10 +200,13 @@ app/ Python 应用(FastAPI,Python 3.12) auth/ 登录协议(wy/kg/kg_simulate/tx/lr/bili) apis/ 各平台官方直连取流(14 平台,含 CDN 选址/档位降级) platforms/ 平台模块(每平台一个包,MODULE: Platform + 子模块) +scripts/ + serve.py 启动器(uvicorn / granian,事件循环 rloop/uvloop/winloop 选择与校验) docs/ PORTING.md TS → Python 平台移植契约(目录/子模块协议/签名差异/验收要求) -pyproject.toml uv 项目定义(依赖、ruff、pyright、pytest 配置) -tests/ pytest 目录(预留:TS 版 smoke 测试尚未移植) +pyproject.toml uv 项目定义(依赖、granian 可选 extra、ruff、pyright、pytest 配置) +tests/ + test_serve.py 启动器服务器/循环选择逻辑测试(TS 版 smoke 测试尚未移植) ``` > 分层依赖单向:`routes` → `sdk`,`sdk` 内部自包含。全源码 `.py` 由 uv 管理的 CPython 3.12 直接运行, @@ -193,4 +223,6 @@ tests/ pytest 目录(预留:TS 版 smoke 测试尚未移植 - **5sing 歌词**:2026-10-23 实测匿名 `newget` 不返回 `dynamicWords`(搜索结果与多个关键词均如此,Node 版同样),拿不到时接口如实报错。 - **5sing 歌单详情**:依赖 5sing 网页(`5sing.kugou.com/.../dj/...html`)解析曲目;2026-10-02 曾整体 502,2026-10-23 实测已恢复。 - **link 解析**:`/parse/*` 走各平台网页链接解析(需上游有对应实现);未实现的平台返回 404。 +- **多 worker**:限流计数、登录续期调度、咪咕加密流票据都是进程内状态;`WORKERS>1` 时各自独立 + (续期会重复执行、`/stream/mg` 票据可能落到别的 worker)。默认单 worker,多 worker 部署需自行加粘性会话。 - 平台接口随时可能变更,遇失效时可对照 `app/sdk/apis/` 内的接口文档调整。 diff --git a/pyproject.toml b/pyproject.toml index 26c6185..1e40053 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,18 @@ dependencies = [ "pillow>=10", ] +# 可选:Granian 服务器(Rust HTTP 实现)与高性能事件循环 +# rloop —— Granian 内置的 Rust 事件循环,跨平台 +# uvloop —— POSIX 上的 libuv 循环 +# winloop —— Windows 上的 libuv 循环(uvloop 的 Windows 对应实现) +# 安装:uv sync --extra granian +[project.optional-dependencies] +granian = [ + # granian 自带的 extras 已按平台配好 marker: + # rloop(Unix)、uvloop(Unix)、winloop(Windows)、reload(watchfiles) + "granian[reload,rloop,uvloop,winloop]>=2.0", +] + [dependency-groups] dev = [ "pytest>=8", @@ -25,7 +37,7 @@ dev = [ [tool.ruff] line-length = 120 target-version = "py312" -src = ["app", "tests"] +src = ["app", "tests", "scripts"] [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "BLE", "ASYNC", "RUF"] @@ -43,7 +55,7 @@ ignore = [ quote-style = "double" [tool.pyright] -include = ["app", "tests"] +include = ["app", "tests", "scripts"] exclude = [".venv", "tmp"] pythonVersion = "3.12" typeCheckingMode = "basic" diff --git a/scripts/serve.py b/scripts/serve.py new file mode 100644 index 0000000..be23abe --- /dev/null +++ b/scripts/serve.py @@ -0,0 +1,171 @@ +"""启动器 —— uvicorn / granian,可选事件循环 rloop / uvloop / winloop。 + +用法(在仓库根目录执行): + + uv run python scripts/serve.py # 默认:装了 granian 就优先 granian,否则 uvicorn + uv run python scripts/serve.py --server granian # Rust HTTP 服务器 + uv run python scripts/serve.py --server granian --loop rloop # 仅 Unix(granian[rloop]) + uv run python scripts/serve.py --server granian --loop uvloop # 仅 Unix(granian[uvloop]) + uv run python scripts/serve.py --server granian --loop winloop # 仅 Windows(granian[winloop]) + uv run python scripts/serve.py --server uvicorn --loop uvloop # uvicorn 在 Unix 上等价 + +事件循环可用性(与 granian 的 extras / 平台 marker 一致): + asyncio 始终可用 + rloop rloop 包,仅 Unix(granian 内置的 Rust 循环) + uvloop uvloop 包,仅 Unix + winloop winloop 包,仅 Windows(uvloop 的 Windows 实现) +`auto` 交给服务器自行选择:granian 优先 rloop → uvloop → winloop → asyncio; +uvicorn 优先 uvloop(已安装时)→ asyncio。 + +环境变量(参数优先):SERVER / LOOP / HOST / PORT / WORKERS / RELOAD。 +PORT 默认读取 .env 后的 16688;HOST 默认 0.0.0.0(局域网播放器可直连)。 +""" + +from __future__ import annotations + +import argparse +import importlib.util +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from app import __version__, config # noqa: E402 + +APP_TARGET = "app.main:app" +SERVERS = ("auto", "uvicorn", "granian") +LOOPS = ("auto", "asyncio", "rloop", "uvloop", "winloop") + +#: 每个循环对应的可导入包(asyncio 无依赖) +LOOP_PACKAGES = {"rloop": "rloop", "uvloop": "uvloop", "winloop": "winloop"} + + +def has_module(name: str) -> bool: + """模块是否可导入(不实际导入,避免触发事件循环初始化)。""" + try: + return importlib.util.find_spec(name) is not None + except (ImportError, ValueError): + return False + + +def pick_server(requested: str) -> str: + """auto:安装了 granian 就用 granian,否则 uvicorn。""" + if requested not in SERVERS: + raise SystemExit(f"不支持的 server: {requested}(可选:{', '.join(SERVERS)})") + if requested != "auto": + return requested + return "granian" if has_module("granian") else "uvicorn" + + +def available_loops(server: str) -> list[str]: + """当前平台/安装状态下该服务器可用的事件循环(含 auto)。""" + loops = ["auto", "asyncio"] + if server == "granian": + loops.extend(name for name in ("rloop", "uvloop", "winloop") if has_module(LOOP_PACKAGES[name])) + elif has_module("uvloop"): + loops.append("uvloop") + return loops + + +def resolve_loop(server: str, loop: str) -> str: + """校验事件循环可用性;显式请求不可用时给出可操作的原因(不做静默降级)。""" + if loop not in LOOPS: + raise SystemExit(f"不支持的 loop: {loop}(可选:{', '.join(LOOPS)})") + if loop == "auto": + return loop + if server == "uvicorn": + if loop == "asyncio": + return loop + if loop == "uvloop": + if not has_module("uvloop"): + raise SystemExit("uvloop 未安装(仅 Unix 可用):uv sync --extra granian 或改用 --loop auto") + return loop + raise SystemExit(f"uvicorn 不支持 --loop {loop};请改用 --server granian(granian 支持 rloop/uvloop/winloop)") + + package = LOOP_PACKAGES.get(loop) + if package and not has_module(package): + hint = "仅 Windows 可用" if loop == "winloop" else "仅 Unix 可用" + raise SystemExit( + f"granian 的 {loop} 事件循环不可用({package} 未安装,{hint})。可用:{', '.join(available_loops(server))}" + ) + return loop + + +def _env_bool(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on") + + +def build_command( + server: str, + *, + host: str, + port: int, + workers: int, + loop: str, + reload: bool, +) -> list[str]: + command = [sys.executable, "-m", server] + if server == "granian": + command += ["--interface", "asgi", "--host", host, "--port", str(port)] + command += ["--workers", str(workers), "--loop", loop] + if reload: + command.append("--reload") + if reload and not has_module("watchfiles"): + print("[serve] 警告:granian --reload 需要 watchfiles(uv sync --extra granian 已包含)", flush=True) + command.append(APP_TARGET) + else: + command += [APP_TARGET, "--host", host, "--port", str(port)] + command += ["--workers", str(workers), "--loop", loop] + if reload: + command.append("--reload") + return command + + +def _arg_or_env(value: str | None, env_name: str, default: str | None = None) -> str | None: + return value if value is not None else os.environ.get(env_name, default) + + +def main(argv: list[str] | None = None) -> int: + config.load_env() + + parser = argparse.ArgumentParser(description="MusicServer 启动器(uvicorn / granian)") + parser.add_argument("--server", choices=SERVERS, help="HTTP 服务器(默认 auto:优先 granian)") + parser.add_argument("--loop", choices=LOOPS, help="事件循环(默认 auto)") + parser.add_argument("--host", help="监听地址(默认 0.0.0.0)") + parser.add_argument("--port", type=int, help="端口(默认 PORT 或 16688)") + parser.add_argument("--workers", type=int, help="worker 进程数(默认 1)") + parser.add_argument("--reload", action="store_true", help="开发热重载(强制单 worker)") + args = parser.parse_args(argv) + + server = pick_server(str(_arg_or_env(args.server, "SERVER", "auto"))) + if not has_module(server): + raise SystemExit(f"未安装 {server}:uv sync --extra granian(或 uv add {server})") + + loop = resolve_loop(server, str(_arg_or_env(args.loop, "LOOP", "auto"))) + host = str(_arg_or_env(args.host, "HOST", "0.0.0.0")) + port = args.port or config.port() + workers = args.workers or int(os.environ.get("WORKERS") or 1) + reload = args.reload or _env_bool("RELOAD") + + if reload and workers > 1: + print(f"[serve] --reload 与多 worker 不兼容,workers 从 {workers} 改为 1", flush=True) + workers = 1 + + command = build_command(server, host=host, port=port, workers=workers, loop=loop, reload=reload) + print( + f"[serve] MusicServer {__version__} | {server} | loop={loop} | http://{host}:{port} " + f"| workers={workers}{' | reload' if reload else ''}", + flush=True, + ) + try: + return subprocess.call(command) + except KeyboardInterrupt: # Ctrl+C 由子进程自行处理,父进程安静退出 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_serve.py b/tests/test_serve.py new file mode 100644 index 0000000..ac77910 --- /dev/null +++ b/tests/test_serve.py @@ -0,0 +1,67 @@ +"""启动器(scripts/serve.py)的循环/服务器选择逻辑测试(不启动真实服务器)。""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from scripts import serve + + +def test_pick_server_auto_prefers_granian(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(serve, "has_module", lambda name: name == "granian") + assert serve.pick_server("auto") == "granian" + + monkeypatch.setattr(serve, "has_module", lambda name: False) + assert serve.pick_server("auto") == "uvicorn" + + +def test_available_loops_follow_installed_packages(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(serve, "has_module", lambda name: name == "winloop") + assert serve.available_loops("granian") == ["auto", "asyncio", "winloop"] + assert serve.available_loops("uvicorn") == ["auto", "asyncio"] + + monkeypatch.setattr(serve, "has_module", lambda name: name in ("rloop", "uvloop")) + assert serve.available_loops("granian") == ["auto", "asyncio", "rloop", "uvloop"] + assert serve.available_loops("uvicorn") == ["auto", "asyncio", "uvloop"] + + +def test_explicit_unavailable_loop_fails_with_hint(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(serve, "has_module", lambda name: False) + with pytest.raises(SystemExit) as error: + serve.resolve_loop("granian", "rloop") + message = str(error.value) + assert "rloop" in message and "可用" in message + + +def test_uvicorn_rejects_granian_only_loops() -> None: + with pytest.raises(SystemExit): + serve.resolve_loop("uvicorn", "winloop") + with pytest.raises(SystemExit): + serve.resolve_loop("uvicorn", "rloop") + + +def test_uvicorn_auto_and_asyncio_allowed() -> None: + assert serve.resolve_loop("uvicorn", "auto") == "auto" + assert serve.resolve_loop("uvicorn", "asyncio") == "asyncio" + + +def test_build_command_granian(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(serve, "has_module", lambda name: True) + command = serve.build_command("granian", host="0.0.0.0", port=16688, workers=2, loop="winloop", reload=True) + assert command[:3] == [sys.executable, "-m", "granian"] + assert command[command.index("--loop") + 1] == "winloop" + assert command[command.index("--workers") + 1] == "2" + assert "--reload" in command + assert command[-1] == serve.APP_TARGET + + +def test_build_command_uvicorn() -> None: + command = serve.build_command("uvicorn", host="127.0.0.1", port=9000, workers=1, loop="auto", reload=False) + assert command[:3] == [sys.executable, "-m", "uvicorn"] + assert command[3] == serve.APP_TARGET + assert "--reload" not in command diff --git a/uv.lock b/uv.lock index 70d37f1..de35832 100644 --- a/uv.lock +++ b/uv.lock @@ -269,6 +269,41 @@ wheels = [ { 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 = "granian" +version = "2.8.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/63/c2548102956296ee0f8671291dd5a6f3f3dc2fc7c4e62e0b86e3be6de571/granian-2.8.5.tar.gz", hash = "sha256:032588689e2d524d633fcb088ee54fad4c5655d0af1a75058d5b4240be4f6864", size = 130933, upload-time = "2026-10-20T11:43:38.079Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/91/103c04ea8ef7d9d40cdecb96a07cae09bfe9e3857d39b8174fc5c1a771fd/granian-2.8.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3404a620364f3816f126863910e6dc86da833298608896e556c01c9bf52a63c2", size = 4600588, upload-time = "2026-10-20T11:42:01.027Z" }, + { url = "https://files.pythonhosted.org/packages/df/f2/52735de5caa6db560bfe91b60c97f5a1bf57a035ccd08004f21f18762830/granian-2.8.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dcb0ecee7650794738fa347ce1aac5a7f790bf36dc0dace4194578335ad3fa60", size = 4310695, upload-time = "2026-10-20T11:42:02.305Z" }, + { url = "https://files.pythonhosted.org/packages/60/e3/274741428e960bf59b12c51cdca2340698e708f3bab4ec6fa16b0765d12a/granian-2.8.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a5e1459736acd617631524db3c430df7b7fe2fd55b6f21536eb9d30a603d41", size = 5156401, upload-time = "2026-10-20T11:42:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/77/53/c9c16f814597303e4373eb3cdfc8b0ca75582f6939a1a84b9109c98cbfeb/granian-2.8.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:217c38e88aa324ef73c9c5d823434b5c5ee7a7181b36c23b408d439905ece7a9", size = 4658203, upload-time = "2026-10-20T11:42:05.252Z" }, + { url = "https://files.pythonhosted.org/packages/26/7e/9a4aebcc1fe18d849b79cbf2aded21e8d6023436b98a53b1514955c5470a/granian-2.8.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66f0de2cb17109da7ca070a2898e4543083914c2c440ba4a134a53cf4ec526be", size = 5067921, upload-time = "2026-10-20T11:42:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4b/d7bb8f98d739e62379d88131654ee68de441ccc5d6118f578784689257fe/granian-2.8.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:95bae2039b53bc532386a0b1327b52e73c16b3e11997a727024f9bd385242a2b", size = 5117522, upload-time = "2026-10-20T11:42:07.749Z" }, + { url = "https://files.pythonhosted.org/packages/59/3e/2c494c6afa6c4ae564c05d0611ce7ee108824812a74bb20e024e752a8f2c/granian-2.8.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1c72079469aba9856d4d030b21ff75a5c2d9a201cab930926237ab1646e5596f", size = 5141659, upload-time = "2026-10-20T11:42:08.96Z" }, + { url = "https://files.pythonhosted.org/packages/5a/16/047f981331408110441c5adbdf9a21acd34846e13ee6546e77c7359b760a/granian-2.8.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d263fe13e16a612a0149e6948f556e5ed8fb4fdec9890812743d116025fb442", size = 5210772, upload-time = "2026-10-20T11:42:10.818Z" }, + { url = "https://files.pythonhosted.org/packages/3f/21/40f73b96a0fe0bcdea7a65ede042a029a10c1490a513fbf8909087eab02c/granian-2.8.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:19902f90f272c089ecea998214c62f7eae846d454a9a82458fcb77b41adddd29", size = 5157211, upload-time = "2026-10-20T11:42:12.484Z" }, + { url = "https://files.pythonhosted.org/packages/14/a5/ac92e100d1f5141115ee796321d8f9d25d8f2c07198e5763b1ce53c457bc/granian-2.8.5-cp312-cp312-win_amd64.whl", hash = "sha256:efc2ae72102983a32cf6cc524a595fae3211937bdeb5bf93767b01dc6798f8ea", size = 3212050, upload-time = "2026-10-20T11:42:13.894Z" }, +] + +[package.optional-dependencies] +reload = [ + { name = "watchfiles" }, +] +rloop = [ + { name = "rloop", marker = "sys_platform != 'win32'" }, +] +uvloop = [ + { name = "uvloop", marker = "platform_python_implementation == 'CPython' and sys_platform != 'win32'" }, +] +winloop = [ + { name = "winloop", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -388,6 +423,11 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] +[package.optional-dependencies] +granian = [ + { name = "granian", extra = ["reload", "rloop", "uvloop", "winloop"] }, +] + [package.dev-dependencies] dev = [ { name = "httpx" }, @@ -403,10 +443,12 @@ requires-dist = [ { name = "brotli", specifier = ">=1.1" }, { name = "cryptography", specifier = ">=43" }, { name = "fastapi", specifier = ">=0.115" }, + { name = "granian", extras = ["reload", "rloop", "uvloop", "winloop"], marker = "extra == 'granian'", specifier = ">=2.0" }, { name = "pillow", specifier = ">=10" }, { name = "qrcode", specifier = ">=8" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, ] +provides-extras = ["granian"] [package.metadata.requires-dev] dev = [ @@ -648,6 +690,23 @@ 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 = "rloop" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/a2/331e56526095044a06e1e0ed5ec0a9ab7a1ce50ee5ed32a8adce1e1c9df6/rloop-0.5.0.tar.gz", hash = "sha256:6706b97558fc787c607ae7dac9ce3d5c823afbf21ef9686284247cbdc311f95e", size = 44758, upload-time = "2026-08-30T14:32:17.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/6b/3f307a59a31c3154554da5981a7379db41700b20df283e3cf2addf1c4ab4/rloop-0.5.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6f389f7250e32078ddf8e9c3ab87b3be738cccb0e2e99b9edf9c5435d07ad70d", size = 344425, upload-time = "2026-08-30T14:31:01.41Z" }, + { url = "https://files.pythonhosted.org/packages/c5/30/573c5389f1665daba6aa78e5beb709c283a044b1f05c2c2b90211ad5ae2e/rloop-0.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b5ad51ce6089c7f6892e4cfd4f0db18aca65efec9a0be77f39265560ef12570e", size = 321921, upload-time = "2026-08-30T14:31:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/957b80c9542e0908eca5a4e37741e356f0fab83c999aede160260adaeef2/rloop-0.5.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7e4cd6d26705d9d9d0a4ca227e9c4e9f60145e2cae499481dcef2df6dda489c7", size = 401550, upload-time = "2026-08-30T14:31:03.903Z" }, + { url = "https://files.pythonhosted.org/packages/de/9c/6e1a768ebe2f4c89e8a93203aa501e86672206cad32e57c4116b195b7896/rloop-0.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd4b86dc8519af28e6cb7d07fc82c675edc1be21074fce071851cc0bf8deaa4d", size = 355556, upload-time = "2026-08-30T14:31:05.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/9d047ad4bafa0a61a0fc6ac25f8c1d87f8bc8e27f270651d27457cea0a60/rloop-0.5.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ccb7766d90fa66028d9fab15969c4c777953c95c7b307aad7b6315fa24aa523f", size = 376083, upload-time = "2026-08-30T14:31:07.309Z" }, + { url = "https://files.pythonhosted.org/packages/12/94/55bbed8a92f48ef7ce023f7c822ee0b055e7804cc3403f012f396dd0d0d7/rloop-0.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:907f7759f03f57feca3f7048876799c65855e09c5a879214d14b2513185103a9", size = 369378, upload-time = "2026-08-30T14:31:08.798Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b8/534aea30c3ce62c7fd82bf8c8daab4bb522100a250fd8e2785685d927c21/rloop-0.5.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:53a4c85f4d88f288830eafc69b05e4aeb6e412fbbe838a110943af66cfdff552", size = 534315, upload-time = "2026-08-30T14:31:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/01/81/212704e88bd5cf4355bdcc23d88b6f3093be814a719b346d56d2104a8eab/rloop-0.5.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:45f718b1d777ba1ec19d3930f28ccc615be1b17152a474408ea5454793116281", size = 653639, upload-time = "2026-08-30T14:31:11.872Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/659dcb15c728e9686b7aba3bee2f4ee4b4d4b68013848439d25ff6d6d993/rloop-0.5.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2b8c5c5d077d2015ecd095eb2a578bf58237aea8fb7bc00d0e7abe6205cfd5b0", size = 583098, upload-time = "2026-08-30T14:31:13.191Z" }, +] + [[package]] name = "ruff" version = "0.17.2" @@ -804,6 +863,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/b0/3f01fcb3406662f6deb1e3e72b34dd0979e993991c0b7435a9e12bc14f02/websockets-17.3-py3-none-any.whl", hash = "sha256:dc2b495a92b3971966bf102e73589f950db3ec1ca6713265355ce7771d97b199", size = 212375, upload-time = "2026-10-19T16:58:04.483Z" }, ] +[[package]] +name = "winloop" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/8c/c9af1d80e8680be97127b2429b03ecd2030e90ede8ab252a218db154bcd3/winloop-0.8.0.tar.gz", hash = "sha256:5a34605a72df5416279e89193688de0efb9e541c99e65923abcf0d7ff788510a", size = 2759840, upload-time = "2026-10-10T19:51:12.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/33/b28cd18a260800726e531575607ed3f2692993deb54e5fd05b129d0bd58c/winloop-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:10e0b32d206274435dc6f1f4542f6f6c47287e45c661c510ab782cecb52d65a3", size = 699338, upload-time = "2026-10-10T19:50:57.486Z" }, + { url = "https://files.pythonhosted.org/packages/9a/10/54c71ab926840a2da5b6de6fd669e874b49eac97d496b0d27c9c57fd73ed/winloop-0.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:2b200e3d4d833b82693c6038ddbaed72dd12e5517008c08042615545c47b4595", size = 604518, upload-time = "2026-10-10T19:50:58.804Z" }, +] + [[package]] name = "yarl" version = "1.25.1"