From a516bc1c775cf8ed7fc9d4b8a5c3d8dabc051ec9 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:07:27 +0800 Subject: [PATCH] fix(plugin): allow dev local hot sync across system version gate (#5961) --- app/core/plugin.py | 6 +- app/helper/plugin.py | 10 ++- tests/test_plugin_local_sync.py | 110 ++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 tests/test_plugin_local_sync.py diff --git a/app/core/plugin.py b/app/core/plugin.py index 4edc0597..c6cff26f 100644 --- a/app/core/plugin.py +++ b/app/core/plugin.py @@ -466,7 +466,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): pid=plugin_dir_name, package_version=package_version, repo_path=local_repo_path, - strict_compat=False + strict_compat=False, + strict_system_version=not settings.DEV ) if candidate: return candidate @@ -488,6 +489,9 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): candidate = candidate or PluginHelper().get_local_plugin_candidate(pid) if not candidate: return False + if candidate.get("compatible") is False: + logger.info(f"本地插件 {pid} 不满足同步条件,跳过自动同步:{candidate.get('skip_reason')}") + return False source_dir = Path(candidate.get("path")) dest_dir = settings.ROOT_PATH / "app" / "plugins" / pid.lower() diff --git a/app/helper/plugin.py b/app/helper/plugin.py index 24dcf823..268e7e66 100644 --- a/app/helper/plugin.py +++ b/app/helper/plugin.py @@ -307,9 +307,11 @@ class PluginHelper(metaclass=WeakSingleton): def get_local_plugin_candidate(self, pid: str, package_version: Optional[str] = None, repo_path: Optional[Path] = None, - strict_compat: bool = True) -> Optional[dict]: + strict_compat: bool = True, + strict_system_version: bool = True) -> Optional[dict]: """ 获取指定插件ID的本地插件候选 + :param strict_system_version: 是否将主系统版本范围不匹配视为不可用候选 """ if not pid: return None @@ -352,7 +354,7 @@ class PluginHelper(metaclass=WeakSingleton): candidate["compatible"] = False candidate["skip_reason"] = f"package.json 未声明 {settings.VERSION_FLAG} 兼容" self.annotate_plugin_system_version(candidate) - if candidate.get("system_version_compatible") is False: + if strict_system_version and candidate.get("system_version_compatible") is False: candidate["compatible"] = False candidate["skip_reason"] = candidate.get("system_version_message") if package_version is not None: @@ -369,6 +371,10 @@ class PluginHelper(metaclass=WeakSingleton): candidates = self.get_local_plugin_candidates() for candidate_pid, candidate in candidates.items(): if candidate_pid.lower() == pid.lower(): + if strict_system_version and candidate.get("system_version_compatible") is False: + candidate = candidate.copy() + candidate["compatible"] = False + candidate["skip_reason"] = candidate.get("system_version_message") return candidate return None diff --git a/tests/test_plugin_local_sync.py b/tests/test_plugin_local_sync.py new file mode 100644 index 00000000..f7e13cf0 --- /dev/null +++ b/tests/test_plugin_local_sync.py @@ -0,0 +1,110 @@ +from pathlib import Path +from types import SimpleNamespace +from typing import Iterator + +import pytest +from packaging.version import Version + +from app.core.plugin import PluginManager +from app.helper.plugin import PluginHelper +from app.schemas.types import SystemConfigKey +from app.utils.singleton import Singleton + + +@pytest.fixture +def plugin_manager() -> Iterator[PluginManager]: + """构造隔离的插件管理器实例,避免单例状态污染其它用例。""" + Singleton._instances.pop((PluginManager, (), frozenset()), None) + manager = PluginManager() + yield manager + Singleton._instances.pop((PluginManager, (), frozenset()), None) + + +def _build_local_plugin_repo(tmp_path: Path) -> tuple[Path, Path]: + """构造带系统版本要求的本地 v2 插件仓库。""" + repo_path = tmp_path / "local-plugins" + source_dir = repo_path / "plugins.v2" / "demoplugin" + source_file = source_dir / "__init__.py" + source_dir.mkdir(parents=True) + source_file.write_text( + "from app.plugins import _PluginBase\n" + "class DemoPlugin(_PluginBase):\n" + " plugin_name = 'Demo'\n", + encoding="utf-8", + ) + (repo_path / "package.v2.json").write_text( + '{"DemoPlugin": {"version": "1.0.0", "system_version": ">=2.13.11"}}', + encoding="utf-8", + ) + return repo_path, source_file + + +def test_dev_local_plugin_candidate_keeps_hot_sync_allowed_when_system_version_lags( + tmp_path, + monkeypatch, + plugin_manager: PluginManager, +) -> None: + """DEV 本地源码候选保留热同步资格,系统版本差异只作为兼容性提示。""" + repo_path, source_file = _build_local_plugin_repo(tmp_path) + runtime_dir = tmp_path / "app" / "plugins" / "demoplugin" + + monkeypatch.setattr("app.core.plugin.settings", SimpleNamespace(DEV=True, ROOT_PATH=tmp_path)) + monkeypatch.setattr("app.helper.plugin.settings.PLUGIN_LOCAL_REPO_PATHS", str(repo_path)) + monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10")) + monkeypatch.setattr( + "app.core.plugin.SystemConfigOper.get", + lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None, + ) + + candidate = plugin_manager._get_local_plugin_candidate_from_path(source_file) + + assert candidate["system_version_compatible"] is False + assert candidate.get("compatible") is not False + assert plugin_manager._sync_local_plugin_if_installed("DemoPlugin", candidate) + assert (runtime_dir / "__init__.py").read_text(encoding="utf-8") == source_file.read_text(encoding="utf-8") + + +def test_local_plugin_candidate_keeps_system_version_gate_outside_dev( + tmp_path, + monkeypatch, + plugin_manager: PluginManager, +) -> None: + """非 DEV 本地候选继续受主系统版本门禁保护,避免自动热加载绕过安装约束。""" + repo_path, source_file = _build_local_plugin_repo(tmp_path) + + monkeypatch.setattr("app.core.plugin.settings", SimpleNamespace(DEV=False, ROOT_PATH=tmp_path)) + monkeypatch.setattr("app.helper.plugin.settings.PLUGIN_LOCAL_REPO_PATHS", str(repo_path)) + monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10")) + + candidate = plugin_manager._get_local_plugin_candidate_from_path(source_file) + + assert candidate["system_version_compatible"] is False + assert candidate["compatible"] is False + assert "MoviePilot 版本 >=2.13.11" in candidate["skip_reason"] + + +def test_local_plugin_sync_without_candidate_respects_system_version_gate( + tmp_path, + monkeypatch, + plugin_manager: PluginManager, +) -> None: + """未传候选时的本地同步兜底查询也必须遵守系统版本门禁。""" + repo_path, _source_file = _build_local_plugin_repo(tmp_path) + runtime_dir = tmp_path / "app" / "plugins" / "demoplugin" + settings_stub = SimpleNamespace( + DEV=False, + ROOT_PATH=tmp_path, + VERSION_FLAG="v2", + PLUGIN_LOCAL_REPO_PATHS=str(repo_path), + ) + + monkeypatch.setattr("app.core.plugin.settings", settings_stub) + monkeypatch.setattr("app.helper.plugin.settings", settings_stub) + monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10")) + monkeypatch.setattr( + "app.core.plugin.SystemConfigOper.get", + lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None, + ) + + assert not plugin_manager._sync_local_plugin_if_installed("DemoPlugin") + assert not runtime_dir.exists()