mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
feat(plugin): add virtual plugin instances
This commit is contained in:
+9
-2
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6306,
|
||||
"edge_sha256": "7ca2da728599965ef604010fdf8e8e03d2939c68f3f45eba5b93aff63c2838a4",
|
||||
"edge_count": 6313,
|
||||
"edge_sha256": "25292ce04e33206f713eb24cff2397931124fcb22f55ab0d78f94314581161d4",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -5482,6 +5482,8 @@
|
||||
"app.runtime.extensions.plugin.catalog -> app.schemas",
|
||||
"app.runtime.extensions.plugin.catalog -> app.schemas.plugin",
|
||||
"app.runtime.extensions.plugin.catalog -> app.schemas.types",
|
||||
"app.runtime.extensions.plugin.clone -> app.schemas",
|
||||
"app.runtime.extensions.plugin.clone -> app.schemas.plugin",
|
||||
"app.runtime.extensions.plugin.contracts -> app.foundation",
|
||||
"app.runtime.extensions.plugin.contracts -> app.foundation.reflection",
|
||||
"app.runtime.extensions.plugin.dependency -> app.runtime",
|
||||
@@ -5490,6 +5492,8 @@
|
||||
"app.runtime.extensions.plugin.dependency -> app.runtime.extensions.plugin.system",
|
||||
"app.runtime.extensions.plugin.lifecycle -> app.schemas",
|
||||
"app.runtime.extensions.plugin.lifecycle -> app.schemas.plugin",
|
||||
"app.runtime.extensions.plugin.loader -> app.schemas",
|
||||
"app.runtime.extensions.plugin.loader -> app.schemas.plugin",
|
||||
"app.runtime.extensions.plugin.metadata -> app.runtime",
|
||||
"app.runtime.extensions.plugin.metadata -> app.runtime.extensions",
|
||||
"app.runtime.extensions.plugin.metadata -> app.runtime.extensions.plugin",
|
||||
@@ -5509,6 +5513,9 @@
|
||||
"app.runtime.extensions.plugin.projection -> app.schemas.plugin",
|
||||
"app.runtime.extensions.plugin.registry -> app.schemas",
|
||||
"app.runtime.extensions.plugin.registry -> app.schemas.plugin",
|
||||
"app.runtime.extensions.plugin.storage -> app.schemas",
|
||||
"app.runtime.extensions.plugin.storage -> app.schemas.plugin",
|
||||
"app.runtime.extensions.plugin.storage -> app.schemas.types",
|
||||
"app.runtime.extensions.plugin.sync -> app.runtime",
|
||||
"app.runtime.extensions.plugin.sync -> app.runtime.extensions",
|
||||
"app.runtime.extensions.plugin.sync -> app.runtime.extensions.plugin",
|
||||
|
||||
+25
-14
@@ -2695,7 +2695,7 @@
|
||||
]
|
||||
},
|
||||
"get_api": {
|
||||
"file_count": 83,
|
||||
"file_count": 84,
|
||||
"files": [
|
||||
"plugins.v2/agentresourceofficer/__init__.py",
|
||||
"plugins.v2/agenttokens/__init__.py",
|
||||
@@ -2775,6 +2775,7 @@
|
||||
"plugins.v3/imdbsource/__init__.py",
|
||||
"plugins.v3/libraryscraper/__init__.py",
|
||||
"plugins.v3/maoyanrank/__init__.py",
|
||||
"plugins.v3/mediainforenameenhancer/__init__.py",
|
||||
"plugins.v3/mediaservermsg/__init__.py",
|
||||
"plugins.v3/neodbsync/__init__.py",
|
||||
"plugins.v3/personmeta/__init__.py",
|
||||
@@ -2787,7 +2788,7 @@
|
||||
"files": []
|
||||
},
|
||||
"get_command": {
|
||||
"file_count": 81,
|
||||
"file_count": 82,
|
||||
"files": [
|
||||
"plugins.v2/agentresourceofficer/__init__.py",
|
||||
"plugins.v2/agenttokens/__init__.py",
|
||||
@@ -2865,6 +2866,7 @@
|
||||
"plugins.v3/imdbsource/__init__.py",
|
||||
"plugins.v3/libraryscraper/__init__.py",
|
||||
"plugins.v3/maoyanrank/__init__.py",
|
||||
"plugins.v3/mediainforenameenhancer/__init__.py",
|
||||
"plugins.v3/mediaservermsg/__init__.py",
|
||||
"plugins.v3/neodbsync/__init__.py",
|
||||
"plugins.v3/personmeta/__init__.py",
|
||||
@@ -2888,7 +2890,7 @@
|
||||
]
|
||||
},
|
||||
"get_form": {
|
||||
"file_count": 83,
|
||||
"file_count": 84,
|
||||
"files": [
|
||||
"plugins.v2/agentresourceofficer/__init__.py",
|
||||
"plugins.v2/agenttokens/__init__.py",
|
||||
@@ -2968,6 +2970,7 @@
|
||||
"plugins.v3/imdbsource/__init__.py",
|
||||
"plugins.v3/libraryscraper/__init__.py",
|
||||
"plugins.v3/maoyanrank/__init__.py",
|
||||
"plugins.v3/mediainforenameenhancer/__init__.py",
|
||||
"plugins.v3/mediaservermsg/__init__.py",
|
||||
"plugins.v3/neodbsync/__init__.py",
|
||||
"plugins.v3/personmeta/__init__.py",
|
||||
@@ -2986,7 +2989,7 @@
|
||||
]
|
||||
},
|
||||
"get_page": {
|
||||
"file_count": 83,
|
||||
"file_count": 84,
|
||||
"files": [
|
||||
"plugins.v2/agentresourceofficer/__init__.py",
|
||||
"plugins.v2/agenttokens/__init__.py",
|
||||
@@ -3066,6 +3069,7 @@
|
||||
"plugins.v3/imdbsource/__init__.py",
|
||||
"plugins.v3/libraryscraper/__init__.py",
|
||||
"plugins.v3/maoyanrank/__init__.py",
|
||||
"plugins.v3/mediainforenameenhancer/__init__.py",
|
||||
"plugins.v3/mediaservermsg/__init__.py",
|
||||
"plugins.v3/neodbsync/__init__.py",
|
||||
"plugins.v3/personmeta/__init__.py",
|
||||
@@ -3148,7 +3152,7 @@
|
||||
"files": []
|
||||
},
|
||||
"get_state": {
|
||||
"file_count": 85,
|
||||
"file_count": 86,
|
||||
"files": [
|
||||
"plugins.v2/agentresourceofficer/__init__.py",
|
||||
"plugins.v2/agenttokens/__init__.py",
|
||||
@@ -3230,6 +3234,7 @@
|
||||
"plugins.v3/imdbsource/__init__.py",
|
||||
"plugins.v3/libraryscraper/__init__.py",
|
||||
"plugins.v3/maoyanrank/__init__.py",
|
||||
"plugins.v3/mediainforenameenhancer/__init__.py",
|
||||
"plugins.v3/mediaservermsg/__init__.py",
|
||||
"plugins.v3/neodbsync/__init__.py",
|
||||
"plugins.v3/personmeta/__init__.py",
|
||||
@@ -3238,7 +3243,7 @@
|
||||
]
|
||||
},
|
||||
"init_plugin": {
|
||||
"file_count": 83,
|
||||
"file_count": 84,
|
||||
"files": [
|
||||
"plugins.v2/agentresourceofficer/__init__.py",
|
||||
"plugins.v2/agenttokens/__init__.py",
|
||||
@@ -3318,6 +3323,7 @@
|
||||
"plugins.v3/imdbsource/__init__.py",
|
||||
"plugins.v3/libraryscraper/__init__.py",
|
||||
"plugins.v3/maoyanrank/__init__.py",
|
||||
"plugins.v3/mediainforenameenhancer/__init__.py",
|
||||
"plugins.v3/mediaservermsg/__init__.py",
|
||||
"plugins.v3/neodbsync/__init__.py",
|
||||
"plugins.v3/personmeta/__init__.py",
|
||||
@@ -3326,7 +3332,7 @@
|
||||
]
|
||||
},
|
||||
"stop_service": {
|
||||
"file_count": 83,
|
||||
"file_count": 84,
|
||||
"files": [
|
||||
"plugins.v2/agentresourceofficer/__init__.py",
|
||||
"plugins.v2/agenttokens/__init__.py",
|
||||
@@ -3406,6 +3412,7 @@
|
||||
"plugins.v3/imdbsource/__init__.py",
|
||||
"plugins.v3/libraryscraper/__init__.py",
|
||||
"plugins.v3/maoyanrank/__init__.py",
|
||||
"plugins.v3/mediainforenameenhancer/__init__.py",
|
||||
"plugins.v3/mediaservermsg/__init__.py",
|
||||
"plugins.v3/neodbsync/__init__.py",
|
||||
"plugins.v3/personmeta/__init__.py",
|
||||
@@ -4293,7 +4300,7 @@
|
||||
]
|
||||
},
|
||||
"app.plugins": {
|
||||
"file_count": 83,
|
||||
"file_count": 84,
|
||||
"files": [
|
||||
"plugins.v2/agentresourceofficer/__init__.py",
|
||||
"plugins.v2/agenttokens/__init__.py",
|
||||
@@ -4373,6 +4380,7 @@
|
||||
"plugins.v3/imdbsource/__init__.py",
|
||||
"plugins.v3/libraryscraper/__init__.py",
|
||||
"plugins.v3/maoyanrank/__init__.py",
|
||||
"plugins.v3/mediainforenameenhancer/__init__.py",
|
||||
"plugins.v3/mediaservermsg/__init__.py",
|
||||
"plugins.v3/neodbsync/__init__.py",
|
||||
"plugins.v3/personmeta/__init__.py",
|
||||
@@ -4541,7 +4549,7 @@
|
||||
]
|
||||
},
|
||||
"app.schemas.types": {
|
||||
"file_count": 66,
|
||||
"file_count": 67,
|
||||
"files": [
|
||||
"plugins.v2/agentresourceofficer/__init__.py",
|
||||
"plugins.v2/agenttokens/__init__.py",
|
||||
@@ -4604,6 +4612,7 @@
|
||||
"plugins.v3/historytov2/__init__.py",
|
||||
"plugins.v3/imdbsource/__init__.py",
|
||||
"plugins.v3/imdbsource/imdbhelper.py",
|
||||
"plugins.v3/mediainforenameenhancer/__init__.py",
|
||||
"plugins.v3/mediaservermsg/__init__.py",
|
||||
"plugins.v3/neodbsync/__init__.py",
|
||||
"plugins.v3/personmeta/__init__.py",
|
||||
@@ -4655,7 +4664,7 @@
|
||||
]
|
||||
},
|
||||
"app.sdk.events": {
|
||||
"file_count": 11,
|
||||
"file_count": 12,
|
||||
"files": [
|
||||
"plugins.v3/autoclean/__init__.py",
|
||||
"plugins.v3/bangumicoll/__init__.py",
|
||||
@@ -4664,6 +4673,7 @@
|
||||
"plugins.v3/downloadsitetag/__init__.py",
|
||||
"plugins.v3/episodegroupmeta/__init__.py",
|
||||
"plugins.v3/imdbsource/__init__.py",
|
||||
"plugins.v3/mediainforenameenhancer/__init__.py",
|
||||
"plugins.v3/mediaservermsg/__init__.py",
|
||||
"plugins.v3/neodbsync/__init__.py",
|
||||
"plugins.v3/personmeta/__init__.py",
|
||||
@@ -4671,7 +4681,7 @@
|
||||
]
|
||||
},
|
||||
"app.sdk.logging": {
|
||||
"file_count": 23,
|
||||
"file_count": 24,
|
||||
"files": [
|
||||
"plugins.v3/agentresourceofficer/__init__.py",
|
||||
"plugins.v3/agentresourceofficer/feishu_channel.py",
|
||||
@@ -4691,6 +4701,7 @@
|
||||
"plugins.v3/imdbsource/officialapi.py",
|
||||
"plugins.v3/libraryscraper/__init__.py",
|
||||
"plugins.v3/maoyanrank/__init__.py",
|
||||
"plugins.v3/mediainforenameenhancer/__init__.py",
|
||||
"plugins.v3/mediaservermsg/__init__.py",
|
||||
"plugins.v3/neodbsync/__init__.py",
|
||||
"plugins.v3/personmeta/__init__.py",
|
||||
@@ -4936,9 +4947,9 @@
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"head": "217c8d25ffe6ff0b3f6352c4278fd6896def442e",
|
||||
"python_file_count": 237,
|
||||
"source_sha256": "c3796565ff3fd49a8ff3f1e1373d9afab44fd7b98371e44f662c32739fe53d15"
|
||||
"head": "fd0363711dbba96e31a27ad56b2e87c3524096a4",
|
||||
"python_file_count": 238,
|
||||
"source_sha256": "f07ff2e8c95080cbbf6d229376464e61dc74061c1a8d371b5ea2cf00148413de"
|
||||
},
|
||||
"schema_version": 3,
|
||||
"scope": {
|
||||
|
||||
@@ -334,7 +334,7 @@ def test_install_plugin_runtime_reloads_in_threadpool() -> None:
|
||||
)
|
||||
assert len(calls) == 2
|
||||
assert calls[0][0] == "plugin"
|
||||
assert calls[0][1] == plugin_manager.reload_plugin
|
||||
assert calls[0][1] == plugin_manager.reload_plugin_tree
|
||||
assert calls[0][2] == ("DemoPlugin",)
|
||||
assert calls[0][3] == {}
|
||||
assert calls[1][0] == "plugin"
|
||||
|
||||
@@ -37,6 +37,8 @@ def test_installed_catalog_keeps_plugins_that_are_not_loaded():
|
||||
map_plugin=lambda **_kwargs: None,
|
||||
auth_checker=lambda **_kwargs: True,
|
||||
plugin_attr=lambda _plugin_id, _attr: None,
|
||||
plugin_instance=lambda _plugin_id: None,
|
||||
plugin_instances=lambda: {},
|
||||
runtime_status=statuses.get,
|
||||
log=SimpleNamespace(error=lambda *_args: None, info=lambda *_args: None),
|
||||
)
|
||||
|
||||
@@ -8,13 +8,15 @@ from app.api.endpoints.plugin import plugin_releases
|
||||
from app.api.endpoints.plugin import reset_plugin
|
||||
from app.api.endpoints.plugin import reload_plugin
|
||||
from app.api.endpoints.plugin import runtime_status
|
||||
from app.api.endpoints.plugin import plugin_static_file
|
||||
from app.api.endpoints.plugin import uninstall_plugin
|
||||
from app.api.endpoints.system import sync_plugin_market_from_wiki
|
||||
from app.application.plugin.config import PluginConfigCommand
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
from app.schemas.event import PluginDataResetEventData
|
||||
from app.schemas.plugin import PluginRuntimeStatus
|
||||
from app.schemas.types import ChainEventType
|
||||
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
|
||||
from app.schemas.types import ChainEventType, SystemConfigKey
|
||||
from app.foundation.singleton import Singleton
|
||||
|
||||
|
||||
@@ -518,11 +520,72 @@ def test_delete_plugin_config_can_force_delete_after_plugin_is_stopped():
|
||||
storage.delete.return_value = True
|
||||
with patch("app.runtime.extensions.plugin_manager.get_plugin_storage", return_value=storage):
|
||||
assert manager.delete_plugin_config("DemoPlugin", force=True) is True
|
||||
|
||||
storage.delete.assert_called_once_with("plugin.DemoPlugin")
|
||||
Singleton._instances.pop((PluginManager, (), frozenset()), None)
|
||||
|
||||
|
||||
def test_virtual_instance_static_file_reads_from_source_directory(tmp_path, monkeypatch):
|
||||
"""实例 URL 保持独立,但静态内容直接读取共享的源插件目录。"""
|
||||
source_file = tmp_path / "app/plugins/demoplugin/dist/remoteEntry.js"
|
||||
source_file.parent.mkdir(parents=True)
|
||||
source_file.write_text("export default 'shared'", encoding="utf-8")
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.get_plugin_source_id.return_value = "DemoPlugin"
|
||||
monkeypatch.setattr(plugin_endpoint, "PluginManager", lambda: plugin_manager)
|
||||
monkeypatch.setattr(
|
||||
plugin_endpoint,
|
||||
"settings",
|
||||
MagicMock(ROOT_PATH=tmp_path),
|
||||
)
|
||||
|
||||
response = asyncio.run(
|
||||
plugin_static_file("DemoPluginwork", "dist/remoteEntry.js", None)
|
||||
)
|
||||
|
||||
async def read_body() -> bytes:
|
||||
"""读取流式响应的全部测试内容。"""
|
||||
return b"".join([chunk async for chunk in response.body_iterator])
|
||||
|
||||
assert asyncio.run(read_body()) == b"export default 'shared'"
|
||||
assert response.media_type == "application/javascript"
|
||||
plugin_manager.get_plugin_source_id.assert_called_once_with("DemoPluginwork")
|
||||
|
||||
|
||||
def test_uninstall_virtual_instance_never_removes_source_package(monkeypatch):
|
||||
"""卸载虚拟实例只清理实例状态,不触碰源插件安装清单或目录。"""
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.get_plugin_instance.return_value = PluginInstance(
|
||||
instance_id="DemoPluginwork",
|
||||
source_plugin_id="DemoPlugin",
|
||||
)
|
||||
plugin_manager.get_plugin_source_instances.return_value = []
|
||||
config = MagicMock()
|
||||
config.get.return_value = ["DemoPlugin"]
|
||||
monkeypatch.setattr(plugin_endpoint, "PluginManager", lambda: plugin_manager)
|
||||
monkeypatch.setattr(plugin_endpoint, "get_configured_system_config", lambda: config)
|
||||
monkeypatch.setattr(plugin_endpoint, "remove_plugin_api", MagicMock())
|
||||
monkeypatch.setattr(plugin_endpoint, "remove_plugin_job", MagicMock())
|
||||
monkeypatch.setattr(plugin_endpoint, "remove_plugin_from_folders", MagicMock())
|
||||
|
||||
result = uninstall_plugin("DemoPluginwork", None)
|
||||
|
||||
assert result.success is True
|
||||
config.set.assert_called_once_with(
|
||||
SystemConfigKey.UserInstalledPlugins,
|
||||
["DemoPlugin"],
|
||||
)
|
||||
plugin_manager.delete_plugin_config.assert_called_once_with(
|
||||
"DemoPluginwork",
|
||||
force=True,
|
||||
)
|
||||
plugin_manager.delete_plugin_data.assert_called_once_with(
|
||||
"DemoPluginwork",
|
||||
force=True,
|
||||
)
|
||||
plugin_manager.delete_plugin_instance.assert_called_once_with("DemoPluginwork")
|
||||
plugin_manager.remove_plugin.assert_called_once_with("DemoPluginwork")
|
||||
|
||||
|
||||
def test_delete_plugin_data_can_force_delete_after_plugin_is_stopped():
|
||||
"""
|
||||
重置入口会先停止插件;插件数据删除不能依赖运行态注册仍存在。
|
||||
|
||||
@@ -172,6 +172,27 @@ def test_projection_builds_federation_and_auth_provider_entries():
|
||||
}]
|
||||
|
||||
|
||||
def test_projection_exposes_source_identity_for_virtual_frontend_instance():
|
||||
"""虚拟实例的联邦入口保留实例 URL,并补充共享源码身份。"""
|
||||
plugin = _Plugin(
|
||||
plugin_source_id="Demo",
|
||||
get_render_mode=lambda: ("vue", "dist/assets"),
|
||||
get_auth_providers=lambda: [{"id": "demo-login"}],
|
||||
)
|
||||
projection = PluginProjection(
|
||||
{"DemoWork": plugin},
|
||||
remote_entry_factory=lambda plugin_id, path: f"/{plugin_id}/{path}",
|
||||
)
|
||||
|
||||
assert projection.remotes()[0] == {
|
||||
"id": "DemoWork",
|
||||
"url": "/DemoWork/dist/assets",
|
||||
"name": "测试插件",
|
||||
"source_plugin_id": "Demo",
|
||||
}
|
||||
assert projection.auth_providers()[0]["remote"]["source_plugin_id"] == "Demo"
|
||||
|
||||
|
||||
def test_projection_normalizes_sidebar_and_dashboard_metadata():
|
||||
"""侧栏和仪表板元数据在投影层完成校验、排序与兼容默认值。"""
|
||||
plugin = _Plugin(
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""虚拟插件实例的持久化、加载和创建行为测试。"""
|
||||
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
from app.runtime.extensions.plugin.clone import PluginCloneService
|
||||
from app.runtime.extensions.plugin.loader import PluginLoader
|
||||
from app.runtime.extensions.plugin.storage import PluginInstanceStore, PluginStorage
|
||||
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
def _logger() -> SimpleNamespace:
|
||||
"""提供加载器测试所需的最小日志对象。"""
|
||||
return SimpleNamespace(
|
||||
debug=lambda *_args: None,
|
||||
info=lambda *_args: None,
|
||||
warning=lambda *_args: None,
|
||||
error=lambda *_args: None,
|
||||
)
|
||||
|
||||
|
||||
def test_instance_store_keeps_virtual_instances_out_of_installed_list():
|
||||
"""虚拟实例使用独立配置键,不改写存量物理插件安装清单。"""
|
||||
values = {SystemConfigKey.UserInstalledPlugins: ["DemoPlugin"]}
|
||||
storage = PluginStorage(
|
||||
read=values.get,
|
||||
write=lambda key, value: values.__setitem__(key, value),
|
||||
)
|
||||
store = PluginInstanceStore(storage=lambda: storage)
|
||||
|
||||
instance = PluginInstance(
|
||||
instance_id="DemoPluginWork",
|
||||
source_plugin_id="DemoPlugin",
|
||||
plugin_name="工作实例",
|
||||
)
|
||||
store.save(instance)
|
||||
|
||||
assert store.get("DemoPluginWork") == instance
|
||||
assert store.for_source("DemoPlugin") == [instance]
|
||||
assert values[SystemConfigKey.UserInstalledPlugins] == ["DemoPlugin"]
|
||||
assert store.delete("DemoPluginWork") is True
|
||||
assert store.all() == {}
|
||||
|
||||
|
||||
def test_loader_executes_each_instance_in_an_isolated_module_namespace(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""两个实例共享磁盘源码,但模块全局状态、类身份和相对导入互相隔离。"""
|
||||
source_dir = tmp_path / "demoplugin"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "state.py").write_text("state = []\n", encoding="utf-8")
|
||||
(source_dir / "__init__.py").write_text(
|
||||
"from app.plugins.demoplugin.state import state\n"
|
||||
"class DemoPlugin:\n"
|
||||
" plugin_name = 'Demo'\n"
|
||||
" plugin_desc = 'Source'\n"
|
||||
" plugin_icon = 'source.svg'\n"
|
||||
" plugin_config_prefix = 'demo_'\n"
|
||||
" def init_plugin(self, _config):\n"
|
||||
" state.append(self.__class__.__name__)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
loader = PluginLoader(
|
||||
plugins_root=tmp_path,
|
||||
import_preparer=lambda **_kwargs: None,
|
||||
import_scanner=lambda **_kwargs: None,
|
||||
log=_logger(),
|
||||
)
|
||||
validator = lambda candidate: hasattr(candidate, "init_plugin")
|
||||
import app.plugins as plugin_package
|
||||
|
||||
source_module = ModuleType("app.plugins.demoplugin")
|
||||
source_module.__path__ = [str(source_dir)]
|
||||
monkeypatch.setitem(sys.modules, "app.plugins.demoplugin", source_module)
|
||||
monkeypatch.setattr(plugin_package, "demoplugin", source_module, raising=False)
|
||||
|
||||
work_class = loader.load_instance(
|
||||
PluginInstance(
|
||||
instance_id="DemoPluginWork",
|
||||
source_plugin_id="DemoPlugin",
|
||||
plugin_name="工作实例",
|
||||
),
|
||||
validator,
|
||||
)[0]
|
||||
home_class = loader.load_instance(
|
||||
PluginInstance(
|
||||
instance_id="DemoPluginHome",
|
||||
source_plugin_id="DemoPlugin",
|
||||
plugin_desc="家庭实例",
|
||||
),
|
||||
validator,
|
||||
)[0]
|
||||
work_class().init_plugin({})
|
||||
home_class().init_plugin({})
|
||||
|
||||
assert work_class.__name__ == "DemoPluginWork"
|
||||
assert home_class.__name__ == "DemoPluginHome"
|
||||
assert work_class.__qualname__ == "DemoPlugin"
|
||||
assert work_class.__module__ == "app.plugins.demopluginwork"
|
||||
assert home_class.__module__ == "app.plugins.demopluginhome"
|
||||
assert work_class.plugin_source_id == "DemoPlugin"
|
||||
assert work_class.plugin_name == "工作实例"
|
||||
assert work_class.plugin_config_prefix == "demopluginwork_"
|
||||
assert work_class.__dict__["init_plugin"].__globals__["state"] == [
|
||||
"DemoPluginWork"
|
||||
]
|
||||
assert home_class.__dict__["init_plugin"].__globals__["state"] == [
|
||||
"DemoPluginHome"
|
||||
]
|
||||
assert sys.modules["app.plugins.demoplugin"] is source_module
|
||||
assert plugin_package.demoplugin is source_module
|
||||
|
||||
|
||||
def test_clone_service_persists_descriptor_without_copying_source_package():
|
||||
"""创建分身只写实例描述和隔离配置,并始终跟随源插件版本。"""
|
||||
instances: dict[str, PluginInstance] = {}
|
||||
configs = {"DemoPlugin": {"enable": True, "token": "secret"}}
|
||||
reloaded: list[str] = []
|
||||
removed: list[str] = []
|
||||
|
||||
class DemoPlugin:
|
||||
"""提供创建用例需要的最小源插件类。"""
|
||||
|
||||
service = PluginCloneService(
|
||||
plugin_class=lambda plugin_id: DemoPlugin if plugin_id == "DemoPlugin" else None,
|
||||
plugin_exists=lambda plugin_id: plugin_id in instances,
|
||||
source_plugin_id=lambda plugin_id: plugin_id,
|
||||
save_instance=lambda instance: instances.__setitem__(
|
||||
instance.instance_id,
|
||||
instance,
|
||||
),
|
||||
delete_instance=lambda plugin_id: instances.pop(plugin_id, None) is not None,
|
||||
read_config=lambda plugin_id: configs.get(plugin_id, {}),
|
||||
save_config=lambda plugin_id, config: not configs.__setitem__(plugin_id, config),
|
||||
delete_config=lambda plugin_id: configs.pop(plugin_id, None) is not None,
|
||||
reload_plugin=lambda plugin_id: (
|
||||
reloaded.append(plugin_id) or PluginRuntimeStatus.ACTIVE
|
||||
),
|
||||
remove_plugin=removed.append,
|
||||
log=_logger(),
|
||||
)
|
||||
|
||||
success, clone_id = service.clone(
|
||||
plugin_id="DemoPlugin",
|
||||
suffix="Work",
|
||||
name="工作实例",
|
||||
description="独立配置",
|
||||
version="9.9.9",
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert clone_id == "DemoPluginwork"
|
||||
assert instances[clone_id].source_plugin_id == "DemoPlugin"
|
||||
assert configs[clone_id] == {
|
||||
"enable": False,
|
||||
"enabled": False,
|
||||
"token": "secret",
|
||||
}
|
||||
assert reloaded == [clone_id]
|
||||
assert removed == []
|
||||
|
||||
|
||||
def test_clone_service_rolls_back_descriptor_and_config_after_load_failure():
|
||||
"""实例首次加载失败时不留下不可见描述或孤立配置。"""
|
||||
instances: dict[str, PluginInstance] = {}
|
||||
configs = {"DemoPlugin": {"enabled": True}}
|
||||
removed: list[str] = []
|
||||
|
||||
class DemoPlugin:
|
||||
"""提供失败回滚用例需要的最小源插件类。"""
|
||||
|
||||
service = PluginCloneService(
|
||||
plugin_class=lambda _plugin_id: DemoPlugin,
|
||||
plugin_exists=lambda plugin_id: plugin_id in instances,
|
||||
source_plugin_id=lambda plugin_id: plugin_id,
|
||||
save_instance=lambda instance: instances.__setitem__(
|
||||
instance.instance_id,
|
||||
instance,
|
||||
),
|
||||
delete_instance=lambda plugin_id: instances.pop(plugin_id, None) is not None,
|
||||
read_config=lambda plugin_id: configs.get(plugin_id, {}),
|
||||
save_config=lambda plugin_id, config: not configs.__setitem__(plugin_id, config),
|
||||
delete_config=lambda plugin_id: configs.pop(plugin_id, None) is not None,
|
||||
reload_plugin=lambda _plugin_id: PluginRuntimeStatus.LOAD_FAILED,
|
||||
remove_plugin=removed.append,
|
||||
log=_logger(),
|
||||
)
|
||||
|
||||
success, message = service.clone(
|
||||
plugin_id="DemoPlugin",
|
||||
suffix="Broken",
|
||||
name="失败实例",
|
||||
description="",
|
||||
)
|
||||
|
||||
assert success is False
|
||||
assert "加载失败" in message
|
||||
assert instances == {}
|
||||
assert configs == {"DemoPlugin": {"enabled": True}}
|
||||
assert removed == ["DemoPluginbroken"]
|
||||
Reference in New Issue
Block a user