mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
feat(plugin): preflight plugin dependencies on free-threaded runtime (#6766)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -113,8 +114,15 @@ def _network_variants(request: PackageInstallRequest) -> list[tuple[str, bool, b
|
||||
return variants
|
||||
|
||||
|
||||
def _build_uv_command(uv_bin: Path, request: PackageInstallRequest, use_index: bool) -> list[str]:
|
||||
def _build_uv_command(
|
||||
uv_bin: Path,
|
||||
request: PackageInstallRequest,
|
||||
use_index: bool,
|
||||
dry_run: bool = False,
|
||||
) -> list[str]:
|
||||
command = [str(uv_bin), "pip", "install", "--python", str(request.python_bin)]
|
||||
if dry_run:
|
||||
command.append("--dry-run")
|
||||
if use_index and request.package_index_url:
|
||||
command.extend(["--default-index", request.package_index_url])
|
||||
command.extend(_base_install_args(request))
|
||||
@@ -141,9 +149,16 @@ def _build_uv_sync_command(uv_bin: Path, request: PackageInstallRequest, use_ind
|
||||
return command
|
||||
|
||||
|
||||
def build_package_install_strategies(request: PackageInstallRequest) -> list[PackageInstallStrategy]:
|
||||
def build_package_install_strategies(
|
||||
request: PackageInstallRequest,
|
||||
dry_run: bool = False,
|
||||
) -> list[PackageInstallStrategy]:
|
||||
"""
|
||||
为固定 uv 安装器构造镜像、代理和直连降级策略。
|
||||
|
||||
dry_run 为 True 时构造 resolve-only 预检命令(`uv pip install --dry-run`),
|
||||
与真实安装共用同一组依赖清单、约束文件、本地 wheels 和网络降级矩阵,
|
||||
保证预检结论与真实安装的解析输入一致。
|
||||
"""
|
||||
strategies: list[PackageInstallStrategy] = []
|
||||
variants = _network_variants(request)
|
||||
@@ -152,7 +167,7 @@ def build_package_install_strategies(request: PackageInstallRequest) -> list[Pac
|
||||
return strategies
|
||||
|
||||
for variant_name, use_index, use_proxy in variants:
|
||||
command = _build_uv_command(uv_bin, request, use_index)
|
||||
command = _build_uv_command(uv_bin, request, use_index, dry_run=dry_run)
|
||||
env = build_package_install_env(request, include_moviepilot_proxy=use_proxy)
|
||||
strategies.append(
|
||||
PackageInstallStrategy(
|
||||
@@ -186,3 +201,39 @@ def build_project_sync_strategies(request: PackageInstallRequest) -> list[Packag
|
||||
)
|
||||
)
|
||||
return strategies
|
||||
|
||||
|
||||
# uv 在目标解释器为 free-threaded(cp314t)且依赖没有可用 wheel 时输出的解析提示
|
||||
_FREE_THREADED_ABI_HINTS = (
|
||||
"free-threading compatible ABI tag",
|
||||
"You require free-threaded CPython",
|
||||
)
|
||||
# uv 源码构建失败的首行,例如 "Failed to build `tokenizers==0.23.2`"
|
||||
_UV_BUILD_FAILURE_PATTERN = re.compile(r"Failed to build `([^`]+)`")
|
||||
|
||||
|
||||
def describe_free_threaded_install_failure(message: str) -> str | None:
|
||||
"""
|
||||
把 uv 在 free-threaded 运行时下的安装/解析失败输出归类为可读原因,无法归类时返回 None。
|
||||
|
||||
只识别两类有据可依的失败:
|
||||
- 解析阶段:依赖没有 cp314t wheel 且没有可用 sdist,uv 会给出 free-threading ABI 提示;
|
||||
- 构建阶段:依赖回退到 sdist 源码构建后失败。v3t 镜像运行层不含编译工具链,
|
||||
没有 cp314t wheel 的原生扩展包会落到这里;纯 Python sdist 仍可正常构建,不会命中。
|
||||
调用方负责只在 free-threaded 运行时使用本函数,标准解释器下的构建失败另有原因。
|
||||
"""
|
||||
if any(hint in message for hint in _FREE_THREADED_ABI_HINTS):
|
||||
hints = [
|
||||
line.strip()
|
||||
for line in message.splitlines()
|
||||
if line.strip().startswith("hint:") and "free-thread" in line
|
||||
]
|
||||
reason = "依赖不兼容 free-threaded 运行时(v3t):存在没有 free-threaded(cp314t)wheel 的依赖"
|
||||
return f"{reason}({' '.join(hints)})" if hints else reason
|
||||
matched = _UV_BUILD_FAILURE_PATTERN.search(message)
|
||||
if matched:
|
||||
return (
|
||||
f"依赖 {matched.group(1)} 需要从源码构建但构建失败:v3t 镜像不含编译工具链,"
|
||||
f"该依赖通常缺少 free-threaded(cp314t)wheel"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -25,9 +25,11 @@ from app.adapters.system.package import (
|
||||
PackageInstallRequest,
|
||||
build_package_install_strategies,
|
||||
build_project_sync_strategies,
|
||||
describe_free_threaded_install_failure,
|
||||
find_uv,
|
||||
)
|
||||
from app.adapters.system.plugin.manifest import load_dependency_file
|
||||
from app.foundation.environment import is_free_threaded_runtime
|
||||
from app.runtime.dependencies.profile import (
|
||||
iter_runtime_profile_requirement_strings,
|
||||
iter_runtime_requirement_strings,
|
||||
@@ -51,6 +53,8 @@ class PluginRuntimeHealth:
|
||||
})
|
||||
_runtime_import_probe = "app.doctor.dependencies"
|
||||
PLUGIN_DEPENDENCY_INSTALL_TIMEOUT = 300
|
||||
# resolve-only 预检只解析和取元数据,不下载完整依赖,超时远小于真实安装
|
||||
PLUGIN_DEPENDENCY_PREFLIGHT_TIMEOUT = 120
|
||||
|
||||
@classmethod
|
||||
def __get_installed_packages(cls) -> Dict[str, Version]:
|
||||
@@ -617,6 +621,76 @@ class PluginRuntimeHealth:
|
||||
logger.error(f"[UV] 使用策略:{strategy.strategy_name} 恢复{repair_desc}失败:{message}")
|
||||
return False, last_error or f"恢复{repair_desc}失败"
|
||||
|
||||
@staticmethod
|
||||
def __free_threaded_preflight_verdict(failures: List[str]) -> Optional[str]:
|
||||
"""
|
||||
根据全部 resolve-only 预检策略的失败输出判断是否拦截真实安装。
|
||||
|
||||
只有能归类为 free-threaded 不兼容的失败才拦截;网络、镜像或其他未知失败
|
||||
放行给真实安装,沿用现有失败处理,避免预检本身引入误拦截。
|
||||
"""
|
||||
for failure in failures:
|
||||
reason = describe_free_threaded_install_failure(failure)
|
||||
if reason:
|
||||
return reason
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __with_free_threaded_reason(message: str) -> str:
|
||||
"""free-threaded 运行时下为 uv 安装失败补充可读原因,原始输出保留在后面便于排查。"""
|
||||
if not is_free_threaded_runtime():
|
||||
return message
|
||||
reason = describe_free_threaded_install_failure(message)
|
||||
return f"{reason};{message}" if reason else message
|
||||
|
||||
@classmethod
|
||||
def __free_threaded_preflight(cls, request: PackageInstallRequest) -> Optional[str]:
|
||||
"""
|
||||
free-threaded 运行时下在真实安装前做一次 resolve-only 预检,返回拦截原因或 None。
|
||||
|
||||
预检复用真实安装的同一组网络降级策略,任一策略解析成功即放行;
|
||||
它不修改 site-packages,因此不持有安装锁,也不进入健康快照与回滚链路。
|
||||
已知边界:解析成功不代表能装上,需要源码编译的依赖仍只能在真实安装阶段暴露,
|
||||
由 __with_free_threaded_reason 给出可读原因。
|
||||
"""
|
||||
if not is_free_threaded_runtime():
|
||||
return None
|
||||
failures: List[str] = []
|
||||
for strategy in build_package_install_strategies(request, dry_run=True):
|
||||
success, message = SystemUtils.execute_with_subprocess(
|
||||
strategy.command,
|
||||
env=strategy.env,
|
||||
safe_command=strategy.safe_log_command,
|
||||
timeout=cls.PLUGIN_DEPENDENCY_PREFLIGHT_TIMEOUT,
|
||||
)
|
||||
if success:
|
||||
return None
|
||||
logger.debug(f"[UV] 策略:{strategy.strategy_name} 依赖预检失败:{message}")
|
||||
failures.append(message)
|
||||
return cls.__free_threaded_preflight_verdict(failures)
|
||||
|
||||
@classmethod
|
||||
async def __async_free_threaded_preflight(
|
||||
cls,
|
||||
request: PackageInstallRequest,
|
||||
) -> Optional[str]:
|
||||
"""__free_threaded_preflight 的异步版本,取消时由子进程工具回收 uv 进程组。"""
|
||||
if not is_free_threaded_runtime():
|
||||
return None
|
||||
failures: List[str] = []
|
||||
for strategy in build_package_install_strategies(request, dry_run=True):
|
||||
success, message = await SystemUtils.execute_with_subprocess_async(
|
||||
strategy.command,
|
||||
env=strategy.env,
|
||||
safe_command=strategy.safe_log_command,
|
||||
timeout=cls.PLUGIN_DEPENDENCY_PREFLIGHT_TIMEOUT,
|
||||
)
|
||||
if success:
|
||||
return None
|
||||
logger.debug(f"[UV] 策略:{strategy.strategy_name} 依赖预检失败:{message}")
|
||||
failures.append(message)
|
||||
return cls.__free_threaded_preflight_verdict(failures)
|
||||
|
||||
@classmethod
|
||||
def install_packages_with_fallback(cls,
|
||||
dependency_files: Path | Sequence[Path],
|
||||
@@ -689,6 +763,13 @@ class PluginRuntimeHealth:
|
||||
)
|
||||
strategies = build_package_install_strategies(request)
|
||||
|
||||
preflight_error = cls.__free_threaded_preflight(request)
|
||||
if preflight_error:
|
||||
logger.error(f"[UV] free-threaded 运行时依赖预检未通过:{preflight_error}")
|
||||
if constraints_file:
|
||||
constraints_file.unlink(missing_ok=True)
|
||||
return False, preflight_error
|
||||
|
||||
try:
|
||||
# 安装器会修改当前解释器的 site-packages,安装与缓存刷新必须串行。
|
||||
with cls._package_install_lock:
|
||||
@@ -766,7 +847,7 @@ class PluginRuntimeHealth:
|
||||
)
|
||||
logger.error(f"[UV] 策略:{strategy.strategy_name} 安装依赖失败,错误信息:{message}")
|
||||
if not repair_ok or repair_message:
|
||||
return False, (
|
||||
return False, cls.__with_free_threaded_reason(
|
||||
f"策略 {strategy.strategy_name} 安装依赖失败:{message};"
|
||||
f"{repair_message}"
|
||||
)
|
||||
@@ -775,7 +856,7 @@ class PluginRuntimeHealth:
|
||||
constraints_file.unlink(missing_ok=True)
|
||||
|
||||
if last_error:
|
||||
return False, f"[UV] 所有策略均安装依赖失败:{last_error}"
|
||||
return False, cls.__with_free_threaded_reason(f"[UV] 所有策略均安装依赖失败:{last_error}")
|
||||
return False, "[UV] 所有策略均安装依赖失败,请检查网络连接、包源配置或插件依赖约束"
|
||||
|
||||
@classmethod
|
||||
@@ -969,6 +1050,11 @@ class PluginRuntimeHealth:
|
||||
strategies = build_package_install_strategies(request)
|
||||
acquired = False
|
||||
try:
|
||||
# 预检不写 site-packages,放在抢锁之前,避免排队等待其他插件的真实安装
|
||||
preflight_error = await cls.__async_free_threaded_preflight(request)
|
||||
if preflight_error:
|
||||
logger.error(f"[UV] free-threaded 运行时依赖预检未通过:{preflight_error}")
|
||||
return False, preflight_error
|
||||
while not cls._package_install_lock.acquire(blocking=False):
|
||||
await asyncio.sleep(0.01)
|
||||
acquired = True
|
||||
@@ -1041,12 +1127,12 @@ class PluginRuntimeHealth:
|
||||
f"[UV] 策略:{strategy.strategy_name} 安装依赖失败,错误信息:{message}"
|
||||
)
|
||||
if not repair_ok or repair_message:
|
||||
return False, (
|
||||
return False, cls.__with_free_threaded_reason(
|
||||
f"策略 {strategy.strategy_name} 安装依赖失败:{message};"
|
||||
f"{repair_message}"
|
||||
)
|
||||
return False, (
|
||||
f"[UV] 所有策略均安装依赖失败:{last_error}"
|
||||
cls.__with_free_threaded_reason(f"[UV] 所有策略均安装依赖失败:{last_error}")
|
||||
if last_error
|
||||
else "[UV] 所有策略均安装依赖失败,请检查网络连接、包源配置或插件依赖约束"
|
||||
)
|
||||
|
||||
@@ -761,7 +761,7 @@ flowchart LR
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 1044 |
|
||||
| 内部导入边 | 8,872 |
|
||||
| 内部导入边 | 8,874 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 53(债务已清零,53 条精确 containment) |
|
||||
|
||||
@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 1044 / 8,872 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档,分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 1044 / 8,874 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档,分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
|
||||
@@ -1084,8 +1084,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 8872,
|
||||
"edge_sha256": "6a3f2d8501b1c8dbfc0b4c11f3ba49495ede25a209ffd2a9fee970749d169389",
|
||||
"edge_count": 8874,
|
||||
"edge_sha256": "d7424a9ce34ac1c30dab0449e660632db539aaa66c1023ac68fe04f979b4efdd",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -1239,6 +1239,8 @@
|
||||
"app.adapters.system.plugin.health -> app.adapters.system.package",
|
||||
"app.adapters.system.plugin.health -> app.adapters.system.plugin",
|
||||
"app.adapters.system.plugin.health -> app.adapters.system.plugin.manifest",
|
||||
"app.adapters.system.plugin.health -> app.foundation",
|
||||
"app.adapters.system.plugin.health -> app.foundation.environment",
|
||||
"app.adapters.system.plugin.health -> app.runtime",
|
||||
"app.adapters.system.plugin.health -> app.runtime.dependencies",
|
||||
"app.adapters.system.plugin.health -> app.runtime.dependencies.profile",
|
||||
|
||||
249
tests/test_plugin_dependency_preflight.py
Normal file
249
tests/test_plugin_dependency_preflight.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user