mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
feat(plugin): enhance dependency management by protecting main program dependencies and refining runtime constraints
This commit is contained in:
+223
-21
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user