From 0bb033afe7daeb0e8d82adbff403a8228d01717a Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:53:46 +0800 Subject: [PATCH] feat(plugin): preflight plugin dependencies on free-threaded runtime (#6766) Co-Authored-By: Claude Opus 5.5 --- app/adapters/system/package.py | 57 +++- app/adapters/system/plugin/health.py | 94 ++++++- docs/architecture-overview.md | 2 +- docs/refactor/optimization-checklist.md | 2 +- .../architecture/dependency-baseline.json | 6 +- tests/test_plugin_dependency_preflight.py | 249 ++++++++++++++++++ 6 files changed, 399 insertions(+), 11 deletions(-) create mode 100644 tests/test_plugin_dependency_preflight.py diff --git a/app/adapters/system/package.py b/app/adapters/system/package.py index 4d7864728..5bac6c1c5 100644 --- a/app/adapters/system/package.py +++ b/app/adapters/system/package.py @@ -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 diff --git a/app/adapters/system/plugin/health.py b/app/adapters/system/plugin/health.py index f196b12ef..fc2bc5a19 100644 --- a/app/adapters/system/plugin/health.py +++ b/app/adapters/system/plugin/health.py @@ -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] 所有策略均安装依赖失败,请检查网络连接、包源配置或插件依赖约束" ) diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 3aadb2b57..ebb2cdc1f 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -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) | diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 29a4c497e..c27f1c8e8 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -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 等基线均为零 | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index e613696e0..e09ede90e 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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", diff --git a/tests/test_plugin_dependency_preflight.py b/tests/test_plugin_dependency_preflight.py new file mode 100644 index 000000000..8f74e0721 --- /dev/null +++ b/tests/test_plugin_dependency_preflight.py @@ -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