feat(plugin): enhance dependency management by protecting main program dependencies and refining runtime constraints

This commit is contained in:
jxxghp
2026-05-12 12:38:17 +08:00
parent 1c17c0b07e
commit ac090af606
2 changed files with 303 additions and 25 deletions
+223 -21
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
from collections import deque
import importlib import importlib
import io import io
import json import json
@@ -17,6 +18,7 @@ import aiofiles
import aioshutil import aioshutil
import httpx import httpx
from anyio import Path as AsyncPath from anyio import Path as AsyncPath
from packaging.markers import default_environment
from packaging.requirements import Requirement from packaging.requirements import Requirement
from packaging.specifiers import SpecifierSet, InvalidSpecifier from packaging.specifiers import SpecifierSet, InvalidSpecifier
from packaging.version import Version, InvalidVersion from packaging.version import Version, InvalidVersion
@@ -865,17 +867,214 @@ class PluginHelper(metaclass=WeakSingleton):
""" """
return name.replace("_", "-") return name.replace("_", "-")
@staticmethod
def __marker_matches(marker, extra: str = "") -> bool:
"""
使用当前运行环境和可选 extra 上下文判断 marker 是否生效。
"""
if not marker:
return True
try:
env = default_environment()
env["extra"] = extra
return marker.evaluate(env)
except Exception as err:
logger.debug(f"依赖 marker 计算失败,按不匹配处理:{err}")
return False
@classmethod
def __parse_project_requirement_roots(
cls,
requirements_file: Path,
visited_files: Optional[Set[Path]] = None
) -> Dict[str, Set[str]]:
"""
解析主项目 requirements 文件,收集根依赖及其启用的 extras。
支持递归处理 -r/--requirement,忽略索引、约束等 pip 选项。
"""
roots = {}
if visited_files is None:
visited_files = set()
try:
requirements_file = requirements_file.resolve()
except Exception:
requirements_file = Path(requirements_file)
if requirements_file in visited_files:
return roots
visited_files.add(requirements_file)
if not requirements_file.exists():
logger.warning(f"主项目依赖文件不存在:{requirements_file}")
return roots
try:
with open(requirements_file, "r", encoding="utf-8") as f:
for raw_line in f:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
include_path = None
if line.startswith("-r"):
include_path = line[2:].strip() if line != "-r" else ""
elif line.startswith("--requirement"):
include_path = line[len("--requirement"):].strip()
if include_path is not None:
if include_path.startswith("="):
include_path = include_path[1:].strip()
if not include_path:
logger.debug(f"忽略无法识别的 requirements 引用:{line}")
continue
included_roots = cls.__parse_project_requirement_roots(
requirements_file.parent / include_path,
visited_files
)
for package_name, extras in included_roots.items():
roots.setdefault(package_name, set()).update(extras)
continue
if line.startswith((
"-c", "--constraint", "-i", "--index-url", "--extra-index-url",
"-f", "--find-links", "--trusted-host", "--no-index"
)):
continue
try:
requirement = Requirement(line)
except Exception as err:
logger.debug(f"无法解析主项目依赖项 '{line}'{err}")
continue
if not cls.__marker_matches(requirement.marker):
continue
package_name = cls.__standardize_pkg_name(requirement.name)
roots.setdefault(package_name, set()).update(
extra.lower() for extra in requirement.extras
)
return roots
except Exception as e:
logger.error(f"解析主项目依赖文件失败:{requirements_file} - {e}")
return {}
@classmethod
def __get_installed_distribution_requirements(cls) -> Dict[str, Tuple[Version, List[Requirement]]]:
"""
获取当前环境中每个已安装包的依赖声明,用于展开主程序依赖图。
"""
requirement_graph = {}
try:
for dist in distributions():
name = dist.metadata.get("Name")
if not name:
continue
package_name = cls.__standardize_pkg_name(name)
version_str = dist.metadata.get("Version") or getattr(dist, "version", None)
if not version_str:
continue
try:
version = Version(version_str)
except InvalidVersion:
logger.debug(f"无法解析已安装包 '{package_name}' 的版本:{version_str}")
continue
requirements = []
for raw_requirement in dist.requires or []:
try:
requirements.append(Requirement(raw_requirement))
except Exception as err:
logger.debug(f"无法解析已安装包 '{package_name}' 的依赖项 '{raw_requirement}'{err}")
if package_name not in requirement_graph or version > requirement_graph[package_name][0]:
requirement_graph[package_name] = (version, requirements)
return requirement_graph
except Exception as e:
logger.error(f"收集已安装包依赖图时发生错误:{e}")
return {}
@classmethod
def __get_protected_runtime_packages(
cls,
installed_packages: Optional[Dict[str, Version]] = None
) -> Dict[str, Version]:
"""
仅收集主程序依赖图中的已安装包版本。
主项目 requirements 中声明的根依赖及其当前已安装的传递依赖都会被冻结,
未被主程序依赖图引用的插件自带包允许后续插件按需升级或降级。
"""
if installed_packages is None:
installed_packages = cls.__get_installed_packages()
protected_packages = {
package_name: version
for package_name, version in installed_packages.items()
if package_name in cls._protected_runtime_packages
}
root_requirements_file = settings.ROOT_PATH / "requirements.txt"
if not root_requirements_file.exists():
root_requirements_file = settings.ROOT_PATH / "requirements.in"
root_requirements = cls.__parse_project_requirement_roots(root_requirements_file)
if not root_requirements:
return protected_packages
requirement_graph = cls.__get_installed_distribution_requirements()
active_extras = {
package_name: set(extras)
for package_name, extras in root_requirements.items()
}
pending_packages = deque(active_extras.keys())
processed_extras: Dict[str, Set[str]] = {}
while pending_packages:
package_name = pending_packages.popleft()
selected_extras = active_extras.get(package_name, set())
previous_extras = processed_extras.get(package_name)
if previous_extras is not None and selected_extras.issubset(previous_extras):
continue
processed_extras[package_name] = set(selected_extras)
if package_name in installed_packages:
protected_packages[package_name] = installed_packages[package_name]
_, requirements = requirement_graph.get(package_name, (None, []))
if not requirements:
continue
active_extra_values = [""] + sorted(selected_extras)
for requirement in requirements:
if requirement.marker and not any(
cls.__marker_matches(requirement.marker, extra)
for extra in active_extra_values
):
continue
dep_name = cls.__standardize_pkg_name(requirement.name)
known_extras = active_extras.setdefault(dep_name, set())
before_len = len(known_extras)
known_extras.update(extra.lower() for extra in requirement.extras)
if dep_name not in processed_extras or len(known_extras) != before_len:
pending_packages.append(dep_name)
return protected_packages
@classmethod @classmethod
def __validate_runtime_dependency_conflicts( def __validate_runtime_dependency_conflicts(
cls, cls,
requirements_file: Path, requirements_file: Path,
installed_packages: Dict[str, Version] protected_packages: Dict[str, Version]
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
""" """
在真正执行 pip 前,先拦截插件对现有运行环境中已安装包的显式覆盖请求。 在真正执行 pip 前,先拦截插件对主程序依赖的显式覆盖请求。
共享 venv 场景下,允许插件新增依赖,但不允许它升级/降级已有包,否则不仅主程序 共享 venv 场景下,仅冻结主程序依赖;插件新增依赖、以及插件之间共享的额外依赖
其他插件也会被一起污染 允许后续安装继续调整版本
""" """
conflicts = [] conflicts = []
try: try:
@@ -890,11 +1089,11 @@ class PluginHelper(metaclass=WeakSingleton):
logger.debug(f"无法解析依赖项 '{line}',跳过运行环境冲突预检:{err}") logger.debug(f"无法解析依赖项 '{line}',跳过运行环境冲突预检:{err}")
continue continue
if requirement.marker and not requirement.marker.evaluate(): if not cls.__marker_matches(requirement.marker):
continue continue
package_name = cls.__standardize_pkg_name(requirement.name) package_name = cls.__standardize_pkg_name(requirement.name)
installed_version = installed_packages.get(package_name) installed_version = protected_packages.get(package_name)
if installed_version is None: if installed_version is None:
continue continue
@@ -936,17 +1135,16 @@ class PluginHelper(metaclass=WeakSingleton):
if len(conflicts) > 5: if len(conflicts) > 5:
details.append(f"其余 {len(conflicts) - 5} 项冲突已省略") details.append(f"其余 {len(conflicts) - 5} 项冲突已省略")
scope = "主程序核心依赖" if any(item[3] for item in conflicts) else "已安装依赖" scope = "主程序核心依赖" if any(item[3] for item in conflicts) else "主程序依赖"
return False, ( return False, (
f"插件依赖与当前运行环境的{scope}冲突:{''.join(details)}" f"插件依赖与当前运行环境的{scope}冲突:{''.join(details)}"
f"为避免共享运行环境被污染,已拒绝安装。" f"为避免共享运行环境被污染,已拒绝安装。"
) )
@classmethod @classmethod
def __create_runtime_constraints_file(cls, installed_packages: Dict[str, Version]) -> Path: def __create_runtime_constraints_file(cls, protected_packages: Dict[str, Version]) -> Path:
""" """
“当前环境已安装版本”为准生成临时约束文件,确保插件只能新增依赖 主程序依赖的当前已安装版本生成临时约束文件,确保插件安装不会改写主程序依赖
不能悄悄升级或降级任何已安装包。
""" """
temp_dir = Path(settings.TEMP_PATH) / "plugin_dependencies" temp_dir = Path(settings.TEMP_PATH) / "plugin_dependencies"
temp_dir.mkdir(parents=True, exist_ok=True) temp_dir.mkdir(parents=True, exist_ok=True)
@@ -958,7 +1156,7 @@ class PluginHelper(metaclass=WeakSingleton):
suffix=".txt", suffix=".txt",
delete=False delete=False
) as temp_file: ) as temp_file:
for package_name, version in sorted(installed_packages.items()): for package_name, version in sorted(protected_packages.items()):
temp_file.write( temp_file.write(
f"{cls.__format_pkg_name_for_pip(package_name)}=={version}\n" f"{cls.__format_pkg_name_for_pip(package_name)}=={version}\n"
) )
@@ -990,11 +1188,11 @@ class PluginHelper(metaclass=WeakSingleton):
@classmethod @classmethod
def __repair_main_runtime_dependencies(cls, snapshot_file: Optional[Path] = None) -> Tuple[bool, str]: def __repair_main_runtime_dependencies(cls, snapshot_file: Optional[Path] = None) -> Tuple[bool, str]:
""" """
依赖安装后如果发现主运行环境已异常,优先恢复安装前依赖快照; 依赖安装后如果发现主运行环境已异常,优先恢复主程序依赖快照;
若快照不可用,再按主项目依赖重新安装进行自愈。 若快照不可用,再按主项目依赖重新安装进行自愈。
""" """
repair_target = snapshot_file repair_target = snapshot_file
repair_desc = "安装前依赖快照" repair_desc = "主程序依赖快照"
if repair_target and not repair_target.exists(): if repair_target and not repair_target.exists():
repair_target = None repair_target = None
if repair_target is None: if repair_target is None:
@@ -1054,21 +1252,23 @@ class PluginHelper(metaclass=WeakSingleton):
logger.debug(f"[PIP] 未发现可用的 wheels 目录,将仅使用在线源。") logger.debug(f"[PIP] 未发现可用的 wheels 目录,将仅使用在线源。")
installed_packages = cls.__get_installed_packages() installed_packages = cls.__get_installed_packages()
check_ok, check_message = cls.__validate_runtime_dependency_conflicts(requirements_file, installed_packages) protected_packages = cls.__get_protected_runtime_packages(installed_packages)
check_ok, check_message = cls.__validate_runtime_dependency_conflicts(requirements_file, protected_packages)
if not check_ok: if not check_ok:
logger.error(f"[PIP] 运行环境冲突预检失败:{check_message}") logger.error(f"[PIP] 运行环境冲突预检失败:{check_message}")
return False, check_message return False, check_message
constraints_file = None constraints_file = None
try: if protected_packages:
constraints_file = cls.__create_runtime_constraints_file(installed_packages) try:
except Exception as e: constraints_file = cls.__create_runtime_constraints_file(protected_packages)
logger.error(f"[PIP] 创建运行环境约束文件失败:{e}") except Exception as e:
return False, f"创建运行环境约束文件失败:{e}" logger.error(f"[PIP] 创建运行环境约束文件失败:{e}")
return False, f"创建运行环境约束文件失败:{e}"
base_cmd = [sys.executable, "-m", "pip", "install"] + find_links_option base_cmd = [sys.executable, "-m", "pip", "install"] + find_links_option
if constraints_file: if constraints_file:
# 这里固定约束到当前运行环境的已安装版本,避免共享 venv 被插件重写 # 这里固定约束到主程序依赖的当前版本,避免共享 venv 被插件改写核心运行环境
base_cmd.extend(["-c", str(constraints_file)]) base_cmd.extend(["-c", str(constraints_file)])
base_cmd.extend(["-r", str(requirements_file)]) base_cmd.extend(["-r", str(requirements_file)])
strategies = cls.__build_pip_install_strategies(base_cmd) strategies = cls.__build_pip_install_strategies(base_cmd)
@@ -1086,7 +1286,9 @@ class PluginHelper(metaclass=WeakSingleton):
health_ok, health_message = cls.__run_runtime_healthcheck() health_ok, health_message = cls.__run_runtime_healthcheck()
if not health_ok: if not health_ok:
logger.error(f"[PIP] 依赖安装后运行环境自检失败:{health_message}") logger.error(f"[PIP] 依赖安装后运行环境自检失败:{health_message}")
repair_ok, repair_message = cls.__repair_main_runtime_dependencies(constraints_file) repair_ok, repair_message = cls.__repair_main_runtime_dependencies(
constraints_file if protected_packages else None
)
if repair_ok: if repair_ok:
health_restored, restored_message = cls.__run_runtime_healthcheck() health_restored, restored_message = cls.__run_runtime_healthcheck()
if health_restored: if health_restored:
+80 -4
View File
@@ -7,6 +7,7 @@ from types import ModuleType
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch from unittest.mock import patch
from packaging.requirements import Requirement
from packaging.version import Version from packaging.version import Version
@@ -120,6 +121,43 @@ class PluginHelperTest(TestCase):
self.assertEqual([], errors) self.assertEqual([], errors)
self.assertEqual(1, max_active_installs) self.assertEqual(1, max_active_installs)
def test_get_protected_runtime_packages_only_keeps_main_dependency_graph(self):
"""
验证仅主程序依赖链上的包会被纳入保护集合。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
self.skipTest(f"missing dependency: {exc}")
installed_packages = {
"passlib": Version("1.7.4"),
"bcrypt": Version("4.0.1"),
"demo_package": Version("1.0"),
}
requirement_graph = {
"passlib": (Version("1.7.4"), [Requirement("bcrypt>=4")]),
"bcrypt": (Version("4.0.1"), []),
"demo_package": (Version("1.0"), []),
}
with patch.object(
PluginHelper,
"_PluginHelper__parse_project_requirement_roots",
return_value={"passlib": set()}
):
with patch.object(
PluginHelper,
"_PluginHelper__get_installed_distribution_requirements",
return_value=requirement_graph
):
protected_packages = PluginHelper._PluginHelper__get_protected_runtime_packages(installed_packages)
self.assertEqual({
"passlib": Version("1.7.4"),
"bcrypt": Version("4.0.1"),
}, protected_packages)
def test_pip_install_rejects_conflicting_runtime_dependency(self): def test_pip_install_rejects_conflicting_runtime_dependency(self):
""" """
验证插件如果试图覆盖主程序核心依赖,会在真正执行 pip 前被直接拒绝。 验证插件如果试图覆盖主程序核心依赖,会在真正执行 pip 前被直接拒绝。
@@ -134,7 +172,7 @@ class PluginHelperTest(TestCase):
requirements_file.write_text("fastapi<0.1\n", encoding="utf-8") requirements_file.write_text("fastapi<0.1\n", encoding="utf-8")
with patch.object( with patch.object(
PluginHelper, PluginHelper,
"_PluginHelper__get_installed_packages", "_PluginHelper__get_protected_runtime_packages",
return_value={"fastapi": Version("0.115.14")} return_value={"fastapi": Version("0.115.14")}
): ):
success, message = PluginHelper.pip_install_with_fallback(requirements_file) success, message = PluginHelper.pip_install_with_fallback(requirements_file)
@@ -143,9 +181,47 @@ class PluginHelperTest(TestCase):
self.assertIn("主程序核心依赖", message) self.assertIn("主程序核心依赖", message)
self.assertIn("fastapi", message) self.assertIn("fastapi", message)
def test_pip_install_allows_changing_non_runtime_dependency(self):
"""
验证非主程序依赖即便已安装,插件后续仍可调整其版本约束。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
self.skipTest(f"missing dependency: {exc}")
seen_install_commands = []
def fake_execute(cmd):
if cmd[:4] == [sys.executable, "-m", "pip", "install"]:
seen_install_commands.append(cmd)
self.assertNotIn("-c", cmd)
return True, "ok"
return True, "ok"
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
requirements_file.write_text("demo-package>=2\n", encoding="utf-8")
with patch.object(
PluginHelper,
"_PluginHelper__get_installed_packages",
return_value={"demo_package": Version("1.0")}
):
with patch.object(
PluginHelper,
"_PluginHelper__get_protected_runtime_packages",
return_value={}
):
with patch("app.helper.plugin.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
self.assertTrue(success)
self.assertEqual("ok", message)
self.assertEqual(1, len(seen_install_commands))
def test_pip_install_uses_runtime_constraints_file(self): def test_pip_install_uses_runtime_constraints_file(self):
""" """
验证插件依赖安装会固定当前运行环境已安装版本,防止共享 venv 被升级或降级 验证插件依赖安装会固定主程序依赖的当前版本,防止共享 venv 被改写
""" """
try: try:
from app.helper.plugin import PluginHelper from app.helper.plugin import PluginHelper
@@ -169,7 +245,7 @@ class PluginHelperTest(TestCase):
requirements_file.write_text("demo-package\n", encoding="utf-8") requirements_file.write_text("demo-package\n", encoding="utf-8")
with patch.object( with patch.object(
PluginHelper, PluginHelper,
"_PluginHelper__get_installed_packages", "_PluginHelper__get_protected_runtime_packages",
return_value={"fastapi": Version("0.115.14")} return_value={"fastapi": Version("0.115.14")}
): ):
with patch("app.helper.plugin.SystemUtils.execute_with_subprocess", side_effect=fake_execute): with patch("app.helper.plugin.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
@@ -213,7 +289,7 @@ class PluginHelperTest(TestCase):
requirements_file.write_text("demo-package\n", encoding="utf-8") requirements_file.write_text("demo-package\n", encoding="utf-8")
with patch.object( with patch.object(
PluginHelper, PluginHelper,
"_PluginHelper__get_installed_packages", "_PluginHelper__get_protected_runtime_packages",
return_value={"fastapi": Version("0.115.14")} return_value={"fastapi": Version("0.115.14")}
): ):
with patch("app.helper.plugin.SystemUtils.execute_with_subprocess", side_effect=fake_execute): with patch("app.helper.plugin.SystemUtils.execute_with_subprocess", side_effect=fake_execute):