diff --git a/app/adapters/external/market.py b/app/adapters/external/market.py index 5498e3b8d..0ddc57aa8 100644 --- a/app/adapters/external/market.py +++ b/app/adapters/external/market.py @@ -24,8 +24,9 @@ import aioshutil import httpx2 from anyio import Path as AsyncPath from packaging.markers import default_environment -from packaging.requirements import Requirement +from packaging.requirements import InvalidRequirement, Requirement from packaging.specifiers import SpecifierSet, InvalidSpecifier +from packaging.utils import canonicalize_name from packaging.version import Version, InvalidVersion from importlib.metadata import distributions from requests import Response @@ -34,6 +35,7 @@ from app.runtime.cache import cached, is_fresh from app.runtime.dependencies import ( iter_runtime_profile_requirement_strings, iter_runtime_requirement_strings, + runtime_excluded_dependency_pairs, ) from app.runtime.settings import RuntimeSettingsCompat from app.adapters.system.package import ( @@ -1733,15 +1735,36 @@ class PluginHelper(metaclass=WeakSingleton): @staticmethod def __runtime_health_error_lines(check_name: str, message: str) -> set[str]: - """提取稳定诊断项,忽略执行器附加的命令摘要。""" + """提取未被项目依赖策略排除的稳定诊断项。""" lines = {line.strip() for line in message.splitlines() if line.strip()} if check_name != "uv check": return lines - package_errors = set(re.findall( - r"The package `[^`]+` requires `[^`]+`, but [^\r\n;]+", + + matches = list(re.finditer( + r"The package `(?P[^`]+)` requires `(?P[^`]+)`, " + r"but [^\r\n;]+", message, )) - return package_errors or lines + if not matches: + return lines + + excluded_pairs = runtime_excluded_dependency_pairs( + Path(settings.ROOT_PATH) / "pyproject.toml" + ) + package_errors = set() + for match in matches: + try: + dependency_name = Requirement(match.group("requirement")).name + except InvalidRequirement: + package_errors.add(match.group(0)) + continue + pair = ( + canonicalize_name(match.group("package")), + canonicalize_name(dependency_name), + ) + if pair not in excluded_pairs: + package_errors.add(match.group(0)) + return package_errors @staticmethod def __runtime_health_regression_message( @@ -1755,7 +1778,14 @@ class PluginHelper(metaclass=WeakSingleton): for check_name, (success, message) in current_health.items(): baseline_success, baseline_message = baseline_health.get(check_name, (True, "")) if baseline_success and not success: - regressions.append(f"{check_name}失败:{message}") + current_lines = PluginHelper.__runtime_health_error_lines( + check_name, + message, + ) + if current_lines: + regressions.append( + f"{check_name}失败:{' | '.join(sorted(current_lines))}" + ) elif not baseline_success and not success: baseline_lines = PluginHelper.__runtime_health_error_lines( check_name, diff --git a/app/runtime/dependencies.py b/app/runtime/dependencies.py index 726333887..44180ed69 100644 --- a/app/runtime/dependencies.py +++ b/app/runtime/dependencies.py @@ -6,6 +6,9 @@ import tomllib from collections.abc import Iterable from pathlib import Path +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name + from app.foundation.environment import is_free_threaded_runtime @@ -52,5 +55,33 @@ def iter_runtime_profile_requirement_strings(project_file: Path) -> Iterable[str yield requirement +def runtime_excluded_dependency_pairs(project_file: Path) -> set[tuple[str, str]]: + """返回项目明确排除的“依赖包 -> 传递依赖”规范化名称对。""" + with project_file.open("rb") as file: + document = tomllib.load(file) + + exclusions = ( + ((document.get("tool") or {}).get("uv") or {}).get("exclude-dependencies") + or () + ) + pairs: set[tuple[str, str]] = set() + for exclusion in exclusions: + if not isinstance(exclusion, dict): + continue + package = exclusion.get("package") or {} + package_name = package.get("name") if isinstance(package, dict) else None + if not isinstance(package_name, str) or not package_name.strip(): + continue + for dependency in exclusion.get("dependencies") or (): + if not isinstance(dependency, str): + continue + try: + dependency_name = Requirement(dependency).name + except InvalidRequirement: + continue + pairs.add((canonicalize_name(package_name), canonicalize_name(dependency_name))) + return pairs + + if __name__ == "__main__": print(runtime_dependency_group()) diff --git a/docs/v3t-runtime-governance.md b/docs/v3t-runtime-governance.md index db3d92a67..aba765905 100644 --- a/docs/v3t-runtime-governance.md +++ b/docs/v3t-runtime-governance.md @@ -81,8 +81,9 @@ profile,依赖名称、版本和 source 语义全部由 `pyproject.toml` 与 ` 3. 恢复完成后重新执行依赖诊断与核心能力探针; 4. 宿主即使恢复成功,本次插件安装仍返回失败,不能把被回滚的安装报告为成功。 -依赖诊断使用 `uv pip check`,并按安装前后的稳定错误集合识别新增问题;这样既不会把 -`oss2` 对旧 `crcmod` 的陈旧元数据误归因于本次安装,也不会让既有告警遮蔽其他新增错误。核心能力 +依赖诊断使用 `uv pip check`,项目在 `tool.uv.exclude-dependencies` 中明确排除的传递依赖不进入健康 +异常集合,其余诊断按安装前后的稳定错误集合识别新增问题;这样既不会反复报告 `oss2` 对旧 `crcmod` +的陈旧元数据,也不会让既有告警遮蔽其他新增错误。核心能力 探针统一由 `app.doctor.dependencies` 执行。普通启动使用轻量模式验证 Web 栈、中文分词与转换; 镜像构建及插件安装前后使用完整模式,继续验证 ABI 敏感原生扩展、CRC C 实现、PostgreSQL C 实现及 导入后的 GIL 状态。插件允许的非核心依赖升级不要求与宿主锁文件逐版本相同,因此插件健康检查不得 diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index ea4bc0573..3893e5773 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -1547,6 +1547,7 @@ demo = { index = "private" } side_effect=health_snapshots, ), \ patch.object(PluginHelper, "_PluginHelper__repair_main_runtime_dependencies") as repair_mock, \ + patch("app.adapters.external.market.logger.warning") as warning_mock, \ patch( "app.adapters.external.market.SystemUtils.execute_with_subprocess", return_value=(True, "installed"), @@ -1556,6 +1557,10 @@ demo = { index = "private" } assert success assert message == "installed" repair_mock.assert_not_called() + assert not any( + "安装前运行环境已存在异常" in str(call.args[0]) + for call in warning_mock.call_args_list + ) def test_preexisting_healthcheck_failure_does_not_hide_new_core_failure(self): """ @@ -1615,14 +1620,34 @@ demo = { index = "private" } existing_error = "The package `oss2` requires `crcmod>=1.7`, but it's not installed" added_error = "The package `demo` requires `missing>=1`, but it's not installed" - message = PluginHelper._PluginHelper__runtime_health_regression_message( - {"uv check": (False, existing_error)}, - {"uv check": (False, f"{existing_error}\n{added_error}")}, - ) + with patch( + "app.adapters.external.market.runtime_excluded_dependency_pairs", + return_value={("oss2", "crcmod")}, + ): + message = PluginHelper._PluginHelper__runtime_health_regression_message( + {"uv check": (False, existing_error)}, + {"uv check": (False, f"{existing_error}\n{added_error}")}, + ) assert added_error in message assert existing_error not in message + def test_expected_uv_diagnostic_does_not_create_baseline_warning(self): + """项目明确排除的传递依赖不得形成插件安装前告警。""" + from app.adapters.external.market import PluginHelper + + expected_error = "The package `oss2` requires `crcmod>=1.7`, but it's not installed" + with patch( + "app.adapters.external.market.runtime_excluded_dependency_pairs", + return_value={("oss2", "crcmod")}, + ): + message = PluginHelper._PluginHelper__runtime_health_regression_message( + {}, + {"uv check": (False, expected_error)}, + ) + + assert message == "" + def test_uv_diagnostic_parser_handles_executor_prefix(self): """执行器把首条错误拼在命令摘要后时仍应识别完整诊断项。""" from app.adapters.external.market import PluginHelper @@ -1801,8 +1826,9 @@ demo = { index = "private" } find_links_dirs, ) + expected_error = "The package `oss2` requires `crcmod>=1.7`, but it's not installed" health = { - "uv check": (True, "ok"), + "uv check": (False, expected_error), "核心依赖导入检查": (True, "ok"), } strategy = Mock( @@ -1834,6 +1860,11 @@ demo = { index = "private" } ), patch.object( PluginHelper, "_PluginHelper__refresh_import_system", + ), patch( + "app.adapters.external.market.logger.warning", + ) as warning_mock, patch( + "app.adapters.external.market.runtime_excluded_dependency_pairs", + return_value={("oss2", "crcmod")}, ), patch( "app.adapters.external.market.SystemUtils.execute_with_subprocess_async", new=AsyncMock(return_value=(True, "ok")), @@ -1846,6 +1877,10 @@ demo = { index = "private" } assert execute_mock.await_args.kwargs["timeout"] == ( PluginHelper.PLUGIN_DEPENDENCY_INSTALL_TIMEOUT ) + assert not any( + "安装前运行环境已存在异常" in str(call.args[0]) + for call in warning_mock.call_args_list + ) def test_async_package_install_cancellation_closes_full_lifecycle(self, tmp_path): """取消真实安装进程后必须回收进程树、临时约束和安装锁。""" diff --git a/tests/test_runtime_dependencies.py b/tests/test_runtime_dependencies.py index c70e78087..2e8eba61f 100644 --- a/tests/test_runtime_dependencies.py +++ b/tests/test_runtime_dependencies.py @@ -79,6 +79,24 @@ def test_runtime_profiles_share_gil_safe_crcmod_distribution(): } in document["tool"]["uv"]["exclude-dependencies"] +def test_runtime_excluded_dependency_pairs_reads_uv_policy(tmp_path: Path): + """运行时诊断应复用 uv 排除配置,不维护第二份包名特判。""" + project_file = tmp_path / "pyproject.toml" + project_file.write_text( + """ +[tool.uv] +exclude-dependencies = [ + { package = { name = "Demo_Package" }, dependencies = ["Legacy-Dep>=1"] }, +] +""", + encoding="utf-8", + ) + + assert dependencies.runtime_excluded_dependency_pairs(project_file) == { + ("demo-package", "legacy-dep") + } + + def test_full_dependency_probe_rejects_psycopg_python_fallback(monkeypatch): """V3t 构建不得把 psycopg 纯 Python 实现误认为可发布能力。""" modules = {