mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-31 04:57:23 +08:00
fix(plugin): filter declared uv dependency exclusions (#6437)
This commit is contained in:
Vendored
+36
-6
@@ -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<package>[^`]+)` requires `(?P<requirement>[^`]+)`, "
|
||||
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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 状态。插件允许的非核心依赖升级不要求与宿主锁文件逐版本相同,因此插件健康检查不得
|
||||
|
||||
@@ -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):
|
||||
"""取消真实安装进程后必须回收进程树、临时约束和安装锁。"""
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user