mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +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
|
import httpx2
|
||||||
from anyio import Path as AsyncPath
|
from anyio import Path as AsyncPath
|
||||||
from packaging.markers import default_environment
|
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.specifiers import SpecifierSet, InvalidSpecifier
|
||||||
|
from packaging.utils import canonicalize_name
|
||||||
from packaging.version import Version, InvalidVersion
|
from packaging.version import Version, InvalidVersion
|
||||||
from importlib.metadata import distributions
|
from importlib.metadata import distributions
|
||||||
from requests import Response
|
from requests import Response
|
||||||
@@ -34,6 +35,7 @@ from app.runtime.cache import cached, is_fresh
|
|||||||
from app.runtime.dependencies import (
|
from app.runtime.dependencies import (
|
||||||
iter_runtime_profile_requirement_strings,
|
iter_runtime_profile_requirement_strings,
|
||||||
iter_runtime_requirement_strings,
|
iter_runtime_requirement_strings,
|
||||||
|
runtime_excluded_dependency_pairs,
|
||||||
)
|
)
|
||||||
from app.runtime.settings import RuntimeSettingsCompat
|
from app.runtime.settings import RuntimeSettingsCompat
|
||||||
from app.adapters.system.package import (
|
from app.adapters.system.package import (
|
||||||
@@ -1733,15 +1735,36 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __runtime_health_error_lines(check_name: str, message: str) -> set[str]:
|
def __runtime_health_error_lines(check_name: str, message: str) -> set[str]:
|
||||||
"""提取稳定诊断项,忽略执行器附加的命令摘要。"""
|
"""提取未被项目依赖策略排除的稳定诊断项。"""
|
||||||
lines = {line.strip() for line in message.splitlines() if line.strip()}
|
lines = {line.strip() for line in message.splitlines() if line.strip()}
|
||||||
if check_name != "uv check":
|
if check_name != "uv check":
|
||||||
return lines
|
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,
|
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
|
@staticmethod
|
||||||
def __runtime_health_regression_message(
|
def __runtime_health_regression_message(
|
||||||
@@ -1755,7 +1778,14 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
for check_name, (success, message) in current_health.items():
|
for check_name, (success, message) in current_health.items():
|
||||||
baseline_success, baseline_message = baseline_health.get(check_name, (True, ""))
|
baseline_success, baseline_message = baseline_health.get(check_name, (True, ""))
|
||||||
if baseline_success and not success:
|
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:
|
elif not baseline_success and not success:
|
||||||
baseline_lines = PluginHelper.__runtime_health_error_lines(
|
baseline_lines = PluginHelper.__runtime_health_error_lines(
|
||||||
check_name,
|
check_name,
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import tomllib
|
|||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from pathlib import Path
|
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
|
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
|
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__":
|
if __name__ == "__main__":
|
||||||
print(runtime_dependency_group())
|
print(runtime_dependency_group())
|
||||||
|
|||||||
@@ -81,8 +81,9 @@ profile,依赖名称、版本和 source 语义全部由 `pyproject.toml` 与 `
|
|||||||
3. 恢复完成后重新执行依赖诊断与核心能力探针;
|
3. 恢复完成后重新执行依赖诊断与核心能力探针;
|
||||||
4. 宿主即使恢复成功,本次插件安装仍返回失败,不能把被回滚的安装报告为成功。
|
4. 宿主即使恢复成功,本次插件安装仍返回失败,不能把被回滚的安装报告为成功。
|
||||||
|
|
||||||
依赖诊断使用 `uv pip check`,并按安装前后的稳定错误集合识别新增问题;这样既不会把
|
依赖诊断使用 `uv pip check`,项目在 `tool.uv.exclude-dependencies` 中明确排除的传递依赖不进入健康
|
||||||
`oss2` 对旧 `crcmod` 的陈旧元数据误归因于本次安装,也不会让既有告警遮蔽其他新增错误。核心能力
|
异常集合,其余诊断按安装前后的稳定错误集合识别新增问题;这样既不会反复报告 `oss2` 对旧 `crcmod`
|
||||||
|
的陈旧元数据,也不会让既有告警遮蔽其他新增错误。核心能力
|
||||||
探针统一由 `app.doctor.dependencies` 执行。普通启动使用轻量模式验证 Web 栈、中文分词与转换;
|
探针统一由 `app.doctor.dependencies` 执行。普通启动使用轻量模式验证 Web 栈、中文分词与转换;
|
||||||
镜像构建及插件安装前后使用完整模式,继续验证 ABI 敏感原生扩展、CRC C 实现、PostgreSQL C 实现及
|
镜像构建及插件安装前后使用完整模式,继续验证 ABI 敏感原生扩展、CRC C 实现、PostgreSQL C 实现及
|
||||||
导入后的 GIL 状态。插件允许的非核心依赖升级不要求与宿主锁文件逐版本相同,因此插件健康检查不得
|
导入后的 GIL 状态。插件允许的非核心依赖升级不要求与宿主锁文件逐版本相同,因此插件健康检查不得
|
||||||
|
|||||||
@@ -1547,6 +1547,7 @@ demo = { index = "private" }
|
|||||||
side_effect=health_snapshots,
|
side_effect=health_snapshots,
|
||||||
), \
|
), \
|
||||||
patch.object(PluginHelper, "_PluginHelper__repair_main_runtime_dependencies") as repair_mock, \
|
patch.object(PluginHelper, "_PluginHelper__repair_main_runtime_dependencies") as repair_mock, \
|
||||||
|
patch("app.adapters.external.market.logger.warning") as warning_mock, \
|
||||||
patch(
|
patch(
|
||||||
"app.adapters.external.market.SystemUtils.execute_with_subprocess",
|
"app.adapters.external.market.SystemUtils.execute_with_subprocess",
|
||||||
return_value=(True, "installed"),
|
return_value=(True, "installed"),
|
||||||
@@ -1556,6 +1557,10 @@ demo = { index = "private" }
|
|||||||
assert success
|
assert success
|
||||||
assert message == "installed"
|
assert message == "installed"
|
||||||
repair_mock.assert_not_called()
|
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):
|
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"
|
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"
|
added_error = "The package `demo` requires `missing>=1`, but it's not installed"
|
||||||
|
|
||||||
message = PluginHelper._PluginHelper__runtime_health_regression_message(
|
with patch(
|
||||||
{"uv check": (False, existing_error)},
|
"app.adapters.external.market.runtime_excluded_dependency_pairs",
|
||||||
{"uv check": (False, f"{existing_error}\n{added_error}")},
|
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 added_error in message
|
||||||
assert existing_error not 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):
|
def test_uv_diagnostic_parser_handles_executor_prefix(self):
|
||||||
"""执行器把首条错误拼在命令摘要后时仍应识别完整诊断项。"""
|
"""执行器把首条错误拼在命令摘要后时仍应识别完整诊断项。"""
|
||||||
from app.adapters.external.market import PluginHelper
|
from app.adapters.external.market import PluginHelper
|
||||||
@@ -1801,8 +1826,9 @@ demo = { index = "private" }
|
|||||||
find_links_dirs,
|
find_links_dirs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
expected_error = "The package `oss2` requires `crcmod>=1.7`, but it's not installed"
|
||||||
health = {
|
health = {
|
||||||
"uv check": (True, "ok"),
|
"uv check": (False, expected_error),
|
||||||
"核心依赖导入检查": (True, "ok"),
|
"核心依赖导入检查": (True, "ok"),
|
||||||
}
|
}
|
||||||
strategy = Mock(
|
strategy = Mock(
|
||||||
@@ -1834,6 +1860,11 @@ demo = { index = "private" }
|
|||||||
), patch.object(
|
), patch.object(
|
||||||
PluginHelper,
|
PluginHelper,
|
||||||
"_PluginHelper__refresh_import_system",
|
"_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(
|
), patch(
|
||||||
"app.adapters.external.market.SystemUtils.execute_with_subprocess_async",
|
"app.adapters.external.market.SystemUtils.execute_with_subprocess_async",
|
||||||
new=AsyncMock(return_value=(True, "ok")),
|
new=AsyncMock(return_value=(True, "ok")),
|
||||||
@@ -1846,6 +1877,10 @@ demo = { index = "private" }
|
|||||||
assert execute_mock.await_args.kwargs["timeout"] == (
|
assert execute_mock.await_args.kwargs["timeout"] == (
|
||||||
PluginHelper.PLUGIN_DEPENDENCY_INSTALL_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):
|
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"]
|
} 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):
|
def test_full_dependency_probe_rejects_psycopg_python_fallback(monkeypatch):
|
||||||
"""V3t 构建不得把 psycopg 纯 Python 实现误认为可发布能力。"""
|
"""V3t 构建不得把 psycopg 纯 Python 实现误认为可发布能力。"""
|
||||||
modules = {
|
modules = {
|
||||||
|
|||||||
Reference in New Issue
Block a user