diff --git a/README.md b/README.md index fbbb118..0eda2cf 100644 --- a/README.md +++ b/README.md @@ -53,29 +53,37 @@ Node/TypeScript 版(v1.x,`master` 分支):`pnpm install && pnpm start` ### 服务器与事件循环(uvicorn / granian) -`scripts/serve.py` 统一启动入口:默认 `--server auto`(装了 granian 就用 granian,否则 uvicorn), +`scripts/serve.py` 统一启动入口。默认组合按平台确定: + +| 平台 | 默认服务器 | 默认事件循环 | +| --- | --- | --- | +| Windows | granian | winloop(未安装则 asyncio) | +| Linux / macOS | granian | rloop(未安装则 uvloop → asyncio) | +| Windows + `--server uvicorn` | uvicorn | asyncio(uvicorn 不支持 winloop) | +| Linux / macOS + `--server uvicorn` | uvicorn | uvloop(未安装则 asyncio) | + 事件循环可用 `auto / asyncio / rloop / uvloop / winloop`,参数与环境变量等价 -(`SERVER` / `LOOP` / `HOST` / `PORT` / `WORKERS` / `RELOAD`)。 +(`SERVER` / `LOOP` / `HOST` / `PORT` / `WORKERS` / `RELOAD`)。`auto` 会在启动器内解析成上表中的具体循环, +启动日志会打印实际使用的组合;显式请求当前平台不可用的循环会直接报错并列出可用项(不会静默降级)。 ```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) +uv sync # 默认依赖已含 granian[rloop/uvloop/winloop/reload] +uv run python scripts/serve.py # Windows → granian+winloop;Unix → granian+rloop +uv run python scripts/serve.py --server uvicorn # Unix → uvicorn+uvloop;Windows → uvicorn+asyncio +uv run python scripts/serve.py --loop rloop # 仅 Unix(Rust 事件循环) +uv run python scripts/serve.py --loop uvloop # 仅 Unix +uv run python scripts/serve.py --loop winloop # 仅 Windows +uv run python scripts/serve.py --server granian --reload # 热重载(强制单 worker) ``` | 事件循环 | 平台 | 说明 | | --- | --- | --- | | `asyncio` | 全平台 | 标准库,始终可用 | -| `rloop` | Unix | granian 的 Rust 事件循环(`rloop` 包,Windows 无 wheel) | +| `rloop` | Unix | Rust 事件循环(`rloop` 包,Windows 无 wheel) | | `uvloop` | Unix | libuv 事件循环 | | `winloop` | Windows | uvloop 的 Windows 实现 | -| `auto` | 全平台 | granian:rloop → uvloop → winloop → asyncio;uvicorn:uvloop → asyncio | -显式请求当前平台不可用的循环会直接报错并列出可用项(不会静默降级)。 +granian 也支持显式指定与默认不同的循环(如 Unix 上 `--loop uvloop`、Windows 上 `--loop asyncio`)。 ## HTTP 接口 @@ -204,7 +212,7 @@ scripts/ serve.py 启动器(uvicorn / granian,事件循环 rloop/uvloop/winloop 选择与校验) docs/ PORTING.md TS → Python 平台移植契约(目录/子模块协议/签名差异/验收要求) -pyproject.toml uv 项目定义(依赖、granian 可选 extra、ruff、pyright、pytest 配置) +pyproject.toml uv 项目定义(默认 granian/uvicorn 依赖、ruff、pyright、pytest 配置) tests/ test_serve.py 启动器服务器/循环选择逻辑测试(TS 版 smoke 测试尚未移植) ``` diff --git a/pyproject.toml b/pyproject.toml index 1e40053..890f74a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,9 @@ description = "多平台音乐聚合服务器" requires-python = ">=3.12,<3.13" dependencies = [ "fastapi>=0.115", + # 默认服务器:granian;uvicorn 作为备选(--server uvicorn) + # extras 按平台自动选择循环:rloop/uvloop(Unix)、winloop(Windows)、reload(watchfiles) + "granian[reload,rloop,uvloop,winloop]>=2.0", "uvicorn[standard]>=0.30", "aiohttp>=3.10", "brotli>=1.1", @@ -13,18 +16,6 @@ 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", diff --git a/scripts/serve.py b/scripts/serve.py index be23abe..190e7bf 100644 --- a/scripts/serve.py +++ b/scripts/serve.py @@ -1,21 +1,22 @@ -"""启动器 —— uvicorn / granian,可选事件循环 rloop / uvloop / winloop。 +"""启动器 —— 默认 granian + 平台首选事件循环(Windows: winloop / Unix: rloop)。 用法(在仓库根目录执行): - 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 上等价 + uv run python scripts/serve.py # Windows: granian+winloop;Unix: granian+rloop + uv run python scripts/serve.py --server uvicorn # Unix 默认 uvloop,Windows 默认 asyncio + uv run python scripts/serve.py --loop rloop # 显式指定(平台/安装不支持时直接报错) + uv run python scripts/serve.py --loop uvloop + uv run python scripts/serve.py --loop winloop -事件循环可用性(与 granian 的 extras / 平台 marker 一致): +事件循环可用性(与依赖的平台 marker 一致): asyncio 始终可用 - rloop rloop 包,仅 Unix(granian 内置的 Rust 循环) + rloop rloop 包,仅 Unix(Rust 事件循环) uvloop uvloop 包,仅 Unix winloop winloop 包,仅 Windows(uvloop 的 Windows 实现) -`auto` 交给服务器自行选择:granian 优先 rloop → uvloop → winloop → asyncio; -uvicorn 优先 uvloop(已安装时)→ asyncio。 + +`auto` 在启动器内解析为具体循环,不交给服务器: + granian: Windows → winloop → asyncio;Unix → rloop → uvloop → asyncio + uvicorn: Windows → asyncio;Unix → uvloop → asyncio 环境变量(参数优先):SERVER / LOOP / HOST / PORT / WORKERS / RELOAD。 PORT 默认读取 .env 后的 16688;HOST 默认 0.0.0.0(局域网播放器可直连)。 @@ -42,6 +43,7 @@ LOOPS = ("auto", "asyncio", "rloop", "uvloop", "winloop") #: 每个循环对应的可导入包(asyncio 无依赖) LOOP_PACKAGES = {"rloop": "rloop", "uvloop": "uvloop", "winloop": "winloop"} +IS_WINDOWS = sys.platform.startswith("win") def has_module(name: str) -> bool: @@ -53,7 +55,7 @@ def has_module(name: str) -> bool: def pick_server(requested: str) -> str: - """auto:安装了 granian 就用 granian,否则 uvicorn。""" + """auto:默认 granian(uv sync 已包含);未安装时回退 uvicorn。""" if requested not in SERVERS: raise SystemExit(f"不支持的 server: {requested}(可选:{', '.join(SERVERS)})") if requested != "auto": @@ -71,18 +73,36 @@ def available_loops(server: str) -> list[str]: return loops +def default_loop(server: str) -> str: + """平台默认循环:granian = Windows winloop / Unix rloop;uvicorn = Unix uvloop / Windows asyncio。 + + 依赖未安装(例如只用 uvloop 时没装 rloop)则按优先级回退,直到 asyncio。 + """ + if server == "granian": + order = ("winloop", "asyncio") if IS_WINDOWS else ("rloop", "uvloop", "asyncio") + else: + order = ("asyncio",) if IS_WINDOWS else ("uvloop", "asyncio") + for loop in order: + if loop == "asyncio": + return loop + package = LOOP_PACKAGES.get(loop) + if package and has_module(package): + return loop + return "asyncio" + + def resolve_loop(server: str, loop: str) -> str: - """校验事件循环可用性;显式请求不可用时给出可操作的原因(不做静默降级)。""" + """auto → 平台默认循环;显式请求不可用时给出可操作的原因(不做静默降级)。""" if loop not in LOOPS: raise SystemExit(f"不支持的 loop: {loop}(可选:{', '.join(LOOPS)})") if loop == "auto": - return loop + return default_loop(server) 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") + raise SystemExit("uvloop 未安装(仅 Unix 可用):uv sync 或改用 --loop auto") return loop raise SystemExit(f"uvicorn 不支持 --loop {loop};请改用 --server granian(granian 支持 rloop/uvloop/winloop)") @@ -115,7 +135,7 @@ def build_command( if reload: command.append("--reload") if reload and not has_module("watchfiles"): - print("[serve] 警告:granian --reload 需要 watchfiles(uv sync --extra granian 已包含)", flush=True) + print("[serve] 警告:granian --reload 需要 watchfiles(uv sync 已包含)", flush=True) command.append(APP_TARGET) else: command += [APP_TARGET, "--host", host, "--port", str(port)] @@ -141,9 +161,12 @@ def main(argv: list[str] | None = None) -> int: 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"))) + requested_server = str(_arg_or_env(args.server, "SERVER", "auto")) + server = pick_server(requested_server) if not has_module(server): - raise SystemExit(f"未安装 {server}:uv sync --extra granian(或 uv add {server})") + raise SystemExit(f"未安装 {server}:uv sync(或 uv add {server})") + if requested_server == "auto" and server == "uvicorn" and not has_module("granian"): + print("[serve] 未安装 granian,回退 uvicorn;uv sync 可安装默认的 granian 依赖", flush=True) loop = resolve_loop(server, str(_arg_or_env(args.loop, "LOOP", "auto"))) host = str(_arg_or_env(args.host, "HOST", "0.0.0.0")) diff --git a/tests/test_serve.py b/tests/test_serve.py index ac77910..6cf212a 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -45,11 +45,57 @@ def test_uvicorn_rejects_granian_only_loops() -> None: serve.resolve_loop("uvicorn", "rloop") -def test_uvicorn_auto_and_asyncio_allowed() -> None: - assert serve.resolve_loop("uvicorn", "auto") == "auto" +def test_uvicorn_auto_and_asyncio_allowed(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(serve, "IS_WINDOWS", True) + assert serve.resolve_loop("uvicorn", "auto") == "asyncio" assert serve.resolve_loop("uvicorn", "asyncio") == "asyncio" +def test_default_loop_granian_windows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(serve, "IS_WINDOWS", True) + monkeypatch.setattr(serve, "has_module", lambda name: name == "winloop") + assert serve.default_loop("granian") == "winloop" + + +def test_default_loop_granian_unix_prefers_rloop(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(serve, "IS_WINDOWS", False) + monkeypatch.setattr(serve, "has_module", lambda name: name in ("rloop", "uvloop")) + assert serve.default_loop("granian") == "rloop" + + +def test_default_loop_granian_unix_falls_back(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(serve, "IS_WINDOWS", False) + monkeypatch.setattr(serve, "has_module", lambda name: name == "uvloop") + assert serve.default_loop("granian") == "uvloop" + + monkeypatch.setattr(serve, "has_module", lambda name: False) + assert serve.default_loop("granian") == "asyncio" + + +def test_default_loop_uvicorn(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(serve, "IS_WINDOWS", False) + monkeypatch.setattr(serve, "has_module", lambda name: name == "uvloop") + assert serve.default_loop("uvicorn") == "uvloop" + + # Windows 上 uvicorn 只有 asyncio(即使装了 winloop 也不会用) + monkeypatch.setattr(serve, "IS_WINDOWS", True) + monkeypatch.setattr(serve, "has_module", lambda name: name == "winloop") + assert serve.default_loop("uvicorn") == "asyncio" + + +def test_default_loop_windows_without_winloop(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(serve, "IS_WINDOWS", True) + monkeypatch.setattr(serve, "has_module", lambda name: False) + assert serve.default_loop("granian") == "asyncio" + + +def test_resolve_loop_auto_uses_platform_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(serve, "IS_WINDOWS", False) + monkeypatch.setattr(serve, "has_module", lambda name: name in ("rloop", "uvloop")) + assert serve.resolve_loop("granian", "auto") == "rloop" + assert serve.resolve_loop("uvicorn", "auto") == "uvloop" + + 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) diff --git a/uv.lock b/uv.lock index de35832..aea742d 100644 --- a/uv.lock +++ b/uv.lock @@ -418,16 +418,12 @@ dependencies = [ { name = "brotli" }, { name = "cryptography" }, { name = "fastapi" }, + { name = "granian", extra = ["reload", "rloop", "uvloop", "winloop"] }, { name = "pillow" }, { name = "qrcode" }, { name = "uvicorn", extra = ["standard"] }, ] -[package.optional-dependencies] -granian = [ - { name = "granian", extra = ["reload", "rloop", "uvloop", "winloop"] }, -] - [package.dev-dependencies] dev = [ { name = "httpx" }, @@ -443,12 +439,11 @@ 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 = "granian", extras = ["reload", "rloop", "uvloop", "winloop"], 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 = [