feat(server): 平台默认组合——Windows granian+winloop,Unix granian+rloop

- granian[reload,rloop,uvloop,winloop] 改为默认依赖,uv sync 即具备完整启动栈
- auto 在启动器内解析为具体循环:granian Windows→winloop / Unix→rloop(缺省回退 uvloop/asyncio);
  uvicorn Unix→uvloop / Windows→asyncio
- 启动日志打印实际 server+loop;测试覆盖平台默认与回退;README 默认矩阵更新
This commit is contained in:
snowy-yang committed 2026-10-23 22:23:30 +08:00
1 parent 0cae770ace
commit bcfc1022eb
5 files changed
+115 -52

No files matched your search

+21 -13
View File
@@ -53,29 +53,37 @@ Node/TypeScript 版(v1.x,`master` 分支):`pnpm install && pnpm start`
### 服务器与事件循环(uvicorn / granian) ### 服务器与事件循环(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`,参数与环境变量等价 事件循环可用 `auto / asyncio / rloop / uvloop / winloop`,参数与环境变量等价
(`SERVER` / `LOOP` / `HOST` / `PORT` / `WORKERS` / `RELOAD`)。 (`SERVER` / `LOOP` / `HOST` / `PORT` / `WORKERS` / `RELOAD`)。`auto` 会在启动器内解析成上表中的具体循环,
启动日志会打印实际使用的组合;显式请求当前平台不可用的循环会直接报错并列出可用项(不会静默降级)。
```bash ```bash
uv sync --extra granian # Granian + rloop/uvloop/winloop + watchfiles uv sync # 默认依赖已含 granian[rloop/uvloop/winloop/reload]
uv run python scripts/serve.py # auto:granian(若已安装),loop=auto uv run python scripts/serve.py # Windows → granian+winloop;Unix → granian+rloop
uv run python scripts/serve.py --server granian --loop rloop # 仅 Unix(Rust 事件循环) uv run python scripts/serve.py --server uvicorn # Unix → uvicorn+uvloop;Windows → uvicorn+asyncio
uv run python scripts/serve.py --server granian --loop uvloop # 仅 Unix uv run python scripts/serve.py --loop rloop # 仅 Unix(Rust 事件循环)
uv run python scripts/serve.py --server granian --loop winloop # 仅 Windows uv run python scripts/serve.py --loop uvloop # 仅 Unix
uv run python scripts/serve.py --server uvicorn --loop uvloop # 仅 Unix uv run python scripts/serve.py --loop winloop # 仅 Windows
uv run python scripts/serve.py --server granian --reload # 热重载(强制单 worker) uv run python scripts/serve.py --server granian --reload # 热重载(强制单 worker)
``` ```
| 事件循环 | 平台 | 说明 | | 事件循环 | 平台 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `asyncio` | 全平台 | 标准库,始终可用 | | `asyncio` | 全平台 | 标准库,始终可用 |
| `rloop` | Unix | granian 的 Rust 事件循环(`rloop` 包,Windows 无 wheel) | | `rloop` | Unix | Rust 事件循环(`rloop` 包,Windows 无 wheel) |
| `uvloop` | Unix | libuv 事件循环 | | `uvloop` | Unix | libuv 事件循环 |
| `winloop` | Windows | uvloop 的 Windows 实现 | | `winloop` | Windows | uvloop 的 Windows 实现 |
| `auto` | 全平台 | granian:rloop → uvloop → winloop → asyncio;uvicorn:uvloop → asyncio |
显式请求当前平台不可用的循环会直接报错并列出可用项(不会静默降级)。 granian 也支持显式指定与默认不同的循环(如 Unix 上 `--loop uvloop`、Windows 上 `--loop asyncio`)。
## HTTP 接口 ## HTTP 接口
@@ -204,7 +212,7 @@ scripts/
serve.py 启动器(uvicorn / granian,事件循环 rloop/uvloop/winloop 选择与校验) serve.py 启动器(uvicorn / granian,事件循环 rloop/uvloop/winloop 选择与校验)
docs/ docs/
PORTING.md TS → Python 平台移植契约(目录/子模块协议/签名差异/验收要求) PORTING.md TS → Python 平台移植契约(目录/子模块协议/签名差异/验收要求)
pyproject.toml uv 项目定义(依赖、granian 可选 extra、ruff、pyright、pytest 配置) pyproject.toml uv 项目定义(默认 granian/uvicorn 依赖、ruff、pyright、pytest 配置)
tests/ tests/
test_serve.py 启动器服务器/循环选择逻辑测试(TS 版 smoke 测试尚未移植) test_serve.py 启动器服务器/循环选择逻辑测试(TS 版 smoke 测试尚未移植)
``` ```
+3 -12
View File
@@ -5,6 +5,9 @@ description = "多平台音乐聚合服务器"
requires-python = ">=3.12,<3.13" requires-python = ">=3.12,<3.13"
dependencies = [ dependencies = [
"fastapi>=0.115", "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", "uvicorn[standard]>=0.30",
"aiohttp>=3.10", "aiohttp>=3.10",
"brotli>=1.1", "brotli>=1.1",
@@ -13,18 +16,6 @@ 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",
+41 -18
View File
@@ -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 # Windows: granian+winloop;Unix: granian+rloop
uv run python scripts/serve.py --server granian # Rust HTTP 服务器 uv run python scripts/serve.py --server uvicorn # Unix 默认 uvloop,Windows 默认 asyncio
uv run python scripts/serve.py --server granian --loop rloop # 仅 Unix(granian[rloop]) uv run python scripts/serve.py --loop rloop # 显式指定(平台/安装不支持时直接报错)
uv run python scripts/serve.py --server granian --loop uvloop # 仅 Unix(granian[uvloop]) uv run python scripts/serve.py --loop uvloop
uv run python scripts/serve.py --server granian --loop winloop # 仅 Windows(granian[winloop]) uv run python scripts/serve.py --loop winloop
uv run python scripts/serve.py --server uvicorn --loop uvloop # uvicorn 在 Unix 上等价
事件循环可用性(与 granian 的 extras / 平台 marker 一致): 事件循环可用性(与依赖的平台 marker 一致):
asyncio 始终可用 asyncio 始终可用
rloop rloop 包,仅 Unix(granian 内置的 Rust 循环) rloop rloop 包,仅 Unix(Rust 事件循环)
uvloop uvloop 包,仅 Unix uvloop uvloop 包,仅 Unix
winloop winloop 包,仅 Windows(uvloop 的 Windows 实现) 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。 环境变量(参数优先):SERVER / LOOP / HOST / PORT / WORKERS / RELOAD。
PORT 默认读取 .env 后的 16688;HOST 默认 0.0.0.0(局域网播放器可直连)。 PORT 默认读取 .env 后的 16688;HOST 默认 0.0.0.0(局域网播放器可直连)。
@@ -42,6 +43,7 @@ LOOPS = ("auto", "asyncio", "rloop", "uvloop", "winloop")
#: 每个循环对应的可导入包(asyncio 无依赖) #: 每个循环对应的可导入包(asyncio 无依赖)
LOOP_PACKAGES = {"rloop": "rloop", "uvloop": "uvloop", "winloop": "winloop"} LOOP_PACKAGES = {"rloop": "rloop", "uvloop": "uvloop", "winloop": "winloop"}
IS_WINDOWS = sys.platform.startswith("win")
def has_module(name: str) -> bool: def has_module(name: str) -> bool:
@@ -53,7 +55,7 @@ def has_module(name: str) -> bool:
def pick_server(requested: str) -> str: def pick_server(requested: str) -> str:
"""auto:安装了 granian 就用 granian,否则 uvicorn。""" """auto:默认 granian(uv sync 已包含);未安装时回退 uvicorn。"""
if requested not in SERVERS: if requested not in SERVERS:
raise SystemExit(f"不支持的 server: {requested}(可选:{', '.join(SERVERS)})") raise SystemExit(f"不支持的 server: {requested}(可选:{', '.join(SERVERS)})")
if requested != "auto": if requested != "auto":
@@ -71,18 +73,36 @@ def available_loops(server: str) -> list[str]:
return loops 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: def resolve_loop(server: str, loop: str) -> str:
"""校验事件循环可用性;显式请求不可用时给出可操作的原因(不做静默降级)。""" """auto → 平台默认循环;显式请求不可用时给出可操作的原因(不做静默降级)。"""
if loop not in LOOPS: if loop not in LOOPS:
raise SystemExit(f"不支持的 loop: {loop}(可选:{', '.join(LOOPS)})") raise SystemExit(f"不支持的 loop: {loop}(可选:{', '.join(LOOPS)})")
if loop == "auto": if loop == "auto":
return loop return default_loop(server)
if server == "uvicorn": if server == "uvicorn":
if loop == "asyncio": if loop == "asyncio":
return loop return loop
if loop == "uvloop": if loop == "uvloop":
if not has_module("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 return loop
raise SystemExit(f"uvicorn 不支持 --loop {loop};请改用 --server granian(granian 支持 rloop/uvloop/winloop)") raise SystemExit(f"uvicorn 不支持 --loop {loop};请改用 --server granian(granian 支持 rloop/uvloop/winloop)")
@@ -115,7 +135,7 @@ def build_command(
if reload: if reload:
command.append("--reload") command.append("--reload")
if reload and not has_module("watchfiles"): 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) command.append(APP_TARGET)
else: else:
command += [APP_TARGET, "--host", host, "--port", str(port)] 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)") parser.add_argument("--reload", action="store_true", help="开发热重载(强制单 worker)")
args = parser.parse_args(argv) 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): 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"))) loop = resolve_loop(server, str(_arg_or_env(args.loop, "LOOP", "auto")))
host = str(_arg_or_env(args.host, "HOST", "0.0.0.0")) host = str(_arg_or_env(args.host, "HOST", "0.0.0.0"))
+48 -2
View File
@@ -45,11 +45,57 @@ def test_uvicorn_rejects_granian_only_loops() -> None:
serve.resolve_loop("uvicorn", "rloop") serve.resolve_loop("uvicorn", "rloop")
def test_uvicorn_auto_and_asyncio_allowed() -> None: def test_uvicorn_auto_and_asyncio_allowed(monkeypatch: pytest.MonkeyPatch) -> None:
assert serve.resolve_loop("uvicorn", "auto") == "auto" monkeypatch.setattr(serve, "IS_WINDOWS", True)
assert serve.resolve_loop("uvicorn", "auto") == "asyncio"
assert serve.resolve_loop("uvicorn", "asyncio") == "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: def test_build_command_granian(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(serve, "has_module", lambda name: True) 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) command = serve.build_command("granian", host="0.0.0.0", port=16688, workers=2, loop="winloop", reload=True)
Generated
+2 -7
View File
@@ -418,16 +418,12 @@ dependencies = [
{ name = "brotli" }, { name = "brotli" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "granian", extra = ["reload", "rloop", "uvloop", "winloop"] },
{ name = "pillow" }, { name = "pillow" },
{ name = "qrcode" }, { name = "qrcode" },
{ 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" },
@@ -443,12 +439,11 @@ 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 = "granian", extras = ["reload", "rloop", "uvloop", "winloop"], 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 = [