mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
feat(plugin): add virtual plugin instances
This commit is contained in:
@@ -336,20 +336,26 @@ async def install_plugin_runtime(
|
||||
return None
|
||||
|
||||
async def reload_runtime(target_id: str) -> object:
|
||||
"""通过 Agent 阻塞任务适配器重建插件实例。"""
|
||||
"""通过 Agent 阻塞任务适配器重载源插件及其虚拟实例。"""
|
||||
return await run_agent_blocking(
|
||||
"plugin",
|
||||
plugin_manager.reload_plugin,
|
||||
plugin_manager.reload_plugin_tree,
|
||||
target_id,
|
||||
)
|
||||
|
||||
async def refresh_registrations(target_id: str) -> object:
|
||||
"""通过 Agent 阻塞任务适配器刷新服务、命令和动态路由。"""
|
||||
return await run_agent_blocking(
|
||||
"plugin",
|
||||
refresh_plugin_registrations,
|
||||
target_id,
|
||||
)
|
||||
"""通过 Agent 阻塞任务适配器刷新源插件及其虚拟实例注册。"""
|
||||
result = None
|
||||
reload_targets = list(
|
||||
plugin_manager.get_plugin_reload_targets(target_id)
|
||||
) or [target_id]
|
||||
for reload_target in reload_targets:
|
||||
result = await run_agent_blocking(
|
||||
"plugin",
|
||||
refresh_plugin_registrations,
|
||||
reload_target,
|
||||
)
|
||||
return result
|
||||
|
||||
with plugin_manager.suppress_plugin_monitor(plugin_id):
|
||||
result = await PluginInstallCommand(
|
||||
@@ -387,6 +393,13 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]:
|
||||
from app.application.plugin.routes import remove_plugin_api
|
||||
from app.application.scheduling import remove_plugin_job
|
||||
|
||||
plugin_manager = get_plugin_manager()
|
||||
virtual_instance = plugin_manager.get_plugin_instance(plugin_id)
|
||||
source_instances = plugin_manager.get_plugin_source_instances(plugin_id)
|
||||
if not virtual_instance and source_instances:
|
||||
instance_ids = "、".join(item.instance_id for item in source_instances)
|
||||
raise ValueError(f"请先卸载该插件的分身:{instance_ids}")
|
||||
|
||||
config_oper = SystemConfigOper()
|
||||
install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
if plugin_id in install_plugins:
|
||||
@@ -396,12 +409,15 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]:
|
||||
remove_plugin_api(plugin_id)
|
||||
remove_plugin_job(plugin_id)
|
||||
|
||||
plugin_manager = get_plugin_manager()
|
||||
plugin_class = plugin_manager.plugins.get(plugin_id)
|
||||
was_clone = bool(getattr(plugin_class, "is_clone", False))
|
||||
clone_files_removed = False
|
||||
|
||||
if was_clone:
|
||||
if virtual_instance:
|
||||
plugin_manager.delete_plugin_config(plugin_id, force=True)
|
||||
plugin_manager.delete_plugin_data(plugin_id, force=True)
|
||||
plugin_manager.delete_plugin_instance(plugin_id)
|
||||
elif was_clone:
|
||||
plugin_manager.delete_plugin_config(plugin_id)
|
||||
plugin_manager.delete_plugin_data(plugin_id)
|
||||
plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower()
|
||||
|
||||
+35
-15
@@ -13,6 +13,7 @@ from starlette.responses import StreamingResponse
|
||||
from app.schemas.common import JsonObject as _SchemaJsonObject
|
||||
from app.schemas.plugin import Plugin as _SchemaPlugin
|
||||
from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard
|
||||
from app.schemas.plugin import PluginCloneRequest as _SchemaPluginCloneRequest
|
||||
from app.schemas.plugin import PluginDashboardMetaItem as _SchemaPluginDashboardMetaItem
|
||||
from app.schemas.plugin import PluginFoldersData as _SchemaPluginFoldersData
|
||||
from app.schemas.plugin import PluginRating as _SchemaPluginRating
|
||||
@@ -558,12 +559,13 @@ async def install(
|
||||
)
|
||||
|
||||
async def reload_runtime(target_id: str) -> object:
|
||||
"""在线程池中重建插件实例并广播重载事件。"""
|
||||
return await run_in_threadpool(PluginManager().reload_plugin, target_id)
|
||||
"""在线程池中重建源插件及其全部虚拟实例。"""
|
||||
return await run_in_threadpool(PluginManager().reload_plugin_tree, target_id)
|
||||
|
||||
async def refresh_registrations(target_id: str) -> object:
|
||||
"""在线程池中刷新插件服务、命令和动态路由。"""
|
||||
return await run_in_threadpool(register_plugin, target_id)
|
||||
"""在线程池中刷新源插件及其虚拟实例的全部宿主注册。"""
|
||||
for reload_target in plugin_manager.get_plugin_reload_targets(target_id):
|
||||
await run_in_threadpool(register_plugin, reload_target)
|
||||
|
||||
command = PluginInstallCommand(
|
||||
installed_plugins_reader=lambda: get_configured_system_config().get(
|
||||
@@ -779,8 +781,12 @@ async def plugin_static_file(
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
|
||||
source_plugin_id = PluginManager().get_plugin_source_id(plugin_id)
|
||||
plugin_base_dir = (
|
||||
AsyncPath(settings.ROOT_PATH) / "app" / "plugins" / plugin_id.lower()
|
||||
AsyncPath(settings.ROOT_PATH)
|
||||
/ "app"
|
||||
/ "plugins"
|
||||
/ source_plugin_id.lower()
|
||||
)
|
||||
plugin_file_path = plugin_base_dir / filepath.lstrip("/")
|
||||
|
||||
@@ -940,7 +946,9 @@ async def update_folder_plugins(
|
||||
"/clone/{plugin_id}", summary="创建插件分身", response_model=_SchemaResponse[None]
|
||||
)
|
||||
def clone_plugin(
|
||||
plugin_id: str, clone_data: dict, _: ApiPrincipal = Depends(get_current_active_superuser)
|
||||
plugin_id: str,
|
||||
clone_data: _SchemaPluginCloneRequest,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
创建插件分身
|
||||
@@ -948,16 +956,16 @@ def clone_plugin(
|
||||
try:
|
||||
success, message = PluginManager().clone_plugin(
|
||||
plugin_id=plugin_id,
|
||||
suffix=clone_data.get("suffix", ""),
|
||||
name=clone_data.get("name", ""),
|
||||
description=clone_data.get("description", ""),
|
||||
version=clone_data.get("version", ""),
|
||||
icon=clone_data.get("icon", ""),
|
||||
suffix=clone_data.suffix,
|
||||
name=clone_data.name,
|
||||
description=clone_data.description,
|
||||
version=clone_data.version,
|
||||
icon=clone_data.icon,
|
||||
)
|
||||
|
||||
if success:
|
||||
# 注册插件服务
|
||||
reload_plugin(message)
|
||||
# 分身服务已完成运行态加载,此处只补齐宿主注册。
|
||||
register_plugin(message)
|
||||
# 将分身插件添加到原插件所在的文件夹中
|
||||
_add_clone_to_plugin_folder(plugin_id, message)
|
||||
return _SchemaResponse(success=True, message="插件分身创建成功")
|
||||
@@ -1003,6 +1011,15 @@ def uninstall_plugin(
|
||||
"""
|
||||
卸载插件
|
||||
"""
|
||||
plugin_manager = PluginManager()
|
||||
virtual_instance = plugin_manager.get_plugin_instance(plugin_id)
|
||||
source_instances = plugin_manager.get_plugin_source_instances(plugin_id)
|
||||
if not virtual_instance and source_instances:
|
||||
instance_ids = "、".join(item.instance_id for item in source_instances)
|
||||
return _SchemaResponse(
|
||||
success=False,
|
||||
message=f"请先卸载该插件的分身:{instance_ids}",
|
||||
)
|
||||
config_oper = get_configured_system_config()
|
||||
# 删除已安装信息
|
||||
install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
@@ -1016,9 +1033,12 @@ def uninstall_plugin(
|
||||
# 移除插件服务
|
||||
remove_plugin_job(plugin_id)
|
||||
# 判断是否为分身
|
||||
plugin_manager = PluginManager()
|
||||
plugin_class = plugin_manager.plugins.get(plugin_id)
|
||||
if getattr(plugin_class, "is_clone", False):
|
||||
if virtual_instance:
|
||||
plugin_manager.delete_plugin_config(plugin_id, force=True)
|
||||
plugin_manager.delete_plugin_data(plugin_id, force=True)
|
||||
plugin_manager.delete_plugin_instance(plugin_id)
|
||||
elif getattr(plugin_class, "is_clone", False):
|
||||
# 如果是分身插件,则删除分身数据和配置
|
||||
plugin_manager.delete_plugin_config(plugin_id)
|
||||
plugin_manager.delete_plugin_data(plugin_id)
|
||||
|
||||
@@ -839,6 +839,10 @@
|
||||
"source": "创建插件分身失败:{reason}",
|
||||
"target": "Failed to create plugin clone: {reason}"
|
||||
},
|
||||
{
|
||||
"source": "请先卸载该插件的分身:{instances}",
|
||||
"target": "Uninstall this plugin's instances first: {instances}"
|
||||
},
|
||||
{
|
||||
"source": "{domain} 站点己存在",
|
||||
"target": "Site {domain} already exists"
|
||||
|
||||
@@ -835,6 +835,10 @@
|
||||
"source": "创建插件分身失败:{reason}",
|
||||
"target": "建立插件分身失敗:{reason}"
|
||||
},
|
||||
{
|
||||
"source": "请先卸载该插件的分身:{instances}",
|
||||
"target": "請先解除安裝該插件的分身:{instances}"
|
||||
},
|
||||
{
|
||||
"source": "{domain} 站点己存在",
|
||||
"target": "{domain} 站點已存在"
|
||||
|
||||
@@ -45,9 +45,10 @@ class PluginAccessPolicy:
|
||||
and hasattr(plugin, "plugin_public_key")
|
||||
):
|
||||
plugin_id = (
|
||||
getattr(plugin, "id", None)
|
||||
getattr(plugin, "plugin_source_id", None)
|
||||
or getattr(plugin, "id", None)
|
||||
if not isinstance(plugin, type)
|
||||
else plugin.__name__
|
||||
else getattr(plugin, "plugin_source_id", None) or plugin.__name__
|
||||
)
|
||||
public_key = plugin.plugin_public_key
|
||||
if public_key and plugin_id:
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.runtime.config import settings
|
||||
from app.runtime.extensions.plugin.contracts import supports_plugin_hook
|
||||
from app.runtime.extensions.plugin.storage import PluginStorage
|
||||
from app.runtime.extensions.plugin.system import PluginSystemServices
|
||||
from app.schemas.plugin import Plugin, PluginRuntimeStatus
|
||||
from app.schemas.plugin import Plugin, PluginInstance, PluginRuntimeStatus
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ class PluginCatalogFacade:
|
||||
map_plugin: Callable[..., Optional[Plugin]],
|
||||
auth_checker: Callable[..., bool],
|
||||
plugin_attr: Callable[[str, str], Any],
|
||||
plugin_instance: Callable[[str], Optional[PluginInstance]],
|
||||
plugin_instances: Callable[[], dict[str, PluginInstance]],
|
||||
runtime_status: Callable[[str], Optional[PluginRuntimeStatus]],
|
||||
log: Any,
|
||||
) -> None:
|
||||
@@ -45,6 +47,8 @@ class PluginCatalogFacade:
|
||||
self._map_plugin = map_plugin
|
||||
self._auth_checker = auth_checker
|
||||
self._plugin_attr = plugin_attr
|
||||
self._plugin_instance = plugin_instance
|
||||
self._plugin_instances = plugin_instances
|
||||
self._runtime_status = runtime_status
|
||||
self._logger = log
|
||||
|
||||
@@ -64,10 +68,11 @@ class PluginCatalogFacade:
|
||||
|
||||
def local(self) -> list[Plugin]:
|
||||
"""把已加载插件投影为本地插件目录 DTO。"""
|
||||
installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
|
||||
installed = self._installed_ids()
|
||||
plugins: list[Plugin] = []
|
||||
for plugin_id, plugin_class in self._classes().items():
|
||||
plugin_instance = self._running().get(plugin_id)
|
||||
instance = self._plugin_instance(plugin_id)
|
||||
plugin = Plugin(
|
||||
id=plugin_id,
|
||||
installed=plugin_id in installed,
|
||||
@@ -84,6 +89,9 @@ class PluginCatalogFacade:
|
||||
plugin_order=getattr(plugin_class, "plugin_order", 0),
|
||||
has_update=False,
|
||||
is_local=True,
|
||||
source_plugin_id=getattr(plugin_class, "plugin_source_id", None),
|
||||
is_instance=instance is not None,
|
||||
instance_mode=instance.mode if instance else None,
|
||||
)
|
||||
if not self._auth_checker(plugin=plugin, source=plugin_class):
|
||||
continue
|
||||
@@ -93,7 +101,7 @@ class PluginCatalogFacade:
|
||||
|
||||
def installed(self) -> list[Plugin]:
|
||||
"""按安装清单投影插件,未加载项目仍返回可观察占位卡片。"""
|
||||
installed_ids = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
|
||||
installed_ids = self._installed_ids()
|
||||
local_by_id = {
|
||||
plugin.id: plugin
|
||||
for plugin in self.local()
|
||||
@@ -105,6 +113,7 @@ class PluginCatalogFacade:
|
||||
if plugin:
|
||||
result.append(plugin)
|
||||
continue
|
||||
instance = self._plugin_instance(plugin_id)
|
||||
result.append(Plugin(
|
||||
id=plugin_id,
|
||||
plugin_name=plugin_id,
|
||||
@@ -112,6 +121,11 @@ class PluginCatalogFacade:
|
||||
state=False,
|
||||
runtime_status=self._runtime_status(plugin_id),
|
||||
is_local=True,
|
||||
source_plugin_id=(
|
||||
instance.source_plugin_id if instance else None
|
||||
),
|
||||
is_instance=instance is not None,
|
||||
instance_mode=instance.mode if instance else None,
|
||||
))
|
||||
# 展示顺序由持久化安装清单保留,避免后台恢复或占位卡片出现后改变用户看到的位置。
|
||||
# 前端可用用户级 PluginOrder 覆盖,plugin_order 只用于运行期插件发现顺序。
|
||||
@@ -119,7 +133,7 @@ class PluginCatalogFacade:
|
||||
|
||||
def local_version(self, plugin_id: str) -> Optional[str]:
|
||||
"""读取指定已安装插件版本,不触发全量目录投影。"""
|
||||
installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
|
||||
installed = self._installed_ids()
|
||||
if plugin_id not in installed:
|
||||
return None
|
||||
plugin_class = self._classes().get(plugin_id)
|
||||
@@ -156,11 +170,20 @@ class PluginCatalogFacade:
|
||||
if not plugin_id:
|
||||
return False
|
||||
try:
|
||||
package_name = f"app.plugins.{plugin_id.lower()}"
|
||||
instance = self._plugin_instance(plugin_id)
|
||||
source_plugin_id = (
|
||||
instance.source_plugin_id if instance else plugin_id
|
||||
)
|
||||
package_name = f"app.plugins.{source_plugin_id.lower()}"
|
||||
spec = importlib.util.find_spec(package_name)
|
||||
if spec is None or spec.origin is None:
|
||||
return False
|
||||
local_version = self._plugin_attr(plugin_id, "plugin_version")
|
||||
if not local_version and instance:
|
||||
local_version = self._plugin_attr(
|
||||
instance.source_plugin_id,
|
||||
"plugin_version",
|
||||
)
|
||||
if not local_version:
|
||||
return False
|
||||
if version and not compare_version(local_version, ">=", version):
|
||||
@@ -174,6 +197,16 @@ class PluginCatalogFacade:
|
||||
self._logger.debug(f"获取插件是否在本地包中存在失败,{error}")
|
||||
return False
|
||||
|
||||
def _installed_ids(self) -> list[str]:
|
||||
"""合并物理安装清单和虚拟实例清单并保持各自持久化顺序。"""
|
||||
installed = list(
|
||||
self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
|
||||
)
|
||||
for instance_id in self._plugin_instances():
|
||||
if instance_id not in installed:
|
||||
installed.append(instance_id)
|
||||
return installed
|
||||
|
||||
def get_from_market(
|
||||
self,
|
||||
market: str,
|
||||
|
||||
@@ -5,36 +5,38 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
|
||||
|
||||
|
||||
class PluginCloneService:
|
||||
"""协调插件包复制、安装清单、配置复制和运行态刷新。"""
|
||||
"""创建共享源码的虚拟插件实例并协调失败回滚。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
plugin_class: Callable[[str], Optional[Any]],
|
||||
plugin_exists: Callable[[str], bool],
|
||||
package_clone: Callable[..., tuple[bool, str]],
|
||||
installed_plugins: Callable[[], list[str]],
|
||||
save_installed_plugins: Callable[[list[str]], Any],
|
||||
source_plugin_id: Callable[[str], str],
|
||||
save_instance: Callable[[PluginInstance], Any],
|
||||
delete_instance: Callable[[str], bool],
|
||||
read_config: Callable[[str], dict],
|
||||
save_config: Callable[[str, dict], bool],
|
||||
delete_config: Callable[[str], bool],
|
||||
reload_plugin: Callable[[str], Any],
|
||||
running_plugin: Callable[[str], Optional[Any]],
|
||||
initialize_plugin: Callable[[str, dict], Any],
|
||||
remove_plugin: Callable[[str], Any],
|
||||
log: Any,
|
||||
) -> None:
|
||||
"""保存包、持久化和运行态端口。"""
|
||||
"""保存实例描述、持久化和运行态端口。"""
|
||||
self._plugin_class = plugin_class
|
||||
self._plugin_exists = plugin_exists
|
||||
self._package_clone = package_clone
|
||||
self._installed_plugins = installed_plugins
|
||||
self._save_installed_plugins = save_installed_plugins
|
||||
self._source_plugin_id = source_plugin_id
|
||||
self._save_instance = save_instance
|
||||
self._delete_instance = delete_instance
|
||||
self._read_config = read_config
|
||||
self._save_config = save_config
|
||||
self._delete_config = delete_config
|
||||
self._reload_plugin = reload_plugin
|
||||
self._running_plugin = running_plugin
|
||||
self._initialize_plugin = initialize_plugin
|
||||
self._remove_plugin = remove_plugin
|
||||
self._logger = log
|
||||
|
||||
def clone(
|
||||
@@ -47,11 +49,10 @@ class PluginCloneService:
|
||||
version: Optional[str] = None,
|
||||
icon: Optional[str] = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""创建插件分身并保持原有默认禁用配置语义。"""
|
||||
"""创建虚拟分身,复制隔离配置并保持默认禁用语义。"""
|
||||
if not plugin_id or not suffix:
|
||||
return False, "插件ID和分身后缀不能为空"
|
||||
original_class = self._plugin_class(plugin_id)
|
||||
if original_class is None:
|
||||
if self._plugin_class(plugin_id) is None:
|
||||
return False, f"原插件 {plugin_id} 不存在"
|
||||
|
||||
clone_id = f"{plugin_id}{suffix.lower()}"
|
||||
@@ -59,38 +60,44 @@ class PluginCloneService:
|
||||
return False, f"分身插件 {clone_id} 已存在"
|
||||
|
||||
try:
|
||||
success, message = self._package_clone(
|
||||
plugin_id=plugin_id,
|
||||
clone_id=clone_id,
|
||||
original_class_name=original_class.__name__,
|
||||
suffix=suffix.lower(),
|
||||
name=name,
|
||||
description=description,
|
||||
version=version,
|
||||
icon=icon,
|
||||
instance = PluginInstance(
|
||||
instance_id=clone_id,
|
||||
source_plugin_id=self._source_plugin_id(plugin_id),
|
||||
plugin_name=name or None,
|
||||
plugin_desc=description or None,
|
||||
plugin_icon=icon or None,
|
||||
)
|
||||
if not success:
|
||||
return False, message
|
||||
|
||||
installed = list(self._installed_plugins())
|
||||
if clone_id not in installed:
|
||||
installed.append(clone_id)
|
||||
self._save_installed_plugins(installed)
|
||||
self._save_instance(instance)
|
||||
|
||||
original_config = self._read_config(plugin_id)
|
||||
if original_config:
|
||||
clone_config = dict(original_config)
|
||||
clone_config["enable"] = False
|
||||
clone_config["enabled"] = False
|
||||
self._save_config(clone_id, clone_config)
|
||||
if not self._save_config(clone_id, clone_config):
|
||||
raise RuntimeError("虚拟实例配置保存失败")
|
||||
|
||||
self._reload_plugin(clone_id)
|
||||
clone_instance = self._running_plugin(clone_id)
|
||||
clone_config = self._read_config(clone_id)
|
||||
if clone_instance and clone_config:
|
||||
self._initialize_plugin(clone_id, clone_config)
|
||||
status = self._reload_plugin(clone_id)
|
||||
if status is PluginRuntimeStatus.LOAD_FAILED:
|
||||
raise RuntimeError("虚拟实例加载失败")
|
||||
self._logger.info(f"插件分身 {clone_id} 创建成功")
|
||||
return True, clone_id
|
||||
except Exception as error: # noqa: BLE001
|
||||
self._rollback(clone_id)
|
||||
self._logger.error(f"创建插件分身失败:{error}")
|
||||
return False, f"创建插件分身失败:{error}"
|
||||
|
||||
def _rollback(self, clone_id: str) -> None:
|
||||
"""逐项清理失败实例,单个清理错误不得阻断其余回滚。"""
|
||||
rollback_steps = (
|
||||
("运行态", self._remove_plugin),
|
||||
("实例描述", self._delete_instance),
|
||||
("配置", self._delete_config),
|
||||
)
|
||||
for label, rollback in rollback_steps:
|
||||
try:
|
||||
rollback(clone_id)
|
||||
except Exception as rollback_error: # noqa: BLE001
|
||||
self._logger.warning(
|
||||
f"回滚插件分身 {clone_id} 的{label}失败:{rollback_error}"
|
||||
)
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.schemas.plugin import PluginInstance
|
||||
|
||||
|
||||
PluginImportPreparer = Callable[..., None]
|
||||
PluginImportScanner = Callable[..., None]
|
||||
@@ -18,6 +22,8 @@ PluginValidator = Callable[[Any], bool]
|
||||
class PluginLoader:
|
||||
"""只负责从运行目录发现插件类,并维护对应模块缓存。"""
|
||||
|
||||
_instance_import_lock = threading.RLock()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -96,6 +102,165 @@ class PluginLoader:
|
||||
)
|
||||
return plugins
|
||||
|
||||
def load_instance(
|
||||
self,
|
||||
instance: PluginInstance,
|
||||
validator: PluginValidator,
|
||||
) -> list[Any]:
|
||||
"""在实例专属模块命名空间中重新执行源插件代码并返回适配类。"""
|
||||
source_dir = self._plugins_root / instance.source_plugin_id.lower()
|
||||
source_file = source_dir / "__init__.py"
|
||||
if not source_file.exists():
|
||||
self._logger.warning(
|
||||
f"虚拟插件实例 {instance.instance_id} 的源码不存在:{source_dir}"
|
||||
)
|
||||
return []
|
||||
|
||||
module_name = f"app.plugins.{instance.instance_id.lower()}"
|
||||
self.clear_modules(instance.instance_id)
|
||||
try:
|
||||
self._import_preparer(
|
||||
plugin_id=instance.source_plugin_id.lower(),
|
||||
plugin_dir=source_dir,
|
||||
)
|
||||
self._import_scanner(
|
||||
plugin_id=instance.source_plugin_id.lower(),
|
||||
plugin_dir=source_dir,
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name,
|
||||
source_file,
|
||||
submodule_search_locations=[str(source_dir)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"无法创建模块规格:{module_name}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
self._execute_instance_module(
|
||||
module=module,
|
||||
module_name=module_name,
|
||||
source_module_name=(
|
||||
f"app.plugins.{instance.source_plugin_id.lower()}"
|
||||
),
|
||||
loader=spec.loader,
|
||||
)
|
||||
for name, candidate in module.__dict__.items():
|
||||
if name.startswith("_") or not isinstance(candidate, type):
|
||||
continue
|
||||
if not validator(candidate):
|
||||
continue
|
||||
self._adapt_instance_class(candidate, instance)
|
||||
self._logger.debug(
|
||||
f"从 {instance.source_plugin_id} 加载虚拟插件实例:{instance.instance_id}"
|
||||
)
|
||||
return [candidate]
|
||||
except Exception as error: # noqa: BLE001
|
||||
self.clear_modules(instance.instance_id)
|
||||
self._logger.error(
|
||||
f"加载虚拟插件实例 {instance.instance_id} 失败:{error} - "
|
||||
f"{traceback.format_exc()}"
|
||||
)
|
||||
return []
|
||||
|
||||
def _execute_instance_module(
|
||||
self,
|
||||
*,
|
||||
module: Any,
|
||||
module_name: str,
|
||||
source_module_name: str,
|
||||
loader: Any,
|
||||
) -> None:
|
||||
"""执行实例模块,并把旧式自身绝对导入迁移到实例命名空间。"""
|
||||
source_prefix = f"{source_module_name}."
|
||||
parent_module = sys.modules.get("app.plugins")
|
||||
source_attribute = source_module_name.rsplit(".", 1)[-1]
|
||||
missing = object()
|
||||
with self._instance_import_lock:
|
||||
source_snapshot = {
|
||||
name: loaded_module
|
||||
for name, loaded_module in list(sys.modules.items())
|
||||
if name == source_module_name or name.startswith(source_prefix)
|
||||
}
|
||||
parent_snapshot = (
|
||||
getattr(parent_module, source_attribute, missing)
|
||||
if parent_module
|
||||
else missing
|
||||
)
|
||||
for name in source_snapshot:
|
||||
sys.modules.pop(name, None)
|
||||
sys.modules[module_name] = module
|
||||
# 兼容旧插件在包内仍写 app.plugins.<source> 的绝对导入。
|
||||
sys.modules[source_module_name] = module
|
||||
if parent_module:
|
||||
setattr(parent_module, source_attribute, module)
|
||||
captured: dict[str, Any] = {}
|
||||
try:
|
||||
loader.exec_module(module)
|
||||
captured = {
|
||||
name: loaded_module
|
||||
for name, loaded_module in list(sys.modules.items())
|
||||
if name == source_module_name or name.startswith(source_prefix)
|
||||
}
|
||||
finally:
|
||||
for name in list(sys.modules):
|
||||
if name == source_module_name or name.startswith(source_prefix):
|
||||
sys.modules.pop(name, None)
|
||||
sys.modules.update(source_snapshot)
|
||||
if parent_module:
|
||||
if parent_snapshot is missing:
|
||||
try:
|
||||
delattr(parent_module, source_attribute)
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
setattr(parent_module, source_attribute, parent_snapshot)
|
||||
|
||||
for source_name, loaded_module in captured.items():
|
||||
suffix = source_name[len(source_module_name):]
|
||||
instance_name = f"{module_name}{suffix}"
|
||||
sys.modules[instance_name] = loaded_module
|
||||
self._retarget_module_identity(
|
||||
loaded_module,
|
||||
source_name,
|
||||
instance_name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _retarget_module_identity(
|
||||
module: Any,
|
||||
source_name: str,
|
||||
instance_name: str,
|
||||
) -> None:
|
||||
"""修正被旧绝对路径加载对象的模块身份,避免事件与诊断键冲突。"""
|
||||
if getattr(module, "__name__", None) == source_name:
|
||||
module.__name__ = instance_name
|
||||
package_name = getattr(module, "__package__", None)
|
||||
if isinstance(package_name, str) and package_name.startswith(source_name):
|
||||
module.__package__ = instance_name + package_name[len(source_name):]
|
||||
spec = getattr(module, "__spec__", None)
|
||||
if spec and getattr(spec, "name", None) == source_name:
|
||||
spec.name = instance_name
|
||||
for value in vars(module).values():
|
||||
if getattr(value, "__module__", None) == source_name:
|
||||
try:
|
||||
value.__module__ = instance_name
|
||||
except (AttributeError, TypeError):
|
||||
continue
|
||||
|
||||
@staticmethod
|
||||
def _adapt_instance_class(candidate: Any, instance: PluginInstance) -> None:
|
||||
"""只改运行身份与展示元数据,不改源码、限定名和联邦产物。"""
|
||||
candidate.__name__ = instance.instance_id
|
||||
candidate.plugin_instance_id = instance.instance_id
|
||||
candidate.plugin_source_id = instance.source_plugin_id
|
||||
candidate.is_clone = True
|
||||
candidate.plugin_config_prefix = f"{instance.instance_id.lower()}_"
|
||||
if instance.plugin_name:
|
||||
candidate.plugin_name = instance.plugin_name
|
||||
if instance.plugin_desc:
|
||||
candidate.plugin_desc = instance.plugin_desc
|
||||
if instance.plugin_icon:
|
||||
candidate.plugin_icon = instance.plugin_icon
|
||||
|
||||
def clear_modules(self, plugin_id: Optional[str] = None) -> list[str]:
|
||||
"""清除指定插件或全部插件的 Python 模块缓存。"""
|
||||
prefix = (
|
||||
|
||||
@@ -150,11 +150,15 @@ class PluginProjection:
|
||||
continue
|
||||
if not self._remote_entry_factory:
|
||||
raise RuntimeError("插件联邦入口生成器尚未配置")
|
||||
remotes.append({
|
||||
remote = {
|
||||
"id": plugin_id,
|
||||
"url": self._remote_entry_factory(plugin_id, dist_path),
|
||||
"name": plugin.plugin_name,
|
||||
})
|
||||
}
|
||||
source_plugin_id = getattr(plugin, "plugin_source_id", None)
|
||||
if source_plugin_id:
|
||||
remote["source_plugin_id"] = source_plugin_id
|
||||
remotes.append(remote)
|
||||
return remotes
|
||||
|
||||
def auth_providers(self) -> List[Dict[str, Any]]:
|
||||
@@ -189,11 +193,15 @@ class PluginProjection:
|
||||
if not self._remote_entry_factory:
|
||||
raise RuntimeError("插件联邦入口生成器尚未配置")
|
||||
provider.setdefault("component", "AuthPage")
|
||||
provider["remote"] = {
|
||||
remote = {
|
||||
"id": plugin_id,
|
||||
"url": self._remote_entry_factory(plugin_id, dist_path),
|
||||
"name": plugin.plugin_name,
|
||||
}
|
||||
source_plugin_id = getattr(plugin, "plugin_source_id", None)
|
||||
if source_plugin_id:
|
||||
remote["source_plugin_id"] = source_plugin_id
|
||||
provider["remote"] = remote
|
||||
providers.append(provider)
|
||||
return providers
|
||||
|
||||
@@ -335,4 +343,9 @@ class PluginProjection:
|
||||
cols=cols or {},
|
||||
attrs=attrs or {},
|
||||
elements=elements,
|
||||
source_plugin_id=getattr(plugin, "plugin_source_id", None),
|
||||
is_instance=bool(getattr(plugin, "plugin_source_id", None)),
|
||||
instance_mode=(
|
||||
"virtual" if getattr(plugin, "plugin_source_id", None) else None
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,11 @@ from __future__ import annotations
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.plugin import PluginInstance
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
ConfigReader = Callable[[Any], Any]
|
||||
ConfigWriter = Callable[[Any, Any], Any]
|
||||
@@ -139,6 +144,74 @@ class PluginConfigStore:
|
||||
return True
|
||||
|
||||
|
||||
class PluginInstanceStore:
|
||||
"""管理虚拟插件实例描述,并隔离兼容清单与新实例清单。"""
|
||||
|
||||
def __init__(self, *, storage: Callable[[], "PluginStorage"]) -> None:
|
||||
"""保存延迟解析的持久化端口,便于启动组合根后装配。"""
|
||||
self._storage = storage
|
||||
|
||||
def all(self) -> dict[str, PluginInstance]:
|
||||
"""读取全部有效实例,忽略损坏项以免阻断存量插件启动。"""
|
||||
raw_instances = self._storage().read(SystemConfigKey.PluginInstances) or {}
|
||||
if isinstance(raw_instances, list):
|
||||
entries = {
|
||||
item.get("instance_id"): item
|
||||
for item in raw_instances
|
||||
if isinstance(item, dict) and item.get("instance_id")
|
||||
}
|
||||
elif isinstance(raw_instances, dict):
|
||||
entries = raw_instances
|
||||
else:
|
||||
return {}
|
||||
|
||||
instances: dict[str, PluginInstance] = {}
|
||||
for instance_id, raw_instance in entries.items():
|
||||
try:
|
||||
payload = dict(raw_instance) if isinstance(raw_instance, dict) else {}
|
||||
payload.setdefault("instance_id", instance_id)
|
||||
instance = PluginInstance.model_validate(payload)
|
||||
instances[instance.instance_id] = instance
|
||||
except (TypeError, ValidationError):
|
||||
continue
|
||||
return instances
|
||||
|
||||
def get(self, instance_id: str) -> PluginInstance | None:
|
||||
"""读取指定实例描述。"""
|
||||
return self.all().get(instance_id)
|
||||
|
||||
def save(self, instance: PluginInstance) -> None:
|
||||
"""新增或更新实例描述,并以实例 ID 作为稳定持久化键。"""
|
||||
instances = self.all()
|
||||
instances[instance.instance_id] = instance
|
||||
self._write(instances)
|
||||
|
||||
def delete(self, instance_id: str) -> bool:
|
||||
"""删除指定实例描述,返回删除前是否存在。"""
|
||||
instances = self.all()
|
||||
removed = instances.pop(instance_id, None)
|
||||
if removed is None:
|
||||
return False
|
||||
self._write(instances)
|
||||
return True
|
||||
|
||||
def for_source(self, source_plugin_id: str) -> list[PluginInstance]:
|
||||
"""按持久化顺序返回引用同一源码插件的全部实例。"""
|
||||
return [
|
||||
instance
|
||||
for instance in self.all().values()
|
||||
if instance.source_plugin_id == source_plugin_id
|
||||
]
|
||||
|
||||
def _write(self, instances: dict[str, PluginInstance]) -> None:
|
||||
"""把模型映射序列化为普通字典,避免存储层依赖 Pydantic。"""
|
||||
payload = {
|
||||
instance_id: instance.model_dump(mode="json")
|
||||
for instance_id, instance in instances.items()
|
||||
}
|
||||
self._storage().write(SystemConfigKey.PluginInstances, payload)
|
||||
|
||||
|
||||
_plugin_storage = PluginStorage()
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from watchfiles import watch
|
||||
|
||||
from app.schemas.plugin import Plugin as _SchemaPlugin
|
||||
from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard
|
||||
from app.schemas.plugin import PluginRuntimeStatus
|
||||
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
|
||||
from app.foundation.crypto import RSAUtils
|
||||
from app.foundation.singleton import Singleton
|
||||
from app.foundation.version import compare_version
|
||||
@@ -42,7 +42,7 @@ from app.runtime.extensions.plugin.dependency import (
|
||||
PluginDependencyInstallResult,
|
||||
PluginDependencyService,
|
||||
)
|
||||
from app.runtime.extensions.plugin.storage import PluginConfigStore
|
||||
from app.runtime.extensions.plugin.storage import PluginConfigStore, PluginInstanceStore
|
||||
from app.schemas.types import EventType, SystemConfigKey
|
||||
|
||||
LegacyDiagnosticsConfigurator = Callable[..., None]
|
||||
@@ -131,6 +131,9 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
# 旧属性继续引用注册表拥有的可变字典,保持插件和测试的访问身份。
|
||||
self._plugins = self._plugin_registry.classes
|
||||
self._running_plugins = self._plugin_registry.running
|
||||
self._plugin_instance_store = PluginInstanceStore(
|
||||
storage=lambda: get_plugin_storage(),
|
||||
)
|
||||
# 配置Key
|
||||
self._config_key: str = "plugin.%s"
|
||||
self._plugin_config_store = PluginConfigStore(
|
||||
@@ -158,6 +161,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
map_plugin=lambda **kwargs: self._process_plugin_info(**kwargs),
|
||||
auth_checker=lambda **kwargs: self.__set_and_check_auth_level(**kwargs),
|
||||
plugin_attr=lambda pid, attr: self.get_plugin_attr(pid, attr),
|
||||
plugin_instance=self.get_plugin_instance,
|
||||
plugin_instances=self.get_plugin_instances,
|
||||
runtime_status=self._plugin_registry.runtime_status,
|
||||
log=logger,
|
||||
)
|
||||
@@ -201,7 +206,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
self._plugin_lifecycle = PluginLifecycle(
|
||||
classes=self._plugins,
|
||||
running=self._running_plugins,
|
||||
load_plugins=lambda pid, installed, check: self._load_selective_plugins(
|
||||
load_plugins=lambda pid, installed, check: self._load_runtime_plugins(
|
||||
pid,
|
||||
installed,
|
||||
check,
|
||||
@@ -262,23 +267,21 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
self._plugin_clone = PluginCloneService(
|
||||
plugin_class=self._plugin_registry.plugin_class,
|
||||
plugin_exists=lambda plugin_id: self.is_plugin_exists(plugin_id),
|
||||
package_clone=lambda **kwargs: get_plugin_system().package.clone(**kwargs),
|
||||
installed_plugins=lambda: get_plugin_storage().read(
|
||||
SystemConfigKey.UserInstalledPlugins
|
||||
) or [],
|
||||
save_installed_plugins=lambda plugins: get_plugin_storage().write(
|
||||
SystemConfigKey.UserInstalledPlugins,
|
||||
plugins,
|
||||
),
|
||||
source_plugin_id=self.get_plugin_source_id,
|
||||
save_instance=self._plugin_instance_store.save,
|
||||
delete_instance=self._plugin_instance_store.delete,
|
||||
read_config=self.get_plugin_config,
|
||||
save_config=lambda plugin_id, config: self.save_plugin_config(
|
||||
plugin_id,
|
||||
config,
|
||||
force=True,
|
||||
),
|
||||
delete_config=lambda plugin_id: self.delete_plugin_config(
|
||||
plugin_id,
|
||||
force=True,
|
||||
),
|
||||
reload_plugin=self.reload_plugin,
|
||||
running_plugin=self._plugin_registry.instance,
|
||||
initialize_plugin=self.init_plugin,
|
||||
remove_plugin=self.remove_plugin,
|
||||
log=logger,
|
||||
)
|
||||
# 事件总线只通过通用解析器访问运行中的插件实例。
|
||||
@@ -372,6 +375,30 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
log=logger,
|
||||
).load(pid, installed_plugins, check_module_func)
|
||||
|
||||
def _load_runtime_plugins(
|
||||
self,
|
||||
pid: Optional[str],
|
||||
installed_plugins: List[str],
|
||||
check_module_func: Callable[[Any], bool],
|
||||
) -> List[Any]:
|
||||
"""加载物理插件或虚拟实例,并保留旧选择性加载方法的合同。"""
|
||||
if pid:
|
||||
instance = self._plugin_instance_store.get(pid)
|
||||
if instance:
|
||||
return self._plugin_loader.load_instance(instance, check_module_func)
|
||||
return self._plugin_loader.load(pid, installed_plugins, check_module_func)
|
||||
|
||||
plugins = self._plugin_loader.load(
|
||||
None,
|
||||
installed_plugins,
|
||||
check_module_func,
|
||||
)
|
||||
for instance in self._plugin_instance_store.all().values():
|
||||
plugins.extend(
|
||||
self._plugin_loader.load_instance(instance, check_module_func)
|
||||
)
|
||||
return plugins
|
||||
|
||||
@property
|
||||
def running_plugins(self) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -435,7 +462,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
monitor_suppressed=self.is_plugin_monitor_suppressed,
|
||||
local_candidate=self._get_local_plugin_candidate_from_path,
|
||||
sync_local=self._sync_local_plugin_if_installed,
|
||||
reload_plugin=self.reload_plugin,
|
||||
reload_plugin=self.reload_plugin_tree,
|
||||
dependency_manifest_status=(
|
||||
get_plugin_system().dependency_manifest_status
|
||||
),
|
||||
@@ -512,6 +539,27 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
"""
|
||||
return self._plugin_lifecycle.reload(plugin_id, EventType.PluginReload)
|
||||
|
||||
def reload_plugin_tree(self, plugin_id: str) -> PluginRuntimeStatus:
|
||||
"""重载源码插件,并同步刷新所有引用该源码的虚拟实例。"""
|
||||
source_plugin_id = self.get_plugin_source_id(plugin_id)
|
||||
status = self.reload_plugin(source_plugin_id)
|
||||
for instance in self._plugin_instance_store.for_source(source_plugin_id):
|
||||
self.reload_plugin(instance.instance_id)
|
||||
return status
|
||||
|
||||
def get_plugin_reload_targets(self, plugin_id: str) -> List[str]:
|
||||
"""返回源码更新后需要刷新注册信息的源插件及其实例 ID。"""
|
||||
source_plugin_id = self.get_plugin_source_id(plugin_id)
|
||||
return [
|
||||
source_plugin_id,
|
||||
*(
|
||||
instance.instance_id
|
||||
for instance in self._plugin_instance_store.for_source(
|
||||
source_plugin_id
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _clear_plugin_modules(plugin_id: Optional[str] = None):
|
||||
"""
|
||||
@@ -551,13 +599,29 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
log=logger,
|
||||
).install_missing_with_status()
|
||||
|
||||
@staticmethod
|
||||
def classify_plugins() -> PluginDependencyClassification:
|
||||
"""按源码和依赖是否就绪划分已安装插件。"""
|
||||
return PluginDependencyService(
|
||||
def classify_plugins(self) -> PluginDependencyClassification:
|
||||
"""按源码依赖状态分类物理插件,并把结果映射到虚拟实例。"""
|
||||
source_classification = PluginDependencyService(
|
||||
system=get_plugin_system,
|
||||
log=logger,
|
||||
).classify_plugins()
|
||||
ready = list(source_classification.ready)
|
||||
missing_dependencies = list(source_classification.missing_dependencies)
|
||||
missing_source = list(source_classification.missing_source)
|
||||
source_ready = set(source_classification.ready)
|
||||
source_pending = set(source_classification.missing_dependencies)
|
||||
for instance in self._plugin_instance_store.all().values():
|
||||
if instance.source_plugin_id in source_ready:
|
||||
ready.append(instance.instance_id)
|
||||
elif instance.source_plugin_id in source_pending:
|
||||
missing_dependencies.append(instance.instance_id)
|
||||
else:
|
||||
missing_source.append(instance.instance_id)
|
||||
return PluginDependencyClassification(
|
||||
ready=tuple(ready),
|
||||
missing_dependencies=tuple(missing_dependencies),
|
||||
missing_source=tuple(missing_source),
|
||||
)
|
||||
|
||||
def apply_plugin_dependency_classification(
|
||||
self,
|
||||
@@ -610,6 +674,27 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
"""
|
||||
return self._plugin_config_store.read(pid)
|
||||
|
||||
def get_plugin_instances(self) -> dict[str, PluginInstance]:
|
||||
"""返回全部有效虚拟插件实例描述。"""
|
||||
return self._plugin_instance_store.all()
|
||||
|
||||
def get_plugin_instance(self, plugin_id: str) -> Optional[PluginInstance]:
|
||||
"""返回指定虚拟插件实例描述,物理插件返回空。"""
|
||||
return self._plugin_instance_store.get(plugin_id)
|
||||
|
||||
def get_plugin_source_id(self, plugin_id: str) -> str:
|
||||
"""解析插件运行身份对应的源码身份,普通插件保持原值。"""
|
||||
instance = self._plugin_instance_store.get(plugin_id)
|
||||
return instance.source_plugin_id if instance else plugin_id
|
||||
|
||||
def get_plugin_source_instances(self, plugin_id: str) -> List[PluginInstance]:
|
||||
"""返回直接引用指定源码插件的虚拟实例。"""
|
||||
return self._plugin_instance_store.for_source(plugin_id)
|
||||
|
||||
def delete_plugin_instance(self, plugin_id: str) -> bool:
|
||||
"""删除虚拟实例描述;调用方仍负责停止实例和清理业务数据。"""
|
||||
return self._plugin_instance_store.delete(plugin_id)
|
||||
|
||||
def save_plugin_config(self, pid: str, conf: dict, force: bool = False) -> bool:
|
||||
"""
|
||||
保存插件配置
|
||||
|
||||
@@ -246,11 +246,13 @@ SCHEMA_EXPORTS = {
|
||||
'Path': ('app.schemas.transfer', 'Path'),
|
||||
'PerformanceSnapshot': ('app.schemas.monitoring', 'PerformanceSnapshot'),
|
||||
'Plugin': ('app.schemas.plugin', 'Plugin'),
|
||||
'PluginCloneRequest': ('app.schemas.plugin', 'PluginCloneRequest'),
|
||||
'PluginDashboard': ('app.schemas.plugin', 'PluginDashboard'),
|
||||
'PluginDashboardMetaItem': ('app.schemas.plugin', 'PluginDashboardMetaItem'),
|
||||
'PluginDataResetEventData': ('app.schemas.event', 'PluginDataResetEventData'),
|
||||
'PluginFolderConfigData': ('app.schemas.plugin', 'PluginFolderConfigData'),
|
||||
'PluginFoldersData': ('app.schemas.plugin', 'PluginFoldersData'),
|
||||
'PluginInstance': ('app.schemas.plugin', 'PluginInstance'),
|
||||
'PluginMarketSyncData': ('app.schemas.system', 'PluginMarketSyncData'),
|
||||
'PluginMarketSyncRequest': ('app.schemas.system', 'PluginMarketSyncRequest'),
|
||||
'PluginMemoryInfo': ('app.schemas.plugin', 'PluginMemoryInfo'),
|
||||
@@ -423,7 +425,7 @@ SCHEMA_CONFLICTS = {
|
||||
'FilterRuleGroup': ['app.schemas.rule', 'app.schemas.system'],
|
||||
'JsonData': ['app.schemas.agent', 'app.schemas.common', 'app.schemas.context', 'app.schemas.dashboard', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.mfa', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.openai', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.user', 'app.schemas.workflow', 'app.schemas.mcp'],
|
||||
'List': ['app.schemas.agent', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.monitoring', 'app.schemas.plugin', 'app.schemas.openai', 'app.schemas.subscribe', 'app.schemas.transfer', 'app.schemas.workflow'],
|
||||
'Literal': ['app.schemas.agent', 'app.schemas.music', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.mcp'],
|
||||
'Literal': ['app.schemas.agent', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.mcp'],
|
||||
'LocaleHelper': ['app.schemas.dashboard', 'app.schemas.response'],
|
||||
'MediaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.workflow'],
|
||||
'MediaSource': ['app.schemas.cache', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.music', 'app.schemas.subscribe', 'app.schemas.transfer'],
|
||||
@@ -448,6 +450,6 @@ SCHEMA_CONFLICTS = {
|
||||
'UserPermissions': ['app.schemas.token', 'app.schemas.user'],
|
||||
'dataclass': ['app.schemas.notification', 'app.schemas.system'],
|
||||
'datetime': ['app.schemas.agent', 'app.schemas.monitoring'],
|
||||
'field_validator': ['app.schemas.event', 'app.schemas.message', 'app.schemas.music', 'app.schemas.response', 'app.schemas.subscribe', 'app.schemas.system'],
|
||||
'field_validator': ['app.schemas.event', 'app.schemas.message', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.response', 'app.schemas.subscribe', 'app.schemas.system'],
|
||||
'model_validator': ['app.schemas.dashboard', 'app.schemas.event', 'app.schemas.mediaserver', 'app.schemas.subscribe'],
|
||||
}
|
||||
|
||||
+48
-2
@@ -1,7 +1,7 @@
|
||||
from enum import Enum as _Enum
|
||||
from typing import Optional, List, Dict, Union
|
||||
from typing import Literal, Optional, List, Dict, Union
|
||||
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
from pydantic import BaseModel, Field, RootModel, field_validator
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
|
||||
@@ -17,6 +17,27 @@ class PluginRuntimeStatus(str, _Enum):
|
||||
LOAD_FAILED = "load_failed"
|
||||
|
||||
|
||||
class PluginInstance(BaseModel):
|
||||
"""持久化一个共享源码插件的独立运行实例。"""
|
||||
|
||||
instance_id: str = Field(description="运行实例 ID,也是配置、数据和路由命名空间")
|
||||
source_plugin_id: str = Field(description="提供代码与前端资源的源插件 ID")
|
||||
plugin_name: Optional[str] = Field(default=None, description="实例展示名称")
|
||||
plugin_desc: Optional[str] = Field(default=None, description="实例展示描述")
|
||||
plugin_icon: Optional[str] = Field(default=None, description="实例展示图标")
|
||||
mode: Literal["virtual"] = Field(default="virtual", description="实例实现模式")
|
||||
|
||||
@field_validator("instance_id", "source_plugin_id")
|
||||
@classmethod
|
||||
def validate_plugin_id(cls, value: str) -> str:
|
||||
"""限制实例标识为可安全用作 Python 类名和路由段的格式。"""
|
||||
if not value or not value[0].isalpha() or not value.isalnum():
|
||||
raise ValueError("插件 ID 必须以字母开头且只能包含字母和数字")
|
||||
if len(value) > 128:
|
||||
raise ValueError("插件 ID 长度不能超过 128 个字符")
|
||||
return value
|
||||
|
||||
|
||||
class Plugin(BaseModel):
|
||||
"""
|
||||
插件信息
|
||||
@@ -72,6 +93,12 @@ class Plugin(BaseModel):
|
||||
add_time: Optional[int] = 0
|
||||
# 插件公钥
|
||||
plugin_public_key: Optional[str] = None
|
||||
# 共享代码与前端资源的源插件 ID;普通插件为空
|
||||
source_plugin_id: Optional[str] = None
|
||||
# 是否为共享源码的虚拟实例
|
||||
is_instance: Optional[bool] = False
|
||||
# 实例实现模式;存量物理分身为空
|
||||
instance_mode: Optional[str] = None
|
||||
|
||||
|
||||
class PluginRuntimeSummary(BaseModel):
|
||||
@@ -83,6 +110,24 @@ class PluginRuntimeSummary(BaseModel):
|
||||
failed_count: int = Field(description="加载失败或被策略阻止的插件数量")
|
||||
|
||||
|
||||
class PluginCloneRequest(BaseModel):
|
||||
"""创建虚拟插件分身的请求参数。"""
|
||||
|
||||
suffix: str = Field(
|
||||
min_length=1,
|
||||
max_length=20,
|
||||
pattern=r"^[A-Za-z0-9]+$",
|
||||
description="追加到当前插件 ID 后的 ASCII 字母或数字后缀",
|
||||
)
|
||||
name: str = Field(default="", description="分身展示名称")
|
||||
description: str = Field(default="", description="分身展示描述")
|
||||
icon: Optional[str] = Field(default=None, description="分身展示图标")
|
||||
version: Optional[str] = Field(
|
||||
default=None,
|
||||
description="兼容旧客户端保留,虚拟分身始终跟随源插件版本",
|
||||
)
|
||||
|
||||
|
||||
class PluginDashboard(Plugin):
|
||||
"""
|
||||
插件仪表盘
|
||||
@@ -164,6 +209,7 @@ class PluginRemoteInfo(BaseModel):
|
||||
id: str
|
||||
url: str
|
||||
name: str
|
||||
source_plugin_id: Optional[str] = None
|
||||
|
||||
|
||||
class PluginReleaseItem(BaseModel):
|
||||
|
||||
@@ -392,6 +392,8 @@ class SystemConfigKey(Enum):
|
||||
UserCustomCSS = "UserCustomCSS"
|
||||
# 用户已安装的插件
|
||||
UserInstalledPlugins = "UserInstalledPlugins"
|
||||
# 共享源码插件的虚拟运行实例
|
||||
PluginInstances = "PluginInstances"
|
||||
# 插件文件夹分组配置
|
||||
PluginFolders = "PluginFolders"
|
||||
# 默认电影订阅规则
|
||||
|
||||
+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