mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 03:48:03 +08:00
250 lines
10 KiB
Python
250 lines
10 KiB
Python
"""free-threaded(v3t)运行时插件依赖 resolve-only 预检与失败原因归类测试。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from app.adapters.system import package as package_module
|
||
from app.adapters.system.package import (
|
||
PackageInstallRequest,
|
||
build_package_install_strategies,
|
||
describe_free_threaded_install_failure,
|
||
)
|
||
from app.adapters.system.plugin import health as health_module
|
||
from app.adapters.system.plugin.health import PluginRuntimeHealth
|
||
|
||
# 摘自 uv 0.12 在 cp314t 下解析 faster-whisper(onnxruntime 无 cp314t wheel 且无 sdist)的真实输出
|
||
UV_FREE_THREADED_RESOLVE_FAILURE = """ × No solution found when resolving dependencies:
|
||
╰─▶ Because onnxruntime>=1.14.0,<=1.23.2 has no wheels with a free-threading
|
||
compatible ABI tag and only the following versions of onnxruntime are
|
||
available:
|
||
we can conclude that your requirements are unsatisfiable.
|
||
|
||
hint: You require free-threaded CPython 3.14 (`cp314t`), but we only found wheels for `onnxruntime` (v1.23.2) with the following Python ABI tags: `cp310`, `cp311`, `cp312`, `cp313`, `cp313t`
|
||
hint: Wheels are available for `onnxruntime` (v1.30.0) on the following platforms: `manylinux_2_28_aarch64`
|
||
"""
|
||
|
||
# 摘自 uv 0.12 在无编译工具链的 python:3.14-slim 容器内为 cp314t 构建 tokenizers sdist 的真实输出
|
||
UV_SOURCE_BUILD_FAILURE = """error: Failed to build `tokenizers==0.23.2`
|
||
cause: The build backend returned an error
|
||
cause: Call to `maturin.build_wheel` failed (exit status: 1)
|
||
|
||
hint: `tokenizers` (v0.23.2) was included because `faster-whisper` (v1.2.1) depends on `tokenizers`
|
||
"""
|
||
|
||
HEALTHY = {"uv check": (True, "ok"), "核心依赖导入检查": (True, "ok")}
|
||
|
||
|
||
def test_describe_resolve_failure_keeps_free_threaded_hint():
|
||
"""解析阶段的 ABI 不兼容归类为 v3t 不兼容,并保留 uv 的 cp314t 提示行。"""
|
||
reason = describe_free_threaded_install_failure(UV_FREE_THREADED_RESOLVE_FAILURE)
|
||
|
||
assert reason is not None
|
||
assert reason.startswith("依赖不兼容 free-threaded 运行时(v3t)")
|
||
assert "You require free-threaded CPython 3.14" in reason
|
||
assert "Wheels are available for" not in reason
|
||
|
||
|
||
def test_describe_source_build_failure_names_package():
|
||
"""源码构建失败归类为缺编译工具链,并指出具体依赖。"""
|
||
reason = describe_free_threaded_install_failure(UV_SOURCE_BUILD_FAILURE)
|
||
|
||
assert reason is not None
|
||
assert "tokenizers==0.23.2" in reason
|
||
assert "编译工具链" in reason
|
||
|
||
|
||
def test_describe_unknown_failure_returns_none():
|
||
"""网络等无法归类的失败不附加 v3t 原因。"""
|
||
assert describe_free_threaded_install_failure("error: Request failed after 3 retries") is None
|
||
|
||
|
||
def test_dry_run_strategies_share_install_inputs(tmp_path):
|
||
"""预检命令与真实安装共用清单、约束、wheels 与网络矩阵,只多一个 --dry-run。"""
|
||
uv_bin = tmp_path / "uv"
|
||
uv_bin.write_text("", encoding="utf-8")
|
||
request = PackageInstallRequest(
|
||
dependency_files=(tmp_path / "pyproject.toml",),
|
||
python_bin=tmp_path / "python",
|
||
find_links_dirs=[tmp_path / "wheels"],
|
||
constraints_file=tmp_path / "constraints.txt",
|
||
package_index_url="https://mirror.example/simple",
|
||
proxy_url="http://proxy.example:7890",
|
||
)
|
||
original_find_uv = package_module.find_uv
|
||
package_module.find_uv = lambda _python_bin: uv_bin
|
||
try:
|
||
install = build_package_install_strategies(request)
|
||
preflight = build_package_install_strategies(request, dry_run=True)
|
||
finally:
|
||
package_module.find_uv = original_find_uv
|
||
|
||
assert [item.strategy_name for item in preflight] == [item.strategy_name for item in install]
|
||
for install_strategy, preflight_strategy in zip(install, preflight):
|
||
assert "--dry-run" not in install_strategy.command
|
||
expected = list(install_strategy.command)
|
||
expected.insert(expected.index("--python") + 2, "--dry-run")
|
||
assert preflight_strategy.command == expected
|
||
assert preflight_strategy.env == install_strategy.env
|
||
|
||
|
||
@pytest.fixture
|
||
def install_env(tmp_path, monkeypatch):
|
||
"""构造可执行安装链路的最小环境:镜像+代理产生四个网络策略,健康检查恒为健康。"""
|
||
manifest = tmp_path / "requirements.txt"
|
||
manifest.write_text("demo\n", encoding="utf-8")
|
||
uv_bin = tmp_path / "uv"
|
||
uv_bin.write_text("", encoding="utf-8")
|
||
settings = {
|
||
"ROOT_PATH": Path(__file__).resolve().parents[1],
|
||
"TEMP_PATH": tmp_path / "temp",
|
||
"CONFIG_PATH": tmp_path / "config",
|
||
"PACKAGE_CACHE_PATH": tmp_path / "cache",
|
||
"PROXY_HOST": "http://proxy.example:7890",
|
||
"PIP_PROXY": "https://mirror.example/simple",
|
||
}
|
||
monkeypatch.setattr(package_module, "find_uv", lambda _python_bin: uv_bin)
|
||
monkeypatch.setattr(health_module, "get_runtime_setting", settings.get)
|
||
monkeypatch.setattr(
|
||
PluginRuntimeHealth,
|
||
"_PluginRuntimeHealth__get_protected_runtime_packages",
|
||
classmethod(lambda cls, _installed: {}),
|
||
)
|
||
monkeypatch.setattr(
|
||
PluginRuntimeHealth,
|
||
"_PluginRuntimeHealth__run_runtime_healthcheck",
|
||
classmethod(lambda cls: dict(HEALTHY)),
|
||
)
|
||
|
||
async def healthy_async(cls):
|
||
return dict(HEALTHY)
|
||
|
||
monkeypatch.setattr(
|
||
PluginRuntimeHealth,
|
||
"_PluginRuntimeHealth__async_run_runtime_healthcheck",
|
||
classmethod(healthy_async),
|
||
)
|
||
return manifest
|
||
|
||
|
||
def _patch_subprocess(monkeypatch, responder):
|
||
"""同一应答函数同时替换同步与异步子进程执行,记录全部命令。"""
|
||
calls: list[list[str]] = []
|
||
|
||
def execute(command, env=None, safe_command=None, timeout=None):
|
||
calls.append(list(command))
|
||
return responder(command)
|
||
|
||
async def execute_async(command, env=None, safe_command=None, timeout=None):
|
||
return execute(command, env=env, safe_command=safe_command, timeout=timeout)
|
||
|
||
monkeypatch.setattr(health_module.SystemUtils, "execute_with_subprocess", staticmethod(execute))
|
||
monkeypatch.setattr(
|
||
health_module.SystemUtils,
|
||
"execute_with_subprocess_async",
|
||
staticmethod(execute_async),
|
||
)
|
||
return calls
|
||
|
||
|
||
def _install(manifest: Path, use_async: bool):
|
||
if use_async:
|
||
return asyncio.run(PluginRuntimeHealth().async_install_packages_with_fallback(manifest))
|
||
return PluginRuntimeHealth.install_packages_with_fallback(manifest)
|
||
|
||
|
||
@pytest.mark.parametrize("use_async", [False, True])
|
||
def test_preflight_blocks_when_every_strategy_reports_free_threaded_incompatibility(
|
||
install_env, monkeypatch, use_async
|
||
):
|
||
"""全部预检策略都给出 ABI 不兼容时直接拒绝,不进入真实安装。"""
|
||
monkeypatch.setattr(health_module, "is_free_threaded_runtime", lambda: True)
|
||
calls = _patch_subprocess(monkeypatch, lambda _command: (False, UV_FREE_THREADED_RESOLVE_FAILURE))
|
||
|
||
success, message = _install(install_env, use_async)
|
||
|
||
assert not success
|
||
assert message.startswith("依赖不兼容 free-threaded 运行时(v3t)")
|
||
assert len(calls) == 4
|
||
assert all("--dry-run" in command for command in calls)
|
||
|
||
|
||
@pytest.mark.parametrize("use_async", [False, True])
|
||
def test_preflight_passes_when_any_strategy_resolves(install_env, monkeypatch, use_async):
|
||
"""镜像策略解析失败但后续代理策略能解析时放行,避免把单一网络路径的失败误判为不兼容。"""
|
||
monkeypatch.setattr(health_module, "is_free_threaded_runtime", lambda: True)
|
||
|
||
def responder(command):
|
||
if "--dry-run" in command and "--default-index" in command:
|
||
return False, UV_FREE_THREADED_RESOLVE_FAILURE
|
||
return True, "ok"
|
||
|
||
calls = _patch_subprocess(monkeypatch, responder)
|
||
|
||
success, message = _install(install_env, use_async)
|
||
|
||
assert success
|
||
assert message == "ok"
|
||
preflight_calls = [command for command in calls if "--dry-run" in command]
|
||
assert len(preflight_calls) == 3
|
||
assert "--default-index" not in preflight_calls[-1]
|
||
assert "--dry-run" not in calls[-1]
|
||
|
||
|
||
@pytest.mark.parametrize("use_async", [False, True])
|
||
def test_unclassified_preflight_failure_falls_through_to_install(install_env, monkeypatch, use_async):
|
||
"""网络等无法归类的预检失败不拦截,交给真实安装按原有链路处理。"""
|
||
monkeypatch.setattr(health_module, "is_free_threaded_runtime", lambda: True)
|
||
|
||
def responder(command):
|
||
if "--dry-run" in command:
|
||
return False, "error: Request failed after 3 retries"
|
||
return True, "ok"
|
||
|
||
calls = _patch_subprocess(monkeypatch, responder)
|
||
|
||
success, _message = _install(install_env, use_async)
|
||
|
||
assert success
|
||
assert sum("--dry-run" in command for command in calls) == 4
|
||
|
||
|
||
@pytest.mark.parametrize("use_async", [False, True])
|
||
def test_standard_runtime_skips_preflight(install_env, monkeypatch, use_async):
|
||
"""标准解释器不付预检成本,也不给失败附加 v3t 原因。"""
|
||
monkeypatch.setattr(health_module, "is_free_threaded_runtime", lambda: False)
|
||
calls = _patch_subprocess(
|
||
monkeypatch,
|
||
lambda command: (False, UV_SOURCE_BUILD_FAILURE) if "install" in command else (True, "ok"),
|
||
)
|
||
|
||
success, message = _install(install_env, use_async)
|
||
|
||
assert not success
|
||
assert not any("--dry-run" in command for command in calls)
|
||
assert "编译工具链" not in message
|
||
|
||
|
||
@pytest.mark.parametrize("use_async", [False, True])
|
||
def test_install_source_build_failure_gets_readable_reason(install_env, monkeypatch, use_async):
|
||
"""解析通过但源码构建失败时,真实安装的错误前置可读原因并保留原始输出。"""
|
||
monkeypatch.setattr(health_module, "is_free_threaded_runtime", lambda: True)
|
||
|
||
def responder(command):
|
||
if "--dry-run" in command:
|
||
return True, "Would install 26 packages"
|
||
if command[1:3] == ["pip", "install"]:
|
||
return False, UV_SOURCE_BUILD_FAILURE
|
||
return True, "ok"
|
||
|
||
_patch_subprocess(monkeypatch, responder)
|
||
|
||
success, message = _install(install_env, use_async)
|
||
|
||
assert not success
|
||
assert message.startswith("依赖 tokenizers==0.23.2 需要从源码构建但构建失败")
|
||
assert "Failed to build `tokenizers==0.23.2`" in message
|