feat(server): 支持 Granian 与 rloop/uvloop/winloop 事件循环

- 新增 scripts/serve.py 启动器:server auto/uvicorn/granian,循环可用性与平台校验(显式不可用直接报错)
- pyproject 增加 granian 可选 extra(granian[reload,rloop,uvloop,winloop],平台 marker 由 granian 维护)
- tests/test_serve.py 覆盖服务器/循环选择与命令拼装;README 补充启动方式、循环矩阵与多 worker 限制
This commit is contained in:
snowy-yang committed 2026-10-23 22:12:08 +08:00
1 parent e168f43b81
commit 0cae770ace
5 files changed
+355 -4

No files matched your search

+34 -2
View File
@@ -44,12 +44,39 @@ uv run uvicorn app.main:app --host 0.0.0.0 --port 16688
# 默认端口 16688;也可用 PORT 环境变量 + --port $PORT # 默认端口 16688;也可用 PORT 环境变量 + --port $PORT
uv run ruff check app && uv run ruff format --check app # 规范检查 uv run ruff check app && uv run ruff format --check app # 规范检查
uv run pyright # 类型检查(基础模式) uv run pyright # 类型检查(基础模式)
uv run pytest # 单元测试(tests/)
``` ```
环境变量(`PORT`、`AUTH_KEY`、`RATE_LIMIT`、`DATA_DIR` 等)可写入项目根目录的 `.env` 文件(已 gitignore),启动时自动加载;已存在的真实环境变量优先于 `.env` 文件值。登录凭据持久化在 `data/auth.json`(0600,原子写入)。 环境变量(`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`)。 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 接口 ## HTTP 接口
接口统一为路径参数风格(`/search`、`/find` 搜索类使用 query 参数)。 接口统一为路径参数风格(`/search`、`/find` 搜索类使用 query 参数)。
@@ -173,10 +200,13 @@ app/ Python 应用(FastAPI,Python 3.12)
auth/ 登录协议(wy/kg/kg_simulate/tx/lr/bili) auth/ 登录协议(wy/kg/kg_simulate/tx/lr/bili)
apis/ 各平台官方直连取流(14 平台,含 CDN 选址/档位降级) apis/ 各平台官方直连取流(14 平台,含 CDN 选址/档位降级)
platforms/ 平台模块(每平台一个包,MODULE: Platform + 子模块) platforms/ 平台模块(每平台一个包,MODULE: Platform + 子模块)
scripts/
serve.py 启动器(uvicorn / granian,事件循环 rloop/uvloop/winloop 选择与校验)
docs/ docs/
PORTING.md TS → Python 平台移植契约(目录/子模块协议/签名差异/验收要求) PORTING.md TS → Python 平台移植契约(目录/子模块协议/签名差异/验收要求)
pyproject.toml uv 项目定义(依赖、ruff、pyright、pytest 配置) pyproject.toml uv 项目定义(依赖、granian 可选 extra、ruff、pyright、pytest 配置)
tests/ pytest 目录(预留:TS 版 smoke 测试尚未移植) tests/
test_serve.py 启动器服务器/循环选择逻辑测试(TS 版 smoke 测试尚未移植)
``` ```
> 分层依赖单向:`routes` → `sdk`,`sdk` 内部自包含。全源码 `.py` 由 uv 管理的 CPython 3.12 直接运行, > 分层依赖单向:`routes` → `sdk`,`sdk` 内部自包含。全源码 `.py` 由 uv 管理的 CPython 3.12 直接运行,
@@ -193,4 +223,6 @@ tests/ pytest 目录(预留:TS 版 smoke 测试尚未移植
- **5sing 歌词**:2026-10-23 实测匿名 `newget` 不返回 `dynamicWords`(搜索结果与多个关键词均如此,Node 版同样),拿不到时接口如实报错。 - **5sing 歌词**:2026-10-23 实测匿名 `newget` 不返回 `dynamicWords`(搜索结果与多个关键词均如此,Node 版同样),拿不到时接口如实报错。
- **5sing 歌单详情**:依赖 5sing 网页(`5sing.kugou.com/.../dj/...html`)解析曲目;2026-10-02 曾整体 502,2026-10-23 实测已恢复。 - **5sing 歌单详情**:依赖 5sing 网页(`5sing.kugou.com/.../dj/...html`)解析曲目;2026-10-02 曾整体 502,2026-10-23 实测已恢复。
- **link 解析**:`/parse/*` 走各平台网页链接解析(需上游有对应实现);未实现的平台返回 404。 - **link 解析**:`/parse/*` 走各平台网页链接解析(需上游有对应实现);未实现的平台返回 404。
- **多 worker**:限流计数、登录续期调度、咪咕加密流票据都是进程内状态;`WORKERS>1` 时各自独立
(续期会重复执行、`/stream/mg` 票据可能落到别的 worker)。默认单 worker,多 worker 部署需自行加粘性会话。
- 平台接口随时可能变更,遇失效时可对照 `app/sdk/apis/` 内的接口文档调整。 - 平台接口随时可能变更,遇失效时可对照 `app/sdk/apis/` 内的接口文档调整。
+14 -2
View File
@@ -13,6 +13,18 @@ dependencies = [
"pillow>=10", "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] [dependency-groups]
dev = [ dev = [
"pytest>=8", "pytest>=8",
@@ -25,7 +37,7 @@ dev = [
[tool.ruff] [tool.ruff]
line-length = 120 line-length = 120
target-version = "py312" target-version = "py312"
src = ["app", "tests"] src = ["app", "tests", "scripts"]
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "BLE", "ASYNC", "RUF"] select = ["E", "F", "W", "I", "UP", "B", "BLE", "ASYNC", "RUF"]
@@ -43,7 +55,7 @@ ignore = [
quote-style = "double" quote-style = "double"
[tool.pyright] [tool.pyright]
include = ["app", "tests"] include = ["app", "tests", "scripts"]
exclude = [".venv", "tmp"] exclude = [".venv", "tmp"]
pythonVersion = "3.12" pythonVersion = "3.12"
typeCheckingMode = "basic" typeCheckingMode = "basic"
+171
View File
@@ -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())
+67
View File
@@ -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
Generated
+69
View File
@@ -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" }, { 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]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@@ -388,6 +423,11 @@ dependencies = [
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
[package.optional-dependencies]
granian = [
{ name = "granian", extra = ["reload", "rloop", "uvloop", "winloop"] },
]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "httpx" }, { name = "httpx" },
@@ -403,10 +443,12 @@ requires-dist = [
{ name = "brotli", specifier = ">=1.1" }, { name = "brotli", specifier = ">=1.1" },
{ name = "cryptography", specifier = ">=43" }, { name = "cryptography", specifier = ">=43" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "granian", extras = ["reload", "rloop", "uvloop", "winloop"], marker = "extra == 'granian'", specifier = ">=2.0" },
{ name = "pillow", specifier = ">=10" }, { name = "pillow", specifier = ">=10" },
{ name = "qrcode", specifier = ">=8" }, { name = "qrcode", specifier = ">=8" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
] ]
provides-extras = ["granian"]
[package.metadata.requires-dev] [package.metadata.requires-dev]
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" }, { 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]] [[package]]
name = "ruff" name = "ruff"
version = "0.17.2" 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" }, { 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]] [[package]]
name = "yarl" name = "yarl"
version = "1.25.1" version = "1.25.1"