Files
MoviePilot/tests/test_plugin_local_sync.py
Aqr-K e47d7defe7 feat(plugin): 插件本体与分身可各自设置独立日志等级
全局日志等级是一个开关,调试单个插件时只能整体下调。一旦下调,被调试插件那几行
日志会淹没在其他几十个插件与宿主自身的 DEBUG 输出里,找不出来;不下调又什么都
看不见。插件实例数量只会继续增长,这个矛盾不会自己消失。

因此把等级下放到实例:plugininstance 行加 log_level / log_expires_at 两列,本体与
分身共用同一张表、同一套语义。等级落在实例自己那一行而不是另起一张按实例 ID 索引
的表——它和业务参数一样是用户按实例设置的东西,同一个生命周期,实例被删时应当随行
一起消失。清除覆盖后若本体行只剩一对身份列,整行按 carries_only_identity 回收,与
清空业务参数同一规则。

覆盖带失效时间,因为它是调试设置不是长期配置:开了 DEBUG 忘记关,下次谁也想不起
某个插件为什么一直在刷日志。过期判定在读取时惰性执行并就地清理,不另起后台线程去
扫一张最多几十条的表。expires_at 在写入侧先归一到 UTC 再分发给缓存与落盘两处:客户端
提交的时间可能不带时区,两处各按进程时区折算一次,就会与读取口返回的 UTC 值差出一个
时区,表现为「提交的失效时刻和读回来的对不上」。

运行期用 ContextVar 在宿主自己控制的四类调用点绑定当前实例——构造与 init_plugin、
事件处理器、定时服务回调、插件声明的 HTTP 端点——而不是沿用 logger() 已有的栈帧内省。
内省认的是栈顶的模块与类,插件通过宿主公共方法转发调用时栈顶是宿主而不是发起调用的
插件,会把日志记到错误的来源上;分身更甚:分身共享源码,类的 __qualname__ 保持源类名
不变,内省根本分不出是哪一个分身在打日志。内省结果继续只用于文件路由,不参与等级判定。

等级过滤必须排在全局等级判定之前。官方现状是先按全局等级短路、之后才做内省,先短路
就会把实例调低的等级挡在门外,后面再怎么识别来源都看不到这条日志。

回调在注册时被捕获、稍后才由调度器或 FastAPI 调用,那时已经不在任何绑定作用域内,
故这两处改用 wrap_for_plugin_instance 把绑定包进回调自身;同一实例重复包装直接返回
原对象,避免插件缓存声明时包装链随重载次数无限增长。

进程内覆盖表按实例 ID 常驻,删分身时必须一并清掉:只删库里那一行的话,同一进程内用
相同后缀重建的分身会继承上一个分身的等级,界面显示「跟随全局」而实际仍按旧等级输出。
进程重启后反向从库预热缓存。

迁移链在 F1 之后追加:c4e1a7b9d2f6(3.0.35)-> 487f7e681955(3.0.36 加两列)。

顺带补了 agent 策略里缺失的 media.classification.policy.get 的 template 字段说明:
少了它,api_mcp_schema.json 与技能文档的两个生成脚本都跑不起来。
2026-09-12 19:05:05 -04:00

883 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import threading
from pathlib import Path
from types import SimpleNamespace
from typing import Iterator
from unittest.mock import Mock
import pytest
from packaging.version import Version
from watchfiles import Change
from app.adapters.external.plugin.client import PluginMarketTransport
from app.foundation.singleton import Singleton
from app.runtime.events import Event, eventmanager
from app.runtime.extensions.plugin.manager import PluginManager
from app.runtime.extensions.plugin.paths import PluginPathResolver
from app.runtime.extensions.plugin.system import get_plugin_system
from app.scheduler import reconcile as scheduler_reconcile
from app.scheduler.facade import Scheduler
from app.scheduler.registry import ExecutionRegistry
from app.schemas.types import EventType, SystemConfigKey
@pytest.fixture
def plugin_manager(monkeypatch) -> Iterator[PluginManager]:
"""构造隔离的插件管理器实例,避免单例状态污染其它用例。"""
system = get_plugin_system()
from app.adapters.external.plugin import client as plugin_client_module
original_runtime_setting = plugin_client_module.get_runtime_setting
class _SettingsStub(SimpleNamespace):
"""允许存量用例覆盖尚未显式声明的配置键。"""
def __getattr__(self, _key):
return None
market_settings = _SettingsStub(
VERSION_FLAG="v2",
REPO_GITHUB_HEADERS=original_runtime_setting("REPO_GITHUB_HEADERS"),
PLUGIN_LOCAL_REPO_PATHS="",
)
monkeypatch.setattr(
plugin_client_module,
"get_runtime_setting",
lambda key, default=None: (
getattr(market_settings, key)
if hasattr(market_settings, key)
else original_runtime_setting(key, default)
),
)
def install_local(**kwargs) -> tuple[bool, str]:
"""用测试包适配器模拟已通过来源准入的本地 Gateway。"""
repo_url = kwargs["repo_url"]
candidate = system.local_candidate(
kwargs["plugin_id"],
package_version=kwargs.get("package_version"),
repo_path=PluginMarketTransport.parse_local_repo_path(repo_url),
strict_system_version=False,
)
if not candidate:
return False, "本地候选不存在"
return (
system.package.sync_local(
kwargs["plugin_id"],
Path(candidate["path"]),
),
"",
)
monkeypatch.setattr(system, "install", install_local)
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",
)
remote_entry = source_dir / "dist" / "assets" / "remoteEntry.js"
remote_entry.parent.mkdir(parents=True)
remote_entry.write_text("export default {}\n", encoding="utf-8")
dependency_file = source_dir / "node_modules" / "example" / "index.js"
dependency_file.parent.mkdir(parents=True)
dependency_file.write_text("module.exports = {}\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 _patch_plugin_runtime_settings(monkeypatch, settings) -> None:
"""以只读键值端口注入插件运行配置。"""
for target in (
"app.runtime.extensions.plugin.manager.get_runtime_setting",
"app.adapters.external.plugin.client.get_runtime_setting",
):
monkeypatch.setattr(
target,
lambda key, default=None: getattr(
settings,
key,
"v2" if key == "VERSION_FLAG" else default,
),
)
def _patch_package_runtime_settings(monkeypatch, settings) -> None:
"""为插件包文件适配器注入隔离路径配置。"""
monkeypatch.setattr(
"app.adapters.system.plugin.package.get_runtime_setting",
lambda key: getattr(settings, key),
)
def _configure_local_watcher(
monkeypatch,
tmp_path: Path,
repo_path: Path,
changes: set[tuple[Change, str]],
*,
dev: bool = True,
) -> None:
"""为单批次本地插件文件监测提供完整运行配置。"""
settings_stub = SimpleNamespace(
DEV=dev,
PLUGIN_AUTO_RELOAD=True,
PLUGIN_LOCAL_REPO_PATHS=str(repo_path),
ROOT_PATH=tmp_path,
TEMP_PATH=tmp_path / "temp",
CONFIG_PATH=tmp_path / "config",
VERSION_FLAG="v2",
)
_patch_plugin_runtime_settings(monkeypatch, settings_stub)
_patch_package_runtime_settings(monkeypatch, settings_stub)
monkeypatch.setattr("app.runtime.extensions.plugin.manager.watch", lambda *_args, **_kwargs: iter([changes]))
def _set_running_render_mode(
plugin_manager: PluginManager,
render_mode: str,
dist_path: str,
) -> None:
"""注册测试所需的运行态插件联邦渲染声明。"""
plugin_manager.running_plugins["DemoPlugin"] = SimpleNamespace(
get_render_mode=lambda: (render_mode, dist_path),
)
def _set_installed_plugins(monkeypatch, plugin_ids: list[str]) -> None:
"""注入本地同步测试所需的已安装插件读取端口。"""
storage = SimpleNamespace(
read=lambda key: plugin_ids
if key == SystemConfigKey.UserInstalledPlugins
else None,
write=lambda _key, _value: None,
)
monkeypatch.setattr(
"app.runtime.extensions.plugin.storage._plugin_storage",
storage,
)
class _FakeSchedulerBackend:
"""提供插件服务增删所需的最小 APScheduler 契约。"""
def __init__(self, job_ids: list[str]):
self.jobs = {job_id: {"id": job_id} for job_id in job_ids}
def get_jobs(self):
"""返回当前注册的 APScheduler job。"""
return [SimpleNamespace(id=job_id) for job_id in self.jobs]
def remove_job(self, job_id: str) -> None:
"""移除指定 APScheduler job。"""
self.jobs.pop(job_id)
def add_job(self, func, trigger, **kwargs) -> None:
"""记录并替换指定 APScheduler job。"""
self.jobs[kwargs["id"]] = {"func": func, "trigger": trigger, **kwargs}
def _build_scheduler_for_plugin_reload(jobs: dict, backend) -> Scheduler:
"""构造不启动后台线程的插件服务 Scheduler。"""
scheduler = object.__new__(Scheduler)
scheduler._lock = threading.RLock()
scheduler._jobs = jobs
scheduler._scheduler = backend
scheduler._lifecycle_state = "running"
scheduler._registry = ExecutionRegistry(scheduler._lock)
return scheduler
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"
settings_stub = SimpleNamespace(
DEV=True,
ROOT_PATH=tmp_path,
TEMP_PATH=tmp_path / "temp",
CONFIG_PATH=tmp_path / "config",
PLUGIN_LOCAL_REPO_PATHS=str(repo_path),
)
_patch_plugin_runtime_settings(monkeypatch, settings_stub)
_patch_package_runtime_settings(monkeypatch, settings_stub)
monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.10"))
_set_installed_plugins(monkeypatch, ["DemoPlugin"])
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")
assert (runtime_dir / "dist" / "assets" / "remoteEntry.js").is_file()
assert not (runtime_dir / "node_modules").exists()
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)
_patch_plugin_runtime_settings(
monkeypatch,
SimpleNamespace(
DEV=False,
ROOT_PATH=tmp_path,
PLUGIN_LOCAL_REPO_PATHS=str(repo_path),
),
)
monkeypatch.setattr(PluginMarketTransport, "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),
)
_patch_plugin_runtime_settings(monkeypatch, settings_stub)
monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.10"))
_set_installed_plugins(monkeypatch, ["DemoPlugin"])
assert not plugin_manager._sync_local_plugin_if_installed("DemoPlugin")
assert not runtime_dir.exists()
def test_local_federated_asset_batch_syncs_once_without_python_reload(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""同批联邦资产变化只同步一次运行副本,不触发 Python 热重载。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
source_dir = source_file.parent
chunk_file = source_dir / "dist" / "assets" / "chunk.js"
chunk_file.write_text("export const chunk = true\n", encoding="utf-8")
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{
(Change.modified, str(source_dir / "dist" / "assets" / "remoteEntry.js")),
(Change.modified, str(chunk_file)),
},
)
_set_running_render_mode(plugin_manager, "vue", "dist/assets")
monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11"))
_set_installed_plugins(monkeypatch, ["DemoPlugin"])
sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed)
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
assert sync_spy.call_count == 1
assert sync_spy.call_args.args[0] == "DemoPlugin"
assert (tmp_path / "app" / "plugins" / "demoplugin" / "dist" / "assets" / "chunk.js").is_file()
reload_spy.assert_not_called()
@pytest.mark.parametrize(
("render_mode", "dist_path"),
[
("schema", "dist/assets"),
("vue", "../assets"),
("vue", "dist/../assets"),
("vue", "dist\\assets"),
("vue", "/tmp/assets"),
],
)
def test_local_federated_asset_ignores_non_vue_or_unsafe_render_paths(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
render_mode: str,
dist_path: str,
) -> None:
"""非 Vue 模式和越出插件目录的声明路径不参与本地同步。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
remote_entry = source_file.parent / "dist" / "assets" / "remoteEntry.js"
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(remote_entry))},
)
_set_running_render_mode(plugin_manager, render_mode, dist_path)
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
def test_local_federated_asset_ignores_change_outside_declared_directory(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""声明目录外的普通文件变化不复制联邦构建产物。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(source_file.parent / "README.md"))},
)
_set_running_render_mode(plugin_manager, "vue", "dist/assets")
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
def test_local_federated_asset_requires_remote_entry(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""联邦入口文件不存在时不复制声明目录中的其它构建产物。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
source_dir = source_file.parent
remote_entry = source_dir / "dist" / "assets" / "remoteEntry.js"
remote_entry.unlink()
chunk_file = source_dir / "dist" / "assets" / "chunk.js"
chunk_file.write_text("export const chunk = true\n", encoding="utf-8")
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(chunk_file))},
)
_set_running_render_mode(plugin_manager, "vue", "dist/assets")
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
def test_local_federated_asset_reads_running_render_mode_for_each_batch(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""每批变化都从运行实例读取当前联邦目录,不缓存旧声明。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
source_dir = source_file.parent
next_entry = source_dir / "next" / "assets" / "remoteEntry.js"
next_entry.parent.mkdir(parents=True)
next_entry.write_text("export default {}\n", encoding="utf-8")
settings_stub = SimpleNamespace(
DEV=True,
PLUGIN_AUTO_RELOAD=True,
PLUGIN_LOCAL_REPO_PATHS=str(repo_path),
ROOT_PATH=tmp_path,
VERSION_FLAG="v2",
)
_patch_plugin_runtime_settings(monkeypatch, settings_stub)
monkeypatch.setattr(
"app.runtime.extensions.plugin.manager.watch",
lambda *_args, **_kwargs: iter([
{(Change.modified, str(source_dir / "dist" / "assets" / "remoteEntry.js"))},
{(Change.modified, str(next_entry))},
]),
)
render_mode = Mock(side_effect=[("vue", "dist/assets"), ("vue", "next/assets")])
plugin_manager.running_plugins["DemoPlugin"] = SimpleNamespace(get_render_mode=render_mode)
sync_spy = Mock(return_value=True)
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
assert render_mode.call_count == 2
assert sync_spy.call_count == 2
reload_spy.assert_not_called()
def test_local_federated_asset_respects_non_dev_compatibility_gate(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""非 DEV 自动监测不绕过本地插件的系统版本兼容性门禁。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
remote_entry = source_file.parent / "dist" / "assets" / "remoteEntry.js"
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(remote_entry))},
dev=False,
)
_set_running_render_mode(plugin_manager, "vue", "dist/assets")
monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.10"))
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
def test_runtime_federated_asset_change_does_not_copy_or_reload(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""运行目录中的联邦资产由构建方直接写入,不执行本地仓库复制或 Python 重载。"""
repo_path, _source_file = _build_local_plugin_repo(tmp_path)
runtime_dir = tmp_path / "app" / "plugins" / "demoplugin"
runtime_entry = runtime_dir / "dist" / "assets" / "remoteEntry.js"
runtime_entry.parent.mkdir(parents=True)
runtime_entry.write_text("export default {}\n", encoding="utf-8")
(runtime_dir / "__init__.py").write_text(
"from app.plugins import _PluginBase\n"
"class DemoPlugin(_PluginBase):\n"
" plugin_name = 'Demo'\n",
encoding="utf-8",
)
generated_python = runtime_entry.parent / "generated.py"
generated_python.write_text("ASSET = True\n", encoding="utf-8")
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(generated_python))},
)
_set_running_render_mode(plugin_manager, "vue", "dist/assets")
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
def test_local_requirements_change_still_does_not_sync_or_reload(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""依赖文件变化继续只提示重新安装,不触发自动同步或热重载。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
requirements_file = source_file.parent / "requirements.txt"
requirements_file.write_text("example==1.0.0\n", encoding="utf-8")
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(requirements_file))},
)
monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11"))
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
log = Mock()
monkeypatch.setattr("app.runtime.extensions.plugin.manager.logger", log)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
log.warning.assert_called_once()
def test_local_pyproject_change_prompts_reinstall_without_sync_or_reload(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""生效的现代依赖清单变化只提示重新安装。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
pyproject_file = source_file.parent / "pyproject.toml"
pyproject_file.write_text(
'[project]\ndependencies = ["example==1.0.0"]\n',
encoding="utf-8",
)
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(pyproject_file))},
)
monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11"))
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
log = Mock()
monkeypatch.setattr("app.runtime.extensions.plugin.manager.logger", log)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
log.warning.assert_called_once()
def test_local_inactive_requirements_change_is_debug_only(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""现代清单生效时,旧 requirements 变化不提示重新安装。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
(source_file.parent / "pyproject.toml").write_text(
'[project]\ndependencies = ["example==1.0.0"]\n',
encoding="utf-8",
)
requirements_file = source_file.parent / "requirements.txt"
requirements_file.write_text("legacy==1.0.0\n", encoding="utf-8")
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(requirements_file))},
)
monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11"))
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
log = Mock()
monkeypatch.setattr("app.runtime.extensions.plugin.manager.logger", log)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
log.warning.assert_not_called()
log.debug.assert_called_once()
def test_deleting_active_pyproject_prompts_for_requirements_takeover(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""删除现代清单后旧清单接管时必须提示重新安装。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
requirements_file = source_file.parent / "requirements.txt"
requirements_file.write_text("legacy==1.0.0\n", encoding="utf-8")
pyproject_file = source_file.parent / "pyproject.toml"
pyproject_file.write_text(
'[project]\nname = "demo"\nversion = "1.0.0"\n'
'dependencies = ["modern==2.0.0"]\n',
encoding="utf-8",
)
pyproject_file.unlink()
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.deleted, str(pyproject_file))},
)
monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11"))
log = Mock()
monkeypatch.setattr("app.runtime.extensions.plugin.manager.logger", log)
plugin_manager._run_file_watcher()
log.warning.assert_called_once()
log.debug.assert_not_called()
def test_deleting_only_active_requirements_prompts_reinstall(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""删除唯一生效的旧清单时必须提示依赖集合已变化。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
requirements_file = source_file.parent / "requirements.txt"
requirements_file.write_text("legacy==1.0.0\n", encoding="utf-8")
requirements_file.unlink()
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.deleted, str(requirements_file))},
)
monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11"))
log = Mock()
monkeypatch.setattr("app.runtime.extensions.plugin.manager.logger", log)
plugin_manager._run_file_watcher()
log.warning.assert_called_once()
log.debug.assert_not_called()
def test_deleting_inactive_requirements_is_debug_only(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""现代清单仍生效时,删除旧清单不得提示重新安装。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
(source_file.parent / "pyproject.toml").write_text(
'[project]\nname = "demo"\nversion = "1.0.0"\n'
'dependencies = ["modern==2.0.0"]\n',
encoding="utf-8",
)
requirements_file = source_file.parent / "requirements.txt"
requirements_file.write_text("legacy==1.0.0\n", encoding="utf-8")
requirements_file.unlink()
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.deleted, str(requirements_file))},
)
monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11"))
log = Mock()
monkeypatch.setattr("app.runtime.extensions.plugin.manager.logger", log)
plugin_manager._run_file_watcher()
log.warning.assert_not_called()
log.debug.assert_called_once()
def test_local_python_change_still_syncs_and_reloads_plugin(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""本地 Python 源码变化继续沿用同步后热重载语义。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(source_file))},
)
monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11"))
_set_installed_plugins(monkeypatch, ["DemoPlugin"])
sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed)
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
assert sync_spy.call_count == 1
reload_spy.assert_called_once_with("DemoPlugin")
@pytest.mark.parametrize("dist_path", [".", "./"])
def test_local_python_change_rejects_root_federated_path_and_still_reloads(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
dist_path: str,
) -> None:
"""插件根目录不能作为联邦输出目录Python 变化仍需同步并热重载。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(source_file))},
)
_set_running_render_mode(plugin_manager, "vue", dist_path)
monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11"))
_set_installed_plugins(monkeypatch, ["DemoPlugin"])
sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed)
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
assert plugin_manager._get_federated_plugin_change(source_file) is None
plugin_manager._run_file_watcher()
assert sync_spy.call_count == 1
reload_spy.assert_called_once_with("DemoPlugin")
def test_local_python_and_federated_changes_share_one_batch_sync(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""同批 Python 与联邦资产变化共用一次复制,并保留 Python 热重载。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
remote_entry = source_file.parent / "dist" / "assets" / "remoteEntry.js"
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{
(Change.modified, str(source_file)),
(Change.modified, str(remote_entry)),
},
)
_set_running_render_mode(plugin_manager, "vue", "dist/assets")
monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11"))
_set_installed_plugins(monkeypatch, ["DemoPlugin"])
sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed)
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
assert sync_spy.call_count == 1
reload_spy.assert_called_once_with("DemoPlugin")
def test_runtime_python_change_reloads_without_local_repository_sync(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""直接修改运行目录中的 Python 文件只重载当前载荷。"""
runtime_dir = tmp_path / "app" / "plugins" / "demoplugin"
runtime_file = runtime_dir / "__init__.py"
runtime_dir.mkdir(parents=True)
runtime_file.write_text(
"from app.plugins import _PluginBase\n"
"class DemoPlugin(_PluginBase):\n"
" plugin_name = 'Demo'\n",
encoding="utf-8",
)
_configure_local_watcher(
monkeypatch,
tmp_path,
tmp_path / "unused-local-repository",
{(Change.modified, str(runtime_file))},
)
plugin_manager._plugin_paths = PluginPathResolver(
runtime_root=tmp_path / "app" / "plugins",
running=lambda: plugin_manager.running_plugins,
system=get_plugin_system,
strict_system_version=lambda: False,
log=Mock(),
)
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "_reload_plugin_tree_from_monitor", reload_spy)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_called_once_with("DemoPlugin")
def test_plugin_reload_refreshes_scheduler_services_idempotently(monkeypatch):
"""插件重载事件必须按当前服务拓扑幂等刷新 Scheduler。"""
current_func = Mock()
plugin_manager = Mock()
plugin_manager.get_plugin_services.return_value = [
{
"id": "new",
"name": "新服务",
"func": current_func,
"trigger": "interval",
"kwargs": {"minutes": 5},
"func_kwargs": {"marker": "new"},
}
]
plugin_manager.get_plugin_attr.return_value = "测试插件"
monkeypatch.setattr(scheduler_reconcile, "get_plugin_manager", lambda: plugin_manager)
backend = _FakeSchedulerBackend(["DemoPlugin_old"])
scheduler = _build_scheduler_for_plugin_reload(
jobs={
"DemoPlugin_old": {
"func": Mock(),
"name": "旧服务",
"pid": "DemoPlugin",
}
},
backend=backend,
)
event = Event(EventType.PluginReload, {"plugin_id": "DemoPlugin"})
reload_handlers = {
item["handler_identifier"]
for item in eventmanager.visualize_handlers()
if item["event_type"] == EventType.PluginReload.value
and item["status"] == "enabled"
}
assert "app.scheduler.Scheduler.on_plugin_reload" in reload_handlers
scheduler.on_plugin_reload(event)
scheduler.on_plugin_reload(event)
assert set(scheduler._jobs) == {"DemoPlugin_new"}
service = scheduler._jobs["DemoPlugin_new"]
# 定时服务回调经 wrap_for_plugin_instance 包了一层日志实例上下文绑定,
# 不再是原始 callable 本身;用 __wrapped__ 核对包装前后是同一个函数,
# 并实测调用确实透传到原始 Mock。
assert service["func"].__wrapped__ is current_func
service["func"](marker="check")
current_func.assert_called_once_with(marker="check")
assert service["kwargs"] == {"marker": "new"}
assert set(backend.jobs) == {"DemoPlugin_new"}
registered_job = backend.jobs["DemoPlugin_new"]
assert registered_job["trigger"] == "interval"
assert registered_job["minutes"] == 5
assert registered_job["kwargs"] == {"job_id": "DemoPlugin_new"}