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:
InfinityPacer
2026-09-23 12:53:46 +08:00
committed by GitHub
parent 5d40bc3026
commit 0bb033afe7
6 changed files with 399 additions and 11 deletions

View File

@@ -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

View File

@@ -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] 所有策略均安装依赖失败,请检查网络连接、包源配置或插件依赖约束"
)

View File

@@ -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) |

View File

@@ -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 等基线均为零 |

View File

@@ -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",

View 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