mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
feat(plugin): 分身后缀可留空自动分配,并支持按后缀恢复已停用的分身 (#6673)
This commit is contained in:
@@ -555,6 +555,7 @@ API_EXTENDED_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
|
||||
effect=ActionEffect.DESTRUCTIVE_WRITE,
|
||||
recovery=RecoveryMode.MANUAL_ONLY,
|
||||
),
|
||||
_admin_read("plugin.clone.restorable", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_write("plugin.clone", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=RecoveryMode.RECONCILE),
|
||||
_spec("config.user.get", result_sensitivity=ResultSensitivity.PRIVATE),
|
||||
_spec("config.public.get"),
|
||||
@@ -810,6 +811,9 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = {
|
||||
"plugin.statistics": ApiOperationRoute("GET", "/api/v1/plugin/statistic"),
|
||||
"plugin.reset": ApiOperationRoute("GET", "/api/v1/plugin/reset/{plugin_id}"),
|
||||
"plugin.clone": ApiOperationRoute("POST", "/api/v1/plugin/clone/{plugin_id}"),
|
||||
"plugin.clone.restorable": ApiOperationRoute(
|
||||
"GET", "/api/v1/plugin/clone/{plugin_id}/restorable"
|
||||
),
|
||||
"config.user.get": ApiOperationRoute("GET", "/api/v1/system/global/user"),
|
||||
"config.public.get": ApiOperationRoute("GET", "/api/v1/system/setting/public/{key}"),
|
||||
"system.usage.statistics": ApiOperationRoute("GET", "/api/v1/system/usage/statistic"),
|
||||
|
||||
@@ -123,7 +123,8 @@ OPERATION_DESCRIPTIONS = {
|
||||
"media.recognize_file": "Recognize canonical media identity from one exact filename and optional path context.",
|
||||
"media.seasons": "List seasons for one exact media identity or a title-and-year fallback.",
|
||||
"media.sources": "List metadata sources currently registered for MoviePilot media operations.",
|
||||
"plugin.clone": "Create a configurable clone of one installed plugin.",
|
||||
"plugin.clone": "Create a configurable clone of one installed plugin; leave suffix empty to let the server allocate the next free instance ID, and reusing the suffix of a disabled clone re-enables that clone together with its stored configuration.",
|
||||
"plugin.clone.restorable": "List one plugin's disabled clones whose configuration is still stored and can be brought back by creating a clone with the same suffix.",
|
||||
"plugin.history": "Read marketplace update notes and history for one plugin.",
|
||||
"plugin.market.sync_wiki": "Refresh the configured plugin marketplace repositories from the MoviePilot Wiki.",
|
||||
"plugin.rating": "Read the current aggregate rating for one plugin.",
|
||||
@@ -614,6 +615,7 @@ FIELD_DESCRIPTIONS.update(
|
||||
"result_payload": "Structured external-operation result recorded with manual review.",
|
||||
"release_status": "Music release status used by classification rules.",
|
||||
"rules": "Ordered classification rules evaluated from highest priority to lowest.",
|
||||
"restore_previous": "When creating a plugin clone, reuse the stored configuration of a disabled clone that already holds the same suffix; false rebuilds that clone's configuration from the source plugin template.",
|
||||
"retry": "Workflow retry-policy definition for this action.",
|
||||
"retry_count": "Number of retry attempts already used by the operation.",
|
||||
"retry_exhausted": "Whether the operation has used all configured retry attempts.",
|
||||
|
||||
@@ -2899,13 +2899,25 @@
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"restore_previous": {
|
||||
"default": true,
|
||||
"description": "When creating a plugin clone, reuse the stored configuration of a disabled clone that already holds the same suffix; false rebuilds that clone's configuration from the source plugin template.",
|
||||
"title": "Restore Previous",
|
||||
"type": "boolean"
|
||||
},
|
||||
"suffix": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 20,
|
||||
"pattern": "^[A-Za-z0-9]+$",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "File suffix or extension matched by an automatic category rule.",
|
||||
"maxLength": 20,
|
||||
"minLength": 1,
|
||||
"pattern": "^[A-Za-z0-9]+$",
|
||||
"title": "Suffix",
|
||||
"type": "string"
|
||||
"title": "Suffix"
|
||||
},
|
||||
"version": {
|
||||
"anyOf": [
|
||||
@@ -2920,9 +2932,6 @@
|
||||
"title": "Version"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"suffix"
|
||||
],
|
||||
"title": "PluginCloneRequest",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -9632,11 +9641,11 @@
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Create a configurable clone of one installed plugin. Method: POST. Path: /api/v1/plugin/clone/{plugin_id}. Effect: external_side_effect.",
|
||||
"description": "Create a configurable clone of one installed plugin; leave suffix empty to let the server allocate the next free instance ID, and reusing the suffix of a disabled clone re-enables that clone together with its stored configuration. Method: POST. Path: /api/v1/plugin/clone/{plugin_id}. Effect: external_side_effect.",
|
||||
"properties": {
|
||||
"body": {
|
||||
"$ref": "#/$defs/PluginCloneRequest",
|
||||
"description": "Request value for plugin.clone. Create a configurable clone of one installed plugin. Use the exact type and fields below."
|
||||
"description": "Request value for plugin.clone. Create a configurable clone of one installed plugin; leave suffix empty to let the server allocate the next free instance ID, and reusing the suffix of a disabled clone re-enables that clone together with its stored configuration. Use the exact type and fields below."
|
||||
},
|
||||
"operation_id": {
|
||||
"const": "plugin.clone",
|
||||
@@ -9645,7 +9654,7 @@
|
||||
},
|
||||
"path_params": {
|
||||
"additionalProperties": false,
|
||||
"description": "Resource identity placeholders for plugin.clone. Create a configurable clone of one installed plugin. Use only the named fields below.",
|
||||
"description": "Resource identity placeholders for plugin.clone. Create a configurable clone of one installed plugin; leave suffix empty to let the server allocate the next free instance ID, and reusing the suffix of a disabled clone re-enables that clone together with its stored configuration. Use only the named fields below.",
|
||||
"properties": {
|
||||
"plugin_id": {
|
||||
"description": "Exact installed or marketplace plugin ID.",
|
||||
@@ -9667,6 +9676,78 @@
|
||||
"title": "plugin.clone",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "List one plugin's disabled clones whose configuration is still stored and can be brought back by creating a clone with the same suffix. Method: GET. Path: /api/v1/plugin/clone/{plugin_id}/restorable. Effect: safe_read. Collection response: data remains a list; omit both page and count to preserve the legacy complete result. Successful gateway output adds collection.result_count and the exact collection.total_count. For a count or summary, send page=1 and count=1, then read collection.total_count; do not query the database merely because item data is truncated.",
|
||||
"properties": {
|
||||
"operation_id": {
|
||||
"const": "plugin.clone.restorable",
|
||||
"description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.",
|
||||
"type": "string"
|
||||
},
|
||||
"path_params": {
|
||||
"additionalProperties": false,
|
||||
"description": "Resource identity placeholders for plugin.clone.restorable. List one plugin's disabled clones whose configuration is still stored and can be brought back by creating a clone with the same suffix. Use only the named fields below.",
|
||||
"properties": {
|
||||
"plugin_id": {
|
||||
"description": "Exact installed or marketplace plugin ID.",
|
||||
"title": "Plugin Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"plugin_id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"query": {
|
||||
"additionalProperties": false,
|
||||
"description": "Filters and control values for plugin.clone.restorable. List one plugin's disabled clones whose configuration is still stored and can be brought back by creating a clone with the same suffix. Use only the named fields below.",
|
||||
"properties": {
|
||||
"count": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maximum": 200,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.",
|
||||
"title": "Count"
|
||||
},
|
||||
"page": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.",
|
||||
"title": "Page"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"operation_id",
|
||||
"path_params"
|
||||
],
|
||||
"title": "plugin.clone.restorable",
|
||||
"type": "object",
|
||||
"x-moviepilot-collection": {
|
||||
"body_shape": "list",
|
||||
"default_pagination": "unpaginated",
|
||||
"result_count_field": "collection.result_count",
|
||||
"total_count_field": "collection.total_count"
|
||||
}
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Read one loaded plugin's configuration form and its defaults merged with saved values. Method: GET. Path: /api/v1/plugin/form/{plugin_id}. Effect: safe_read.",
|
||||
@@ -16280,6 +16361,7 @@
|
||||
"music.recognize",
|
||||
"plugin.capabilities",
|
||||
"plugin.clone",
|
||||
"plugin.clone.restorable",
|
||||
"plugin.config.get",
|
||||
"plugin.config.update",
|
||||
"plugin.data",
|
||||
|
||||
@@ -38,7 +38,7 @@ from app.application.configuration import get_api_runtime_config_snapshot, get_c
|
||||
from app.application.plugin.catalog import get_plugin_catalog_query
|
||||
from app.application.plugin.config import PluginConfigCommand
|
||||
from app.application.plugin.data import PluginDataQueryService, PluginDataSummaryService
|
||||
from app.application.plugin.folders import add_clone_to_plugin_folder, remove_plugin_from_folders
|
||||
from app.application.plugin.folders import remove_plugin_from_folders
|
||||
from app.application.plugin.gateway import get_plugin_install_service
|
||||
from app.application.plugin.management import (
|
||||
get_plugin_snapshot,
|
||||
@@ -57,7 +57,6 @@ from app.runtime.tasks import TaskRegistry
|
||||
from app.schemas.common import JsonObject as _SchemaJsonObject
|
||||
from app.schemas.exception import PluginMutationRejectedError
|
||||
from app.schemas.plugin import Plugin as _SchemaPlugin
|
||||
from app.schemas.plugin import PluginCloneRequest as _SchemaPluginCloneRequest
|
||||
from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard
|
||||
from app.schemas.plugin import PluginDashboardMetaItem as _SchemaPluginDashboardMetaItem
|
||||
from app.schemas.plugin import PluginDataSummary as _SchemaPluginDataSummary
|
||||
@@ -767,39 +766,6 @@ async def plugin_static_file(
|
||||
raise HTTPException(status_code=500, detail="Internal Server Error")
|
||||
|
||||
|
||||
@router.post("/clone/{plugin_id}", summary="创建插件分身", response_model=_SchemaResponse[None])
|
||||
def clone_plugin(
|
||||
plugin_id: str,
|
||||
clone_data: _SchemaPluginCloneRequest,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
创建插件分身
|
||||
"""
|
||||
plugin_manager = get_plugin_manager()
|
||||
try:
|
||||
with plugin_manager.mutation(f"创建插件 {plugin_id} 分身"):
|
||||
success, message = plugin_manager.clone_plugin(
|
||||
plugin_id=plugin_id,
|
||||
suffix=clone_data.suffix,
|
||||
name=clone_data.name,
|
||||
description=clone_data.description,
|
||||
version=clone_data.version,
|
||||
icon=clone_data.icon,
|
||||
)
|
||||
|
||||
if success:
|
||||
# 分身服务已完成运行态加载,此处只补齐宿主注册。
|
||||
register_plugin(message)
|
||||
# 将分身插件添加到原插件所在的文件夹中
|
||||
add_clone_to_plugin_folder(plugin_id, message)
|
||||
return _SchemaResponse(success=True, message="插件分身创建成功")
|
||||
return _SchemaResponse(success=False, message=message)
|
||||
except Exception as e:
|
||||
logger.error(f"创建插件分身失败:{str(e)}")
|
||||
return _SchemaResponse(success=False, message=f"创建插件分身失败:{str(e)}")
|
||||
|
||||
|
||||
@router.get( # type: ignore[misc]
|
||||
"/runtime/capabilities",
|
||||
summary="查询插件运行能力",
|
||||
|
||||
119
app/api/endpoints/pluginclone.py
Normal file
119
app/api/endpoints/pluginclone.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""插件分身的创建、恢复与可恢复清单接口。
|
||||
|
||||
这些路由并入 ``plugin`` 路由器,路径与单文件时期完全一致;单独成篇只是让分身创建与
|
||||
插件目录、市场、静态资源等关注点各自分开,与 ``pluginfolder``/``pluginloglevel``/
|
||||
``plugintarget`` 的切分方式一致。
|
||||
"""
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import Depends, Response
|
||||
|
||||
from app.api.dependencies.auth import get_current_active_superuser
|
||||
from app.api.endpoints.plugin import register_plugin
|
||||
from app.api.principal import ApiPrincipal
|
||||
from app.api.response import (
|
||||
COLLECTION_TOTAL_HEADER,
|
||||
COLLECTION_TOTAL_OPENAPI_KEY,
|
||||
CompatibleCountParam,
|
||||
CompatiblePageParam,
|
||||
ResponseAPIRouter,
|
||||
resolve_compatible_pagination,
|
||||
)
|
||||
from app.application.plugin.folders import add_clone_to_plugin_folder
|
||||
from app.application.plugin.runtime import get_plugin_manager
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.plugin import PluginCloneOutcome as _SchemaPluginCloneOutcome
|
||||
from app.schemas.plugin import PluginCloneRequest as _SchemaPluginCloneRequest
|
||||
from app.schemas.plugin import PluginRestorableInstance as _SchemaPluginRestorableInstance
|
||||
from app.schemas.response import Response as _SchemaResponse
|
||||
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
@router.get( # type: ignore[misc]
|
||||
"/clone/{plugin_id}/restorable",
|
||||
summary="列出可恢复的已停用分身",
|
||||
response_model=List[_SchemaPluginRestorableInstance],
|
||||
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
|
||||
)
|
||||
def plugin_restorable_instances(
|
||||
plugin_id: str,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser),
|
||||
page: CompatiblePageParam = None,
|
||||
count: CompatibleCountParam = None,
|
||||
response: Response = None,
|
||||
) -> Any:
|
||||
"""
|
||||
列出该插件名下已停用、设置仍留存可被恢复的分身
|
||||
|
||||
停用只把启用位置假,业务参数与展示信息都还留在那一行上。启用中的分身不在此列
|
||||
——它们的配置正被使用,摆进恢复选择器只会让人误以为能把一个活着的实例再建一遍。
|
||||
|
||||
未指定分页时返回完整清单:一个插件的历史分身数量有限,恢复选择器要一次看全。
|
||||
"""
|
||||
instances = [
|
||||
_SchemaPluginRestorableInstance(**item)
|
||||
for item in get_plugin_manager().get_restorable_plugin_instances(plugin_id)
|
||||
]
|
||||
if response is not None:
|
||||
response.headers[COLLECTION_TOTAL_HEADER] = str(len(instances))
|
||||
if page is not None or count is not None:
|
||||
page, count = resolve_compatible_pagination(page, count)
|
||||
assert page is not None and count is not None
|
||||
return instances[(page - 1) * count: page * count]
|
||||
return instances
|
||||
|
||||
|
||||
@router.post( # type: ignore[misc]
|
||||
"/clone/{plugin_id}",
|
||||
summary="创建插件分身",
|
||||
response_model=_SchemaResponse[_SchemaPluginCloneOutcome],
|
||||
)
|
||||
def clone_plugin(
|
||||
plugin_id: str,
|
||||
clone_data: _SchemaPluginCloneRequest,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
创建插件分身
|
||||
|
||||
不填后缀时由服务端分配一个最小可用序号,因而实例 ID 只能由回执给出;该后缀名下
|
||||
留有一个已停用的分身时,本次创建就是把那一行连同它的业务参数重新启用。
|
||||
"""
|
||||
plugin_manager = get_plugin_manager()
|
||||
try:
|
||||
with plugin_manager.mutation(f"创建插件 {plugin_id} 分身"):
|
||||
success, message = plugin_manager.clone_plugin(
|
||||
plugin_id=plugin_id,
|
||||
suffix=clone_data.suffix,
|
||||
name=clone_data.name,
|
||||
description=clone_data.description,
|
||||
version=clone_data.version,
|
||||
icon=clone_data.icon,
|
||||
restore_previous=clone_data.restore_previous,
|
||||
)
|
||||
if not success:
|
||||
return _SchemaResponse(success=False, message=message)
|
||||
# 分身此时已经创建并加载完成,后面只是补齐宿主注册。这一步失败不能报成
|
||||
# 「创建失败」:分身确实已经存在,用户照提示重试只会撞上「分身已存在」,
|
||||
# 真正的原因反而被那句话盖掉。
|
||||
outcome = _SchemaPluginCloneOutcome(instance_id=message)
|
||||
try:
|
||||
register_plugin(message)
|
||||
# 将分身插件添加到原插件所在的文件夹中
|
||||
add_clone_to_plugin_folder(plugin_id, message)
|
||||
except Exception as register_error: # noqa: BLE001
|
||||
logger.error(f"插件分身 {message} 已创建,注册宿主能力失败:{register_error}")
|
||||
return _SchemaResponse(
|
||||
success=False,
|
||||
message=(
|
||||
f"插件分身 {message} 已创建,但注册定时任务或路由失败:"
|
||||
f"{register_error};请检查该分身配置后重载插件"
|
||||
),
|
||||
data=outcome,
|
||||
)
|
||||
return _SchemaResponse(success=True, message="插件分身创建成功", data=outcome)
|
||||
except Exception as e:
|
||||
logger.error(f"创建插件分身失败:{str(e)}")
|
||||
return _SchemaResponse(success=False, message=f"创建插件分身失败:{str(e)}")
|
||||
@@ -25,6 +25,7 @@ from app.api.endpoints import (
|
||||
notification,
|
||||
openai,
|
||||
plugin,
|
||||
pluginclone,
|
||||
plugininstance,
|
||||
pluginloglevel,
|
||||
plugintarget,
|
||||
@@ -76,6 +77,7 @@ API_V1_ROUTER_SPECS: tuple[RouterSpec, ...] = (
|
||||
RouterSpec(notification.router, "/notification", ("notification",)),
|
||||
RouterSpec(llm.router, "/llm", ("llm",)),
|
||||
RouterSpec(plugin.router, "/plugin", ("plugin",)),
|
||||
RouterSpec(pluginclone.router, "/plugin", ("plugin",)),
|
||||
RouterSpec(plugininstance.router, "/plugin", ("plugin",)),
|
||||
RouterSpec(pluginloglevel.router, "/plugin", ("plugin",)),
|
||||
RouterSpec(plugintarget.router, "/plugin", ("plugin",)),
|
||||
|
||||
@@ -2,11 +2,32 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
from typing import Any, NamedTuple, Optional
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
|
||||
|
||||
# 自动分配的后缀从 2 起算:源插件本体在用户眼里就是第 1 个实例,分身接着往下排
|
||||
_FIRST_AUTO_SUFFIX = 2
|
||||
# 探测次数上限:占用判据若因端口故障恒为真,没有上限会让请求线程原地打转并一直扣着占位锁
|
||||
_MAX_AUTO_SUFFIX_PROBES = 1000
|
||||
|
||||
|
||||
def _first_validation_message(error: ValidationError) -> str:
|
||||
"""取校验错误里的首条说明,供回给用户的单行原因使用。"""
|
||||
details = error.errors()
|
||||
return str(details[0].get("msg")) if details else str(error)
|
||||
|
||||
|
||||
class _Reservation(NamedTuple):
|
||||
"""占位阶段已经定下来的事实,供后续加载与回滚判断来历。"""
|
||||
|
||||
clone_id: str
|
||||
restoring: bool
|
||||
|
||||
|
||||
class PluginCloneService:
|
||||
"""创建共享源码的虚拟插件实例并协调失败回滚。"""
|
||||
@@ -15,10 +36,12 @@ class PluginCloneService:
|
||||
self,
|
||||
*,
|
||||
plugin_class: Callable[[str], Optional[Any]],
|
||||
plugin_exists: Callable[[str], bool],
|
||||
instance_id_taken: Callable[[str], bool],
|
||||
get_instance: Callable[[str], Optional[PluginInstance]],
|
||||
source_plugin_id: Callable[[str], str],
|
||||
save_instance: Callable[[PluginInstance], Any],
|
||||
delete_instance: Callable[[str], bool],
|
||||
disable_instance: Callable[[str], bool],
|
||||
read_config: Callable[[str], dict],
|
||||
save_config: Callable[[str, dict], bool],
|
||||
delete_config: Callable[[str], bool],
|
||||
@@ -28,74 +51,235 @@ class PluginCloneService:
|
||||
) -> None:
|
||||
"""保存实例描述、持久化和运行态端口。"""
|
||||
self._plugin_class = plugin_class
|
||||
self._plugin_exists = plugin_exists
|
||||
self._instance_id_taken = instance_id_taken
|
||||
self._get_instance = get_instance
|
||||
self._source_plugin_id = source_plugin_id
|
||||
self._save_instance = save_instance
|
||||
self._delete_instance = delete_instance
|
||||
self._disable_instance = disable_instance
|
||||
self._read_config = read_config
|
||||
self._save_config = save_config
|
||||
self._delete_config = delete_config
|
||||
self._reload_plugin = reload_plugin
|
||||
self._remove_plugin = remove_plugin
|
||||
self._logger = log
|
||||
# 「挑一个没被占用的 ID」与「把这个 ID 占下来」之间存在窗口,两个并发的创建
|
||||
# 请求会在窗口里选出同一个 ID,随后一个覆盖另一个的实例行。创建是低频的管理
|
||||
# 动作,用一把进程内互斥把占位串行化即可,代价远小于让两个分身共用一行
|
||||
self._reservation_lock = threading.Lock()
|
||||
|
||||
def clone(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str,
|
||||
suffix: str,
|
||||
suffix: Optional[str] = None,
|
||||
name: str,
|
||||
description: str,
|
||||
version: Optional[str] = None,
|
||||
icon: Optional[str] = None,
|
||||
restore_previous: bool = True,
|
||||
) -> tuple[bool, str]:
|
||||
"""创建虚拟分身,复制隔离配置并保持默认禁用语义。"""
|
||||
if not plugin_id or not suffix:
|
||||
return False, "插件ID和分身后缀不能为空"
|
||||
"""创建虚拟分身,复制隔离配置并保持默认禁用语义。
|
||||
|
||||
:param plugin_id: 源插件ID
|
||||
:param suffix: 追加到源插件ID后的分身后缀;留空时自动分配最小可用序号
|
||||
:param name: 分身展示名称,恢复时留空表示沿用停用前登记的那一份
|
||||
:param description: 分身展示描述,留空时的处理同 ``name``
|
||||
:param version: 旧客户端仍会带上;分身始终跟随源插件版本,此处不消费
|
||||
:param icon: 分身展示图标,留空时的处理同 ``name``
|
||||
:param restore_previous: 该后缀名下留有一个已停用的分身时是否沿用它的业务
|
||||
参数;为假时改按源插件模板重建一份配置
|
||||
:return: 是否成功与分身ID或失败原因
|
||||
"""
|
||||
del version
|
||||
rejection = self._reject_invalid_source(plugin_id)
|
||||
if rejection:
|
||||
return False, rejection
|
||||
|
||||
with self._reservation_lock:
|
||||
reservation, message = self._reserve(
|
||||
plugin_id=plugin_id,
|
||||
suffix=suffix,
|
||||
name=name,
|
||||
description=description,
|
||||
icon=icon,
|
||||
restore_previous=restore_previous,
|
||||
)
|
||||
if reservation is None:
|
||||
return False, message
|
||||
return self._activate(
|
||||
reservation,
|
||||
plugin_id=plugin_id,
|
||||
restore_previous=restore_previous,
|
||||
)
|
||||
|
||||
def _reject_invalid_source(self, plugin_id: str) -> str:
|
||||
"""判断给定插件能否作为分身来源,可以时返回空串。
|
||||
|
||||
分身不能再生分身:分身共享源插件的源码,它自己并不是一份源码。放行的话新实例
|
||||
会挂到源插件名下,后缀却是按分身 ID 算的,自动分配因而会在一个与占用判据对不上
|
||||
的号段里挑号。
|
||||
"""
|
||||
if not plugin_id:
|
||||
return "插件ID不能为空"
|
||||
if self._plugin_class(plugin_id) is None:
|
||||
return False, f"原插件 {plugin_id} 不存在"
|
||||
return f"原插件 {plugin_id} 不存在"
|
||||
source_id = self._source_plugin_id(plugin_id)
|
||||
if source_id != plugin_id:
|
||||
return (
|
||||
f"{plugin_id} 是分身实例,不能作为分身来源,"
|
||||
f"请对源插件 {source_id} 创建分身"
|
||||
)
|
||||
return ""
|
||||
|
||||
clone_id = f"{plugin_id}{suffix.lower()}"
|
||||
if self._plugin_exists(clone_id):
|
||||
return False, f"分身插件 {clone_id} 已存在"
|
||||
def _reserve(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str,
|
||||
suffix: Optional[str],
|
||||
name: str,
|
||||
description: str,
|
||||
icon: Optional[str],
|
||||
restore_previous: bool,
|
||||
) -> tuple[Optional[_Reservation], str]:
|
||||
"""定下分身 ID 并把实例行落库,返回占位结果或拒绝原因。
|
||||
|
||||
调用方须持有占位锁:本方法内部先读后写,两个并发创建各自读到「未占用」就会
|
||||
落到同一行上。
|
||||
"""
|
||||
resolved_suffix = (suffix or "").strip().lower() or self._allocate_suffix(plugin_id)
|
||||
if not resolved_suffix:
|
||||
return None, f"插件 {plugin_id} 的可用分身后缀已耗尽,请手动指定一个"
|
||||
clone_id = f"{plugin_id}{resolved_suffix}"
|
||||
# 已停用的分身仍留着自己那一行,业务参数原样挂在上面:同后缀重建就是把它连同
|
||||
# 配置一起拿回来,而不是撞车。归属对不上的行不算可恢复——它是别的源插件的分身
|
||||
previous = self._get_instance(clone_id)
|
||||
restoring = (
|
||||
previous is not None
|
||||
and not previous.is_enabled
|
||||
and previous.source_plugin_id == plugin_id
|
||||
)
|
||||
if not restoring and self._instance_id_taken(clone_id):
|
||||
return None, f"分身插件 {clone_id} 已存在"
|
||||
|
||||
instance, message = self._build_instance(
|
||||
clone_id=clone_id,
|
||||
source_plugin_id=plugin_id,
|
||||
name=name,
|
||||
description=description,
|
||||
icon=icon,
|
||||
previous=previous if restoring else None,
|
||||
inherit_display=restore_previous,
|
||||
)
|
||||
if instance is None:
|
||||
return None, message
|
||||
self._save_instance(instance)
|
||||
return _Reservation(clone_id=clone_id, restoring=restoring), ""
|
||||
|
||||
def _allocate_suffix(self, plugin_id: str) -> str:
|
||||
"""为新分身分配一个最小可用的数字后缀,无号可用时返回空串。
|
||||
|
||||
占用判据与显式指定后缀走的是同一个 ``instance_id_taken``,自动分配因此不可能
|
||||
挑中一个手填时会被判成「已存在」的 ID。已停用的分身同样占位:重用它的 ID 是
|
||||
「恢复」而不是新建,用户没点恢复就不该凭空拿到上一个分身留下的配置。
|
||||
"""
|
||||
for index in range(
|
||||
_FIRST_AUTO_SUFFIX,
|
||||
_FIRST_AUTO_SUFFIX + _MAX_AUTO_SUFFIX_PROBES,
|
||||
):
|
||||
if not self._instance_id_taken(f"{plugin_id}{index}"):
|
||||
return str(index)
|
||||
return ""
|
||||
|
||||
def _build_instance(
|
||||
self,
|
||||
*,
|
||||
clone_id: str,
|
||||
source_plugin_id: str,
|
||||
name: str,
|
||||
description: str,
|
||||
icon: Optional[str],
|
||||
previous: Optional[PluginInstance],
|
||||
inherit_display: bool,
|
||||
) -> tuple[Optional[PluginInstance], str]:
|
||||
"""构造实例描述,并在任何写入之前拦下不合法的实例 ID。
|
||||
|
||||
合法性由 ``PluginInstance`` 自己判定,这里不另抄一份规则;构造排在落库之前,
|
||||
非法 ID 因而不会先写出一行再靠回滚擦掉。
|
||||
|
||||
:param previous: 正在被恢复的那一行,非恢复时为 None
|
||||
:param inherit_display: 恢复时展示信息是否沿用停用前登记的那一份
|
||||
"""
|
||||
if previous is not None and inherit_display:
|
||||
name = name or (previous.plugin_name or "")
|
||||
description = description or (previous.plugin_desc or "")
|
||||
icon = icon or (previous.plugin_icon or "")
|
||||
try:
|
||||
instance = PluginInstance(
|
||||
instance_id=clone_id,
|
||||
source_plugin_id=self._source_plugin_id(plugin_id),
|
||||
source_plugin_id=source_plugin_id,
|
||||
plugin_name=name or None,
|
||||
plugin_desc=description or None,
|
||||
plugin_icon=icon or None,
|
||||
# 恢复时保留该行原有的默认目标置位;新建的分身不该凭空成为默认目标
|
||||
is_default_target=(
|
||||
previous.is_default_target if previous is not None else False
|
||||
),
|
||||
# 新建的分身就是要拿去跑的;启用位是装载判据,留空会让它建出来却不加载
|
||||
is_enabled=True,
|
||||
)
|
||||
self._save_instance(instance)
|
||||
except ValidationError as error:
|
||||
return None, f"分身实例 ID {clone_id} 不合法:{_first_validation_message(error)}"
|
||||
return instance, ""
|
||||
|
||||
original_config = self._read_config(plugin_id)
|
||||
if original_config:
|
||||
clone_config = dict(original_config)
|
||||
clone_config["enable"] = False
|
||||
clone_config["enabled"] = False
|
||||
if not self._save_config(clone_id, clone_config):
|
||||
raise RuntimeError("虚拟实例配置保存失败")
|
||||
def _activate(
|
||||
self,
|
||||
reservation: _Reservation,
|
||||
*,
|
||||
plugin_id: str,
|
||||
restore_previous: bool,
|
||||
) -> tuple[bool, str]:
|
||||
"""准备配置并完成首次加载,失败时按来历回滚。"""
|
||||
clone_id = reservation.clone_id
|
||||
# 恢复留存配置时不得用源插件模板盖掉它,那正是用户要拿回来的东西
|
||||
keep_previous_config = reservation.restoring and restore_previous
|
||||
try:
|
||||
if not keep_previous_config:
|
||||
original_config = self._read_config(plugin_id)
|
||||
if original_config:
|
||||
clone_config = dict(original_config)
|
||||
clone_config["enable"] = False
|
||||
clone_config["enabled"] = False
|
||||
if not self._save_config(clone_id, clone_config):
|
||||
raise RuntimeError("虚拟实例配置保存失败")
|
||||
|
||||
status = self._reload_plugin(clone_id)
|
||||
if status is PluginRuntimeStatus.LOAD_FAILED:
|
||||
raise RuntimeError("虚拟实例加载失败")
|
||||
self._logger.info(f"插件分身 {clone_id} 创建成功")
|
||||
action = "恢复" if reservation.restoring else "创建"
|
||||
self._logger.info(f"插件分身 {clone_id} {action}成功")
|
||||
return True, clone_id
|
||||
except Exception as error: # noqa: BLE001
|
||||
self._rollback(clone_id)
|
||||
self._rollback(clone_id, purge_instance=not reservation.restoring)
|
||||
self._logger.error(f"创建插件分身失败:{error}")
|
||||
return False, f"创建插件分身失败:{error}"
|
||||
|
||||
def _rollback(self, clone_id: str) -> None:
|
||||
"""逐项清理失败实例,单个清理错误不得阻断其余回滚。"""
|
||||
rollback_steps = (
|
||||
def _rollback(self, clone_id: str, *, purge_instance: bool) -> None:
|
||||
"""逐项清理失败实例,单个清理错误不得阻断其余回滚。
|
||||
|
||||
:param clone_id: 本次创建的分身ID
|
||||
:param purge_instance: 实例行与配置是否由本次创建产生、可随之抹掉;恢复一个
|
||||
已停用的分身时为假——那一行连同用户留在上面的业务参数是特意保留的,一次
|
||||
加载失败不该把它毁掉,只需把启用位退回停用,下次仍可再试一遍恢复
|
||||
"""
|
||||
rollback_steps: list[tuple[str, Callable[[str], Any]]] = [
|
||||
("运行态", self._remove_plugin),
|
||||
("实例描述", self._delete_instance),
|
||||
("配置", self._delete_config),
|
||||
)
|
||||
]
|
||||
if purge_instance:
|
||||
rollback_steps.append(("实例描述", self._delete_instance))
|
||||
rollback_steps.append(("配置", self._delete_config))
|
||||
else:
|
||||
rollback_steps.append(("启用位", self._disable_instance))
|
||||
for label, rollback in rollback_steps:
|
||||
try:
|
||||
rollback(clone_id)
|
||||
|
||||
@@ -1282,17 +1282,19 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
"""
|
||||
return PluginAccessPolicy.private_key(plugin_id)
|
||||
|
||||
def clone_plugin(self, plugin_id: str, suffix: str, name: str, description: str,
|
||||
version: str = None, icon: str = None) -> Tuple[bool, str]:
|
||||
def clone_plugin(self, plugin_id: str, suffix: Optional[str], name: str, description: str,
|
||||
version: str = None, icon: str = None,
|
||||
restore_previous: bool = True) -> Tuple[bool, str]:
|
||||
"""
|
||||
创建插件分身
|
||||
:param plugin_id: 原插件ID
|
||||
:param suffix: 分身后缀
|
||||
:param suffix: 分身后缀;留空时由运行时自动分配最小可用序号
|
||||
:param name: 分身名称
|
||||
:param description: 分身描述
|
||||
:param version: 自定义版本号
|
||||
:param version: 自定义版本号,分身始终跟随源插件版本,仅为旧客户端保留
|
||||
:param icon: 自定义图标URL
|
||||
:return: (是否成功, 错误信息)
|
||||
:param restore_previous: 同后缀名下留有已停用的分身时是否沿用它的业务参数
|
||||
:return: (是否成功, 成功时为分身实例ID/失败时为可读原因)
|
||||
"""
|
||||
try:
|
||||
with self.mutation("创建插件分身"):
|
||||
@@ -1303,11 +1305,42 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
description=description,
|
||||
version=version,
|
||||
icon=icon,
|
||||
restore_previous=restore_previous,
|
||||
)
|
||||
except PluginMutationRejectedError as error:
|
||||
logger.warning(str(error))
|
||||
return False, str(error)
|
||||
|
||||
def get_restorable_plugin_instances(self, plugin_id: str) -> List[Dict[str, Any]]:
|
||||
"""列出该插件名下已停用、设置仍留存可恢复的分身。
|
||||
|
||||
停用只把启用位置假,业务参数与展示信息都还在那一行上;用户在创建分身时挑一个
|
||||
拿回来,比按后缀去猜哪个留有残留可靠得多。启用中的分身不在此列——它们的配置
|
||||
正被使用,摆进恢复选择器只会让人误以为能把一个活着的实例再建一遍。
|
||||
|
||||
:param plugin_id: 源插件ID
|
||||
:return: 每个可恢复分身的实例ID、后缀、展示信息与是否留有业务参数
|
||||
"""
|
||||
prefix_length = len(plugin_id)
|
||||
results: List[Dict[str, Any]] = []
|
||||
for instance in self._plugin_instance_store.for_source(plugin_id):
|
||||
if instance.is_enabled:
|
||||
continue
|
||||
instance_id = instance.instance_id
|
||||
results.append({
|
||||
"instance_id": instance_id,
|
||||
# 分身ID由源插件ID直接拼后缀而成,去掉前缀即还原用户当初填的后缀
|
||||
"suffix": (
|
||||
instance_id[prefix_length:]
|
||||
if instance_id.startswith(plugin_id)
|
||||
else instance_id
|
||||
),
|
||||
"plugin_name": instance.plugin_name,
|
||||
"plugin_desc": instance.plugin_desc,
|
||||
"has_config": self._plugin_config_store.has_config(instance_id),
|
||||
})
|
||||
return results
|
||||
|
||||
def get_plugin_instance_log_levels(self, plugin_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
查询插件全部实例(含本体)当前的日志等级设置
|
||||
|
||||
@@ -355,12 +355,48 @@ def build_plugin_runtime(
|
||||
) or []
|
||||
return plugin_id in installed
|
||||
|
||||
def instance_id_taken(instance_id: str) -> bool:
|
||||
"""判断一个候选实例 ID 是否已被占用。
|
||||
|
||||
创建分身的判存与自动分配后缀共用这一个判据,自动分配因此不可能挑中一个手填
|
||||
时会被拒绝的 ID。四条依据各自覆盖一类占用者,缺一条就会让新分身顶掉一个真实
|
||||
存在的插件身份:
|
||||
|
||||
* 类注册表——当前已装载的本体与分身;
|
||||
* 实例行——含已停用的分身与本体,它们的配置还留在行上,不是空位;
|
||||
* 安装清单——装过但此刻未装载的物理插件;
|
||||
* 插件包目录——卸载不删源码,磁盘上因此会留下不在前三者里的插件包,占了它的
|
||||
号会让那个插件以后再也装不回来(实例行的归属列对不上,写入直接被拒)。
|
||||
|
||||
判存不能只看运行态:源插件本次加载失败时,已有的同名分身会被判成「不存在」
|
||||
而放行,随后它的描述符被覆盖,再在回滚里连同配置一起删掉。``catalog.exists``
|
||||
不足以充当磁盘判据——它要从**运行中**的实例上取版本号,未装载的插件包一律
|
||||
报告不存在,因而这里直接看包目录。
|
||||
|
||||
:param instance_id: 候选实例 ID
|
||||
:return: 该 ID 是否已被占用
|
||||
"""
|
||||
if registry.plugin_class(instance_id) is not None:
|
||||
return True
|
||||
if instances.get(instance_id) is not None:
|
||||
return True
|
||||
if instances.get_host(instance_id) is not None:
|
||||
return True
|
||||
installed = environment.storage().read(
|
||||
SystemConfigKey.UserInstalledPlugins
|
||||
) or []
|
||||
if instance_id in installed:
|
||||
return True
|
||||
return (environment.plugins_root / instance_id.lower()).is_dir()
|
||||
|
||||
clone = PluginCloneService(
|
||||
plugin_class=registry.plugin_class,
|
||||
plugin_exists=catalog.exists,
|
||||
instance_id_taken=instance_id_taken,
|
||||
get_instance=instances.get,
|
||||
source_plugin_id=source_plugin_id,
|
||||
save_instance=instances.save,
|
||||
delete_instance=instances.delete,
|
||||
disable_instance=instances.disable,
|
||||
read_config=configs.read,
|
||||
save_config=lambda plugin_id, config: configs.write(
|
||||
plugin_id,
|
||||
|
||||
@@ -198,6 +198,17 @@ class PluginConfigStore:
|
||||
if key
|
||||
}
|
||||
|
||||
def has_config(self, instance_id: str) -> bool:
|
||||
"""判断该实例行上是否留有业务参数。
|
||||
|
||||
不复用 :meth:`read`:那里要求插件类当前已登记,而这里问的恰恰是已停用、因而
|
||||
不在类注册表里的实例——走 :meth:`read` 会让每一个可恢复分身都报告「没有配置」。
|
||||
|
||||
:param instance_id: 实例 ID
|
||||
:return: 该实例是否留有业务参数
|
||||
"""
|
||||
return bool(self._storage().read_config(instance_id))
|
||||
|
||||
def write(self, plugin_id: str, config: dict, force: bool = False) -> bool:
|
||||
"""保存配置,默认拒绝不存在插件的配置写入。"""
|
||||
if not force and not self._plugin_exists(plugin_id):
|
||||
|
||||
@@ -341,6 +341,7 @@ SCHEMA_EXPORTS = {
|
||||
'PerformanceSnapshot': ('app.schemas.monitoring', 'PerformanceSnapshot'),
|
||||
'Plugin': ('app.schemas.plugin', 'Plugin'),
|
||||
'PluginActionEventData': ('app.schemas.event', 'PluginActionEventData'),
|
||||
'PluginCloneOutcome': ('app.schemas.plugin', 'PluginCloneOutcome'),
|
||||
'PluginCloneRequest': ('app.schemas.plugin', 'PluginCloneRequest'),
|
||||
'PluginDashboard': ('app.schemas.plugin', 'PluginDashboard'),
|
||||
'PluginDashboardMetaItem': ('app.schemas.plugin', 'PluginDashboardMetaItem'),
|
||||
@@ -370,6 +371,7 @@ SCHEMA_EXPORTS = {
|
||||
'PluginReleaseItem': ('app.schemas.plugin', 'PluginReleaseItem'),
|
||||
'PluginReloadEventData': ('app.schemas.event', 'PluginReloadEventData'),
|
||||
'PluginRemoteInfo': ('app.schemas.plugin', 'PluginRemoteInfo'),
|
||||
'PluginRestorableInstance': ('app.schemas.plugin', 'PluginRestorableInstance'),
|
||||
'PluginRuntimeActionCapability': ('app.schemas.plugin', 'PluginRuntimeActionCapability'),
|
||||
'PluginRuntimeActionGroup': ('app.schemas.plugin', 'PluginRuntimeActionGroup'),
|
||||
'PluginRuntimeCapabilities': ('app.schemas.plugin', 'PluginRuntimeCapabilities'),
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Dict, List, Literal, Optional, Union
|
||||
|
||||
from pydantic import AfterValidator as _AfterValidator
|
||||
from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator
|
||||
from pydantic import BeforeValidator as _BeforeValidator
|
||||
from pydantic import PrivateAttr as _PrivateAttr
|
||||
from pydantic import computed_field as _computed_field
|
||||
|
||||
@@ -327,14 +328,32 @@ class PluginInstallOutcome(BaseModel):
|
||||
restart_required: bool = Field(description="本次依赖更新是否需要重启 MoviePilot 才能完成")
|
||||
|
||||
|
||||
def _blank_clone_suffix_to_none(value: object) -> object:
|
||||
"""把「没填后缀」的三种写法归一成同一个值。
|
||||
|
||||
前端的后缀输入框留空时可能整个字段不带、带 null,也可能带一个空串或只有空白;
|
||||
三者表达的都是「由服务端挑一个」。不归一的话空串会撞上格式校验,用户看到的是
|
||||
一条与他的操作对不上的格式错误,而不是自动分配。
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
return value
|
||||
|
||||
|
||||
_CloneSuffix = _Annotated[Optional[str], _BeforeValidator(_blank_clone_suffix_to_none)]
|
||||
|
||||
|
||||
class PluginCloneRequest(BaseModel):
|
||||
"""创建虚拟插件分身的请求参数。"""
|
||||
|
||||
suffix: str = Field(
|
||||
min_length=1,
|
||||
# 格式与长度在这一层判定:非法后缀拼出的实例 ID 用不了 Python 类名与路由段,
|
||||
# 等落库之后再报错意味着已经写进一行、还要靠回滚把它擦掉
|
||||
suffix: _CloneSuffix = Field(
|
||||
default=None,
|
||||
max_length=20,
|
||||
pattern=r"^[A-Za-z0-9]+$",
|
||||
description="追加到当前插件 ID 后的 ASCII 字母或数字后缀",
|
||||
description="追加到当前插件 ID 后的 ASCII 字母或数字后缀;留空时由服务端自动分配",
|
||||
)
|
||||
name: str = Field(default="", description="分身展示名称")
|
||||
description: str = Field(default="", description="分身展示描述")
|
||||
@@ -343,6 +362,34 @@ class PluginCloneRequest(BaseModel):
|
||||
default=None,
|
||||
description="兼容旧客户端保留,虚拟分身始终跟随源插件版本",
|
||||
)
|
||||
restore_previous: bool = Field(
|
||||
default=True,
|
||||
description="该后缀名下留有一个已停用的分身时是否沿用它的业务参数,为假时按源插件模板重建",
|
||||
)
|
||||
|
||||
|
||||
class PluginCloneOutcome(BaseModel): # type: ignore[misc]
|
||||
"""一次分身创建或恢复的结果。
|
||||
|
||||
实例 ID 必须回传:后缀可以由服务端自动分配,调用方因此再也算不出它,而后续要
|
||||
拿它去打开配置、刷新列表或跳转。
|
||||
"""
|
||||
|
||||
instance_id: str = Field(description="新建或恢复出来的分身实例 ID")
|
||||
|
||||
|
||||
class PluginRestorableInstance(BaseModel): # type: ignore[misc]
|
||||
"""一个已停用、其设置仍留存可被恢复的分身实例。
|
||||
|
||||
在册启用的分身不在此列:它们的配置正在被使用,拿来「恢复」没有意义,摆进选择器
|
||||
只会让用户误以为能把一个活着的实例再创建一遍。
|
||||
"""
|
||||
|
||||
instance_id: str = Field(description="分身实例 ID")
|
||||
suffix: str = Field(description="该实例相对源插件 ID 的后缀")
|
||||
plugin_name: Optional[str] = Field(default=None, description="停用前登记的展示名称")
|
||||
plugin_desc: Optional[str] = Field(default=None, description="停用前登记的展示描述")
|
||||
has_config: bool = Field(default=False, description="是否留有业务参数")
|
||||
|
||||
|
||||
class PluginSourceIdentity(BaseModel): # type: ignore[misc]
|
||||
|
||||
@@ -756,8 +756,8 @@ flowchart LR
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 1018 |
|
||||
| 内部导入边 | 8,650 |
|
||||
| Python 模块 | 1019 |
|
||||
| 内部导入边 | 8,667 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 53(债务已清零,53 条精确 containment) |
|
||||
|
||||
@@ -97,6 +97,30 @@ MCP 当前不会主动发送工具列表变更通知(`listChanged=false`)。
|
||||
时不会把它计入 `purged`。实例 ID 必须是合法的插件实例 ID(字母开头、仅字母与数字):
|
||||
它会被拼进插件数据目录路径,带路径分隔符或上跳的标识一律在动任何存储之前被拒绝。
|
||||
|
||||
### 插件分身的创建与恢复
|
||||
|
||||
`plugin.clone` 用源插件 ID 创建一个共享它源码的分身。`suffix` 可以不填:留空时由服务端
|
||||
挑一个最小可用序号(本体算第 1 个实例,分身从 2 起排),因而调用方**算不出**分身 ID,
|
||||
必须从回执的 `data.instance_id` 取。填了后缀则只接受 ASCII 字母与数字,且在请求解析阶段
|
||||
就会被拦下——非法后缀拼出的实例 ID 用不了 Python 类名与路由段,等落库再报错意味着已经
|
||||
写出一行、还要靠回滚擦掉。
|
||||
|
||||
占号判据不止看「此刻在不在跑」:当前已装载的类、实例表里的每一行(含已停用的分身与本体)、
|
||||
安装清单,以及磁盘上留存的插件包目录,任意一项命中都算已占用。自动分配与手填后缀共用这
|
||||
同一个判据,自动分配因此不会挑中一个手填时会被拒绝的 ID。
|
||||
|
||||
同一个后缀名下留有一个**已停用**的分身时,这次创建就是把那一行重新置为启用,而不是新建:
|
||||
停用从不删行,业务参数一直挂在上面,恢复因而拿回的正是用户当初留下的那份配置。展示名、
|
||||
描述与图标留空则沿用停用前登记的那一份,填了就按填的改。要改为「丢掉旧配置、按源插件模板
|
||||
重建」,把 `restore_previous` 置为 false——静默丢弃用户数据不能是默认行为。恢复失败时只
|
||||
把启用位退回停用,不会连同那一行与它的配置一起毁掉。
|
||||
|
||||
`plugin.clone.restorable` 用源插件 ID 列出该插件名下所有已停用、配置仍留存的分身,给出
|
||||
实例 ID、相对源插件 ID 的后缀、停用前登记的展示信息,以及是否留有业务参数。启用中的分身
|
||||
不在此列:它们的配置正被使用,拿来「恢复」没有意义。
|
||||
|
||||
被 `plugin.instance.purge` 彻底清理掉的分身也不在此列,而且不可恢复:清理删的是实例行
|
||||
本身,恢复所依赖的那份留存配置随之消失,同后缀再建只会得到一个全新的空白分身。
|
||||
|
||||
## 3.1 结构化 Agent 工具与完整参数合同
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"disposition_counts": {
|
||||
"alternate-auth-duplicate": 11,
|
||||
"consolidated": 71,
|
||||
"gateway": 225,
|
||||
"gateway": 226,
|
||||
"provider-skill": 12,
|
||||
"stream_or_binary": 10,
|
||||
"transport_or_identity": 66,
|
||||
@@ -18,10 +18,10 @@
|
||||
"reason": "The executor validates and expands this bounded source placeholder to one of tmdb, douban, bangumi, or anilist before calling the corresponding concrete OpenAPI route."
|
||||
}
|
||||
],
|
||||
"gateway_http_route_count": 226,
|
||||
"gateway_operation_count": 228,
|
||||
"matched_gateway_http_route_count": 225,
|
||||
"openapi_operation_count": 408,
|
||||
"gateway_http_route_count": 227,
|
||||
"gateway_operation_count": 229,
|
||||
"matched_gateway_http_route_count": 226,
|
||||
"openapi_operation_count": 409,
|
||||
"operations": [
|
||||
{
|
||||
"disposition": "consolidated",
|
||||
@@ -2039,6 +2039,20 @@
|
||||
"plugin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"disposition": "gateway",
|
||||
"method": "GET",
|
||||
"operation_ids": [
|
||||
"plugin.clone.restorable"
|
||||
],
|
||||
"owner": "moviepilot-api",
|
||||
"path": "/api/v1/plugin/clone/{plugin_id}/restorable",
|
||||
"reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.",
|
||||
"summary": "列出可恢复的已停用分身",
|
||||
"tags": [
|
||||
"plugin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"disposition": "ui_presentation",
|
||||
"method": "GET",
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
|
||||
## Result
|
||||
|
||||
- OpenAPI HTTP operations: **408**
|
||||
- Stable `moviepilot_api` operations: **228**
|
||||
- Exact HTTP routes used by the gateway: **226**
|
||||
- OpenAPI routes matched directly by the gateway: **225**
|
||||
- OpenAPI HTTP operations: **409**
|
||||
- Stable `moviepilot_api` operations: **229**
|
||||
- Exact HTTP routes used by the gateway: **227**
|
||||
- OpenAPI routes matched directly by the gateway: **226**
|
||||
- Bounded dynamic gateway routes: **1**
|
||||
- Every gateway operation has a generated English oneOf input contract in MCP `tools/list` and `skills/moviepilot-api/SKILL.md`.
|
||||
- Every non-gateway OpenAPI operation is listed below with an explicit ownership boundary; it is not silently callable through arbitrary URL/method input.
|
||||
@@ -19,7 +19,7 @@
|
||||
| :--- | ---: | :--- |
|
||||
| `alternate-auth-duplicate` | 11 | API-token compatibility duplicate of a bearer-authenticated capability. |
|
||||
| `consolidated` | 71 | Source/UI route represented by a stable aggregate Agent operation. |
|
||||
| `gateway` | 225 | Approved structured MoviePilot Agent operation. |
|
||||
| `gateway` | 226 | Approved structured MoviePilot Agent operation. |
|
||||
| `provider-skill` | 12 | Low-level downloader or media-server capability owned by a provider Skill. |
|
||||
| `stream_or_binary` | 10 | Streaming or binary response owned by a direct client transport. |
|
||||
| `transport_or_identity` | 66 | Authentication, protocol, callback, account, or conversation transport boundary. |
|
||||
@@ -193,6 +193,7 @@
|
||||
| `POST` | `/api/v1/openai/v1/responses` | openai | `transport_or_identity` | host-runtime | OpenAI compatible responses |
|
||||
| `GET` | `/api/v1/plugin/` | plugin | `gateway` | plugin.installed, plugin.market | 所有插件 |
|
||||
| `POST` | `/api/v1/plugin/clone/{plugin_id}` | plugin | `gateway` | plugin.clone | 创建插件分身 |
|
||||
| `GET` | `/api/v1/plugin/clone/{plugin_id}/restorable` | plugin | `gateway` | plugin.clone.restorable | 列出可恢复的已停用分身 |
|
||||
| `GET` | `/api/v1/plugin/dashboard/meta` | plugin | `ui_presentation` | host-ui | 获取所有插件仪表板元信息 |
|
||||
| `GET` | `/api/v1/plugin/dashboard/{plugin_id}` | plugin | `ui_presentation` | host-ui | 获取插件仪表板配置 |
|
||||
| `GET` | `/api/v1/plugin/dashboard/{plugin_id}/{key}` | plugin | `ui_presentation` | host-ui | 获取插件仪表板配置 |
|
||||
|
||||
@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 1018 / 8,650 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 1019 / 8,667 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
@@ -102,7 +102,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
|
||||
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 |
|
||||
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 |
|
||||
| 全量 mypy 历史债务 | 9,371 / 509 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
||||
| 全量 mypy 历史债务 | 9,370 / 509 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
||||
| Ruff 历史诊断 | 519 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
|
||||
|
||||
@@ -57,9 +57,9 @@ allowed-api-operations: >-
|
||||
system.network.targets system.network.test system.module.list system.module.catalog
|
||||
system.module.settings system.module.test plugin.market.sync_wiki plugin.runtime.status
|
||||
plugin.history plugin.releases plugin.ratings plugin.rating plugin.rating.submit
|
||||
plugin.statistics plugin.reset plugin.clone config.user.get config.public.get
|
||||
system.usage.statistics plugin.folders.get plugin.folders.update plugin.folder.create
|
||||
plugin.folder.update plugin.folder.delete plugin.folder.plugins.update
|
||||
plugin.statistics plugin.reset plugin.clone.restorable plugin.clone config.user.get
|
||||
config.public.get system.usage.statistics plugin.folders.get plugin.folders.update
|
||||
plugin.folder.create plugin.folder.update plugin.folder.delete plugin.folder.plugins.update
|
||||
plugin.folder.plugin.assign plugin.folder.plugin.remove plugin.loglevel.get plugin.loglevel.set
|
||||
plugin.loglevel.clear plugin.default_target.set plugin.default_target.clear
|
||||
plugin.instance.set_enabled plugin.instance.purge
|
||||
|
||||
@@ -13,10 +13,18 @@ Purpose: Inspect the runtime capabilities exposed by installed plugins.
|
||||
|
||||
### `plugin.clone`
|
||||
`POST /api/v1/plugin/clone/{plugin_id}`; policy effect: `external_side_effect`.
|
||||
Purpose: Create a configurable clone of one installed plugin.
|
||||
Purpose: Create a configurable clone of one installed plugin; leave suffix empty to let the server allocate the next free instance ID, and reusing the suffix of a disabled clone re-enables that clone together with its stored configuration.
|
||||
- `path_params`: `plugin_id*` (string): Exact installed or marketplace plugin ID.
|
||||
- `query`: none
|
||||
- `body`: `description` (string; default ``): Human-readable media, torrent, or subscription description.; `icon` (string|null): Icon name or URL used by a workflow, network target, plugin, or category.; `name` (string; default ``): Human-readable name of the site, storage item, subscription, or rule group.; `suffix*` (string; minimum length `1`): File suffix or extension matched by an automatic category rule.; `version` (string|null): Plugin release or schema version selected by the operation.
|
||||
- `body`: `description` (string; default ``): Human-readable media, torrent, or subscription description.; `icon` (string|null): Icon name or URL used by a workflow, network target, plugin, or category.; `name` (string; default ``): Human-readable name of the site, storage item, subscription, or rule group.; `restore_previous` (boolean; default `True`): When creating a plugin clone, reuse the stored configuration of a disabled clone that already holds the same suffix; false rebuilds that clone's configuration from the source plugin template.; `suffix` (string|null): File suffix or extension matched by an automatic category rule.; `version` (string|null): Plugin release or schema version selected by the operation.
|
||||
|
||||
### `plugin.clone.restorable`
|
||||
`GET /api/v1/plugin/clone/{plugin_id}/restorable`; policy effect: `safe_read`.
|
||||
Purpose: List one plugin's disabled clones whose configuration is still stored and can be brought back by creating a clone with the same suffix.
|
||||
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated.
|
||||
- `path_params`: `plugin_id*` (string): Exact installed or marketplace plugin ID.
|
||||
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
|
||||
- `body`: none
|
||||
|
||||
### `plugin.config.get`
|
||||
`GET /api/v1/plugin/form/{plugin_id}`; policy effect: `safe_read`.
|
||||
|
||||
@@ -1077,8 +1077,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 8650,
|
||||
"edge_sha256": "b127ad724b438d3a940f056676b637592f97903a38457b1c99842b56ea1deb0f",
|
||||
"edge_count": 8667,
|
||||
"edge_sha256": "0c4c36ff964813d64a77cf77fc905770298c4e00a48e0ffbc2f017a4d1ccce1f",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -2696,6 +2696,22 @@
|
||||
"app.api.endpoints.plugin -> app.startup",
|
||||
"app.api.endpoints.plugin -> app.startup.composition",
|
||||
"app.api.endpoints.plugin -> app.startup.composition.context",
|
||||
"app.api.endpoints.pluginclone -> app.api",
|
||||
"app.api.endpoints.pluginclone -> app.api.dependencies",
|
||||
"app.api.endpoints.pluginclone -> app.api.dependencies.auth",
|
||||
"app.api.endpoints.pluginclone -> app.api.endpoints",
|
||||
"app.api.endpoints.pluginclone -> app.api.endpoints.plugin",
|
||||
"app.api.endpoints.pluginclone -> app.api.principal",
|
||||
"app.api.endpoints.pluginclone -> app.api.response",
|
||||
"app.api.endpoints.pluginclone -> app.application",
|
||||
"app.api.endpoints.pluginclone -> app.application.plugin",
|
||||
"app.api.endpoints.pluginclone -> app.application.plugin.folders",
|
||||
"app.api.endpoints.pluginclone -> app.application.plugin.runtime",
|
||||
"app.api.endpoints.pluginclone -> app.runtime",
|
||||
"app.api.endpoints.pluginclone -> app.runtime.log",
|
||||
"app.api.endpoints.pluginclone -> app.schemas",
|
||||
"app.api.endpoints.pluginclone -> app.schemas.plugin",
|
||||
"app.api.endpoints.pluginclone -> app.schemas.response",
|
||||
"app.api.endpoints.pluginfolder -> app.api",
|
||||
"app.api.endpoints.pluginfolder -> app.api.dependencies",
|
||||
"app.api.endpoints.pluginfolder -> app.api.dependencies.auth",
|
||||
@@ -3147,6 +3163,7 @@
|
||||
"app.api.routers -> app.api.endpoints.notification",
|
||||
"app.api.routers -> app.api.endpoints.openai",
|
||||
"app.api.routers -> app.api.endpoints.plugin",
|
||||
"app.api.routers -> app.api.endpoints.pluginclone",
|
||||
"app.api.routers -> app.api.endpoints.plugininstance",
|
||||
"app.api.routers -> app.api.endpoints.pluginloglevel",
|
||||
"app.api.routers -> app.api.endpoints.plugintarget",
|
||||
@@ -9731,7 +9748,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 1018,
|
||||
"module_count": 1019,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -9918,6 +9935,7 @@
|
||||
"app.api.endpoints.notification",
|
||||
"app.api.endpoints.openai",
|
||||
"app.api.endpoints.plugin",
|
||||
"app.api.endpoints.pluginclone",
|
||||
"app.api.endpoints.pluginfolder",
|
||||
"app.api.endpoints.plugininstance",
|
||||
"app.api.endpoints.pluginloglevel",
|
||||
|
||||
@@ -495,7 +495,7 @@
|
||||
"app/api/endpoints/plugin.py": {
|
||||
"assignment": 1,
|
||||
"import-untyped": 1,
|
||||
"misc": 29,
|
||||
"misc": 28,
|
||||
"no-any-return": 5,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
|
||||
@@ -34,8 +34,8 @@ def test_api_operation_registry_matches_migration_batches() -> None:
|
||||
assert len(API_PARITY_OPERATION_SPECS) == 15
|
||||
assert len(API_MUSIC_OPERATION_SPECS) == 10
|
||||
assert len(API_SYSTEM_OPERATION_SPECS) == 7
|
||||
assert len(API_EXTENDED_OPERATION_SPECS) == 143
|
||||
assert len(API_OPERATION_SPECS) == 228
|
||||
assert len(API_EXTENDED_OPERATION_SPECS) == 144
|
||||
assert len(API_OPERATION_SPECS) == 229
|
||||
assert {spec.operation_id for spec in API_OPERATION_SPECS} == set(API_OPERATION_ROUTES)
|
||||
assert {
|
||||
"download.list",
|
||||
|
||||
@@ -4,8 +4,10 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
import app.agent.orchestrator as agent_module
|
||||
from app.adapters.system import resource as resource_module
|
||||
from app.adapters.system.resource import configure_resource_version_provider
|
||||
from app.agent.memory import MemoryManager
|
||||
from app.agent.orchestrator import (
|
||||
from app.agent.orchestrator import ( # pylint: disable=no-name-in-module
|
||||
AGENT_SESSION_QUEUE_MAX_SIZE,
|
||||
AgentManager,
|
||||
AgentManagerQueueFullError,
|
||||
@@ -22,6 +24,21 @@ from app.startup.initializers import agent as agent_initializer
|
||||
from app.startup.initializers import modules as modules_initializer
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_resource_version_provider():
|
||||
"""还原进程级的站点资源版本读取器。
|
||||
|
||||
本文件有用例把 ``SitesHelper`` 桩成 MagicMock 后去跑真实的 ``init_modules``,
|
||||
而组合根会把那个桩对象捕获进 ``_resource_version_provider`` 这个进程级闭包里。
|
||||
``monkeypatch`` 只还原它自己改过的 ``modules_initializer.SitesHelper`` 属性,
|
||||
还不掉已经被闭包捕获的那份引用;不显式撤销,同一进程内后续任何读取站点资源
|
||||
版本的用例都会拿到 MagicMock,而它既不是字符串也无法参与版本比较。
|
||||
"""
|
||||
previous = resource_module._resource_version_provider
|
||||
yield
|
||||
configure_resource_version_provider(previous)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_web_agent_background_tasks_are_cancelled_and_drained() -> None:
|
||||
"""Web Agent 任务关闭后不得继续占用循环或提交晚到的快照。"""
|
||||
|
||||
@@ -155,7 +155,7 @@ async def test_bundled_moviepilot_api_skill_loads_complete_contract() -> None:
|
||||
assert payload["content_limit_bytes"] == MAX_SKILL_CONTENT_BYTES
|
||||
assert payload["truncated"] is False
|
||||
assert payload["truncation_message"] is None
|
||||
assert len(payload["skill"]["allowed_api_operations"]) == 228
|
||||
assert len(payload["skill"]["allowed_api_operations"]) == 229
|
||||
assert "## API Category Index" in payload["content"]
|
||||
assert "### `workflow.update`" not in payload["content"]
|
||||
assert "api/workflow.md" in payload["supporting_files"]
|
||||
|
||||
612
tests/test_plugin_clone_experience.py
Normal file
612
tests/test_plugin_clone_experience.py
Normal file
@@ -0,0 +1,612 @@
|
||||
"""分身创建体验测试:自动分配后缀、按 ID 恢复已停用的分身与提交前的后缀校验。
|
||||
|
||||
这些行为大多只在 ``build_plugin_runtime`` 装配出来的整体上成立:分身服务自己只认注入
|
||||
进来的端口,「哪些 ID 算被占用」是组合根的决定,因而占用判据必须在这一层验证,而不是
|
||||
在用例里另接一套判据自证。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.runtime.extensions.plugin.manager import PluginManager
|
||||
from app.runtime.extensions.plugin.runtime import (
|
||||
PluginRuntime,
|
||||
PluginRuntimeEnvironment,
|
||||
build_plugin_runtime,
|
||||
)
|
||||
from app.runtime.extensions.plugin.storage import (
|
||||
PluginInstanceDirectory,
|
||||
PluginStorage,
|
||||
)
|
||||
from app.schemas.plugin import PluginCloneRequest, PluginInstance, PluginRuntimeStatus
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
class DemoPlugin:
|
||||
"""充当源插件本体的最小插件类,只需要能被注册表登记。"""
|
||||
|
||||
|
||||
def _logger() -> SimpleNamespace:
|
||||
"""提供运行时构造所需的最小日志对象。"""
|
||||
return SimpleNamespace(
|
||||
debug=lambda *_args, **_kwargs: None,
|
||||
info=lambda *_args, **_kwargs: None,
|
||||
warning=lambda *_args, **_kwargs: None,
|
||||
error=lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
|
||||
class _World:
|
||||
"""一套全内存的插件世界,暴露用例要断言的每一份真实状态。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""建立空的实例表、配置表与运行态记录。"""
|
||||
self.rows: dict[str, PluginInstance] = {}
|
||||
self.configs: dict[str, dict] = {}
|
||||
self.installed: list[str] = []
|
||||
self.plugins_root = Path("/nonexistent-plugins-root")
|
||||
self.reloaded: list[str] = []
|
||||
self.removed: list[str] = []
|
||||
self.status = PluginRuntimeStatus.ACTIVE
|
||||
self.probe_delay = 0.0
|
||||
self.runtime: Optional[PluginRuntime] = None
|
||||
|
||||
def clone(self, **kwargs: Any) -> tuple[bool, str]:
|
||||
"""按默认展示信息发起一次分身创建。"""
|
||||
assert self.runtime is not None
|
||||
params: dict[str, Any] = {"name": "", "description": ""}
|
||||
params.update(kwargs)
|
||||
return self.runtime.clone.clone(**params)
|
||||
|
||||
def restorable(self) -> list[dict[str, Any]]:
|
||||
"""调用管理器上的可恢复清单投影。
|
||||
|
||||
``get_restorable_plugin_instances`` 是一段纯投影,只用到实例表与配置表两个
|
||||
属性;用一个替身 self 调用它,既验证真实投影逻辑,又不必把 PluginManager
|
||||
这个单例连同它的文件监控与线程池一起启动起来。
|
||||
"""
|
||||
assert self.runtime is not None
|
||||
stand_in = SimpleNamespace(
|
||||
_plugin_instance_store=self.runtime.instances,
|
||||
_plugin_config_store=self.runtime.configs,
|
||||
)
|
||||
return PluginManager.get_restorable_plugin_instances(stand_in, "DemoPlugin")
|
||||
|
||||
|
||||
def _build_world(
|
||||
*,
|
||||
rows: Optional[dict[str, PluginInstance]] = None,
|
||||
configs: Optional[dict[str, dict]] = None,
|
||||
installed: Optional[list[str]] = None,
|
||||
plugins_root: Optional[Path] = None,
|
||||
) -> _World:
|
||||
"""按给定实例行、配置、安装清单与插件包目录装配一个全内存运行时。"""
|
||||
world = _World()
|
||||
world.rows.update(rows or {})
|
||||
world.configs.update(configs or {})
|
||||
world.installed.extend(installed or [])
|
||||
if plugins_root is not None:
|
||||
world.plugins_root = plugins_root
|
||||
|
||||
values: dict = {SystemConfigKey.UserInstalledPlugins: world.installed}
|
||||
|
||||
def _read_row(instance_id: str) -> Optional[PluginInstance]:
|
||||
"""读取实例行,可按需放慢以拉开并发创建的竞争窗口。"""
|
||||
if world.probe_delay:
|
||||
time.sleep(world.probe_delay)
|
||||
return world.rows.get(instance_id)
|
||||
|
||||
def _set_enabled(instance_id: str, is_enabled: bool) -> bool:
|
||||
"""就地翻转启用位,行不存在时报告未写入。"""
|
||||
record = world.rows.get(instance_id)
|
||||
if record is None:
|
||||
return False
|
||||
world.rows[instance_id] = record.model_copy(update={"is_enabled": is_enabled})
|
||||
return True
|
||||
|
||||
storage = PluginStorage(
|
||||
read=values.get,
|
||||
write=values.__setitem__,
|
||||
read_config=world.configs.get,
|
||||
write_config=lambda instance_id, config: world.configs.__setitem__(
|
||||
instance_id,
|
||||
config,
|
||||
),
|
||||
delete_config=lambda instance_id: world.configs.pop(instance_id, None) is not None,
|
||||
)
|
||||
directory = PluginInstanceDirectory(
|
||||
get=_read_row,
|
||||
list_all=lambda: list(world.rows.values()),
|
||||
list_by_source=lambda source_plugin_id: [
|
||||
record
|
||||
for record in world.rows.values()
|
||||
if record.source_plugin_id == source_plugin_id
|
||||
],
|
||||
save=lambda instance: world.rows.__setitem__(instance.instance_id, instance),
|
||||
delete=lambda instance_id: world.rows.pop(instance_id, None) is not None,
|
||||
list_enabled=lambda: [
|
||||
record for record in world.rows.values() if record.is_enabled
|
||||
],
|
||||
set_enabled=_set_enabled,
|
||||
)
|
||||
|
||||
def _reload(plugin_id: str) -> PluginRuntimeStatus:
|
||||
"""记录一次定向重载并回报当前世界设定的结果。"""
|
||||
world.reloaded.append(plugin_id)
|
||||
return world.status
|
||||
|
||||
environment = PluginRuntimeEnvironment(
|
||||
plugins_root=world.plugins_root,
|
||||
storage=lambda: storage,
|
||||
instance_directory=lambda: directory,
|
||||
system=lambda: SimpleNamespace(),
|
||||
database=lambda: SimpleNamespace(),
|
||||
catalog_factory=lambda _mapper: SimpleNamespace(),
|
||||
import_preparer=lambda **_kwargs: None,
|
||||
import_scanner=lambda **_kwargs: None,
|
||||
auth_level=lambda: 0,
|
||||
remote_entry=lambda _plugin_id, _page: "",
|
||||
development=lambda: False,
|
||||
logger=_logger(),
|
||||
set_default_target=lambda _plugin_id, _instance_id: True,
|
||||
clear_default_target=lambda _plugin_id: None,
|
||||
)
|
||||
runtime = build_plugin_runtime(
|
||||
SimpleNamespace(
|
||||
reload_plugin=_reload,
|
||||
remove_plugin=world.removed.append,
|
||||
get_plugin_remote_entry=lambda _plugin_id, _page: "",
|
||||
_run_file_watcher=lambda: None,
|
||||
get_plugins_from_market=lambda *_args, **_kwargs: None,
|
||||
async_get_plugins_from_market=lambda *_args, **_kwargs: None,
|
||||
),
|
||||
environment,
|
||||
tool_build_max_attempts=1,
|
||||
)
|
||||
# 源插件本体必须在类注册表里:分身共享它的源码,它缺席就谈不上给它建分身
|
||||
runtime.registry.classes["DemoPlugin"] = DemoPlugin
|
||||
world.runtime = runtime
|
||||
return world
|
||||
|
||||
|
||||
def _disabled_clone(instance_id: str, **fields: Any) -> PluginInstance:
|
||||
"""构造一行已停用的分身,模拟「停用后设置仍留存」的状态。"""
|
||||
payload: dict[str, Any] = {
|
||||
"instance_id": instance_id,
|
||||
"source_plugin_id": "DemoPlugin",
|
||||
"is_enabled": False,
|
||||
}
|
||||
payload.update(fields)
|
||||
return PluginInstance(**payload)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 自动分配后缀
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_auto_allocated_suffix_starts_right_after_the_host_instance():
|
||||
"""不填后缀时从 2 起算:本体在用户眼里就是第 1 个实例,分身接着往下排。"""
|
||||
world = _build_world()
|
||||
|
||||
success, instance_id = world.clone(plugin_id="DemoPlugin")
|
||||
|
||||
assert success is True
|
||||
assert instance_id == "DemoPlugin2"
|
||||
assert world.rows["DemoPlugin2"].is_enabled is True
|
||||
|
||||
|
||||
def test_auto_allocated_suffix_skips_a_disabled_clone_row():
|
||||
"""已停用的分身同样占号,自动分配不得挑中它。
|
||||
|
||||
重用它的 ID 是「恢复」而不是新建:用户点的是「新建一个」,却拿到上一个分身留下
|
||||
的业务参数,等于凭空继承了一份他没打算要的配置。
|
||||
"""
|
||||
world = _build_world(
|
||||
rows={"DemoPlugin2": _disabled_clone("DemoPlugin2")},
|
||||
configs={"DemoPlugin2": {"token": "旧的"}},
|
||||
)
|
||||
|
||||
success, instance_id = world.clone(plugin_id="DemoPlugin")
|
||||
|
||||
assert success is True
|
||||
assert instance_id == "DemoPlugin3"
|
||||
# 旧行原样留着,没有被这次新建顶掉
|
||||
assert world.rows["DemoPlugin2"].is_enabled is False
|
||||
assert world.configs["DemoPlugin2"] == {"token": "旧的"}
|
||||
|
||||
|
||||
def test_auto_allocated_suffix_skips_an_enabled_clone_row():
|
||||
"""在册的分身占号,自动分配跳过它继续往下找。"""
|
||||
world = _build_world(
|
||||
rows={
|
||||
"DemoPlugin2": _disabled_clone("DemoPlugin2", is_enabled=True),
|
||||
"DemoPlugin3": _disabled_clone("DemoPlugin3", is_enabled=True),
|
||||
},
|
||||
)
|
||||
|
||||
success, instance_id = world.clone(plugin_id="DemoPlugin")
|
||||
|
||||
assert success is True
|
||||
assert instance_id == "DemoPlugin4"
|
||||
|
||||
|
||||
def test_auto_allocated_suffix_skips_an_installed_physical_plugin():
|
||||
"""装过但此刻未装载的物理插件同样占号。
|
||||
|
||||
它不在类注册表里,只看运行态会把 ``DemoPlugin2`` 判成空位,新分身于是顶着一个
|
||||
真实插件的身份建出来,那个插件下次装载时两者撞在同一个 ID 上。
|
||||
"""
|
||||
world = _build_world(installed=["DemoPlugin", "DemoPlugin2"])
|
||||
|
||||
success, instance_id = world.clone(plugin_id="DemoPlugin")
|
||||
|
||||
assert success is True
|
||||
assert instance_id == "DemoPlugin3"
|
||||
|
||||
|
||||
def test_auto_allocated_suffix_skips_a_plugin_package_present_on_disk(tmp_path):
|
||||
"""磁盘上留着同名插件包时不得占用该号,否则那个插件以后再也装不回来。
|
||||
|
||||
卸载不删源码,包目录会一直留着;它既不在类注册表里,也不在安装清单里,只看这两
|
||||
处会把它判成空位。分身占了这个号之后,那个插件重装时实例行的归属列对不上,写入
|
||||
会被持久化层直接拒绝。
|
||||
"""
|
||||
(tmp_path / "demoplugin2").mkdir()
|
||||
world = _build_world(plugins_root=tmp_path)
|
||||
|
||||
success, instance_id = world.clone(plugin_id="DemoPlugin")
|
||||
|
||||
assert success is True
|
||||
assert instance_id == "DemoPlugin3"
|
||||
|
||||
|
||||
def test_auto_allocated_suffix_skips_a_currently_loaded_plugin_class():
|
||||
"""运行期已登记同名类时不得占用该号。"""
|
||||
world = _build_world()
|
||||
world.runtime.registry.classes["DemoPlugin2"] = DemoPlugin
|
||||
|
||||
success, instance_id = world.clone(plugin_id="DemoPlugin")
|
||||
|
||||
assert success is True
|
||||
assert instance_id == "DemoPlugin3"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank", ["", " ", None])
|
||||
def test_blank_suffix_falls_back_to_automatic_allocation(blank):
|
||||
"""后缀留空的几种写法都走自动分配,而不是拼出一个等于源插件 ID 的实例。"""
|
||||
world = _build_world()
|
||||
|
||||
success, instance_id = world.clone(plugin_id="DemoPlugin", suffix=blank)
|
||||
|
||||
assert success is True
|
||||
assert instance_id == "DemoPlugin2"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 判存:同一个占用判据挡住显式后缀
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_explicit_suffix_colliding_with_an_enabled_clone_is_rejected():
|
||||
"""显式后缀撞上在册分身时拒绝,且不得覆盖它的描述符。"""
|
||||
world = _build_world(
|
||||
rows={"DemoPlugin2": _disabled_clone("DemoPlugin2", is_enabled=True, plugin_name="在跑的")},
|
||||
)
|
||||
|
||||
success, message = world.clone(plugin_id="DemoPlugin", suffix="2", name="新的")
|
||||
|
||||
assert success is False
|
||||
assert "已存在" in message
|
||||
assert world.rows["DemoPlugin2"].plugin_name == "在跑的"
|
||||
|
||||
|
||||
def test_explicit_suffix_colliding_with_another_sources_clone_is_rejected():
|
||||
"""归属对不上的停用行不算可恢复:它是别的源插件的分身,不能被这里改嫁。"""
|
||||
world = _build_world(
|
||||
rows={
|
||||
"DemoPlugin2": PluginInstance(
|
||||
instance_id="DemoPlugin2",
|
||||
source_plugin_id="OtherPlugin",
|
||||
is_enabled=False,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
success, message = world.clone(plugin_id="DemoPlugin", suffix="2")
|
||||
|
||||
assert success is False
|
||||
assert "已存在" in message
|
||||
assert world.rows["DemoPlugin2"].source_plugin_id == "OtherPlugin"
|
||||
|
||||
|
||||
def test_a_clone_cannot_be_used_as_a_clone_source():
|
||||
"""分身不能再生分身:它共享源插件的源码,自己并不是一份源码。"""
|
||||
world = _build_world(rows={"DemoPlugin2": _disabled_clone("DemoPlugin2", is_enabled=True)})
|
||||
world.runtime.registry.classes["DemoPlugin2"] = DemoPlugin
|
||||
|
||||
success, message = world.clone(plugin_id="DemoPlugin2", suffix="3")
|
||||
|
||||
assert success is False
|
||||
assert "不能作为分身来源" in message
|
||||
assert "DemoPlugin" in message
|
||||
|
||||
|
||||
def test_repeating_the_same_create_is_rejected_rather_than_overwriting():
|
||||
"""同一个后缀重复提交两次,第二次确定地被判成已存在。"""
|
||||
world = _build_world()
|
||||
|
||||
first = world.clone(plugin_id="DemoPlugin", suffix="Work", name="第一次")
|
||||
second = world.clone(plugin_id="DemoPlugin", suffix="Work", name="第二次")
|
||||
|
||||
assert first == (True, "DemoPluginwork")
|
||||
assert second[0] is False
|
||||
assert "已存在" in second[1]
|
||||
assert world.rows["DemoPluginwork"].plugin_name == "第一次"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 按 ID 恢复已停用的分身
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_restoring_a_disabled_clone_brings_back_its_config_and_display_info():
|
||||
"""按 ID 恢复就是把那一行重新置为启用,配置与展示信息原样回来。
|
||||
|
||||
停用从不删行,业务参数一直挂在上面;恢复因而不能是「新建一行再按源插件模板铺一份
|
||||
配置」,那会把用户特意留着的东西盖掉。
|
||||
"""
|
||||
world = _build_world(
|
||||
rows={
|
||||
"DemoPlugin2": _disabled_clone(
|
||||
"DemoPlugin2",
|
||||
plugin_name="夜间任务",
|
||||
plugin_desc="只在夜里跑",
|
||||
plugin_icon="night.png",
|
||||
),
|
||||
},
|
||||
configs={
|
||||
"DemoPlugin": {"token": "源插件的"},
|
||||
"DemoPlugin2": {"token": "分身自己的", "cron": "0 3 * * *"},
|
||||
},
|
||||
)
|
||||
|
||||
success, instance_id = world.clone(plugin_id="DemoPlugin", suffix="2")
|
||||
|
||||
assert (success, instance_id) == (True, "DemoPlugin2")
|
||||
restored = world.rows["DemoPlugin2"]
|
||||
assert restored.is_enabled is True
|
||||
assert restored.plugin_name == "夜间任务"
|
||||
assert restored.plugin_desc == "只在夜里跑"
|
||||
assert restored.plugin_icon == "night.png"
|
||||
# 配置没有被源插件模板顶掉,这正是用户要拿回来的东西
|
||||
assert world.configs["DemoPlugin2"] == {"token": "分身自己的", "cron": "0 3 * * *"}
|
||||
assert world.reloaded == ["DemoPlugin2"]
|
||||
|
||||
|
||||
def test_restoring_accepts_a_new_display_name_while_keeping_the_config():
|
||||
"""恢复时可以顺手改名,改的只是展示信息,业务参数仍然沿用。"""
|
||||
world = _build_world(
|
||||
rows={"DemoPlugin2": _disabled_clone("DemoPlugin2", plugin_name="旧名字")},
|
||||
configs={"DemoPlugin2": {"token": "留着"}},
|
||||
)
|
||||
|
||||
success, _instance_id = world.clone(
|
||||
plugin_id="DemoPlugin",
|
||||
suffix="2",
|
||||
name="新名字",
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert world.rows["DemoPlugin2"].plugin_name == "新名字"
|
||||
assert world.configs["DemoPlugin2"] == {"token": "留着"}
|
||||
|
||||
|
||||
def test_restore_previous_false_rebuilds_the_config_from_the_source_template():
|
||||
"""显式要求全新时按源插件模板重建配置,并保持业务开关关闭待用户配置。"""
|
||||
world = _build_world(
|
||||
rows={"DemoPlugin2": _disabled_clone("DemoPlugin2", plugin_name="旧名字")},
|
||||
configs={
|
||||
"DemoPlugin": {"enable": True, "token": "源插件的"},
|
||||
"DemoPlugin2": {"token": "该被丢弃"},
|
||||
},
|
||||
)
|
||||
|
||||
success, _instance_id = world.clone(
|
||||
plugin_id="DemoPlugin",
|
||||
suffix="2",
|
||||
name="全新",
|
||||
restore_previous=False,
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert world.configs["DemoPlugin2"] == {
|
||||
"enable": False,
|
||||
"enabled": False,
|
||||
"token": "源插件的",
|
||||
}
|
||||
assert world.rows["DemoPlugin2"].plugin_name == "全新"
|
||||
|
||||
|
||||
def test_failed_restore_only_puts_the_row_back_to_disabled():
|
||||
"""恢复失败只把启用位退回停用,不得连同用户留存的配置一起毁掉。
|
||||
|
||||
那一行是用户特意留着的,不是本次创建的产物;一次加载失败就删掉它,等于让用户为
|
||||
一个可重试的故障付出丢数据的代价。
|
||||
"""
|
||||
world = _build_world(
|
||||
rows={"DemoPlugin2": _disabled_clone("DemoPlugin2", plugin_name="夜间任务")},
|
||||
configs={"DemoPlugin2": {"token": "必须留着"}},
|
||||
)
|
||||
world.status = PluginRuntimeStatus.LOAD_FAILED
|
||||
|
||||
success, message = world.clone(plugin_id="DemoPlugin", suffix="2")
|
||||
|
||||
assert success is False
|
||||
assert "加载失败" in message
|
||||
assert world.rows["DemoPlugin2"].is_enabled is False
|
||||
assert world.rows["DemoPlugin2"].plugin_name == "夜间任务"
|
||||
assert world.configs["DemoPlugin2"] == {"token": "必须留着"}
|
||||
assert world.removed == ["DemoPlugin2"]
|
||||
|
||||
|
||||
def test_failed_fresh_creation_still_removes_the_row_it_created():
|
||||
"""新建失败仍按老规矩连行带配置抹掉:它整个是本次的产物,留着只是垃圾。"""
|
||||
world = _build_world(configs={"DemoPlugin": {"token": "源插件的"}})
|
||||
world.status = PluginRuntimeStatus.LOAD_FAILED
|
||||
|
||||
success, message = world.clone(plugin_id="DemoPlugin", suffix="Work")
|
||||
|
||||
assert success is False
|
||||
assert "加载失败" in message
|
||||
assert "DemoPluginwork" not in world.rows
|
||||
assert "DemoPluginwork" not in world.configs
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 校验在提交前拦截
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"suffix",
|
||||
["a-b", "带中文", "work!", "with space", "x" * 21],
|
||||
)
|
||||
def test_illegal_suffix_is_rejected_by_the_request_schema(suffix):
|
||||
"""非法后缀在请求解析阶段就被拦下,根本到不了会写库的那一层。"""
|
||||
with pytest.raises(ValidationError):
|
||||
PluginCloneRequest(suffix=suffix)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank", ["", " ", None])
|
||||
def test_blank_suffix_is_normalized_to_automatic_allocation_by_the_schema(blank):
|
||||
"""留空的三种写法在请求层归一为 None,不会撞上格式校验。"""
|
||||
assert PluginCloneRequest(suffix=blank).suffix is None
|
||||
|
||||
|
||||
def test_request_schema_trims_and_keeps_a_legal_suffix():
|
||||
"""合法后缀去掉首尾空白后原样保留,大小写留给运行时归一。"""
|
||||
assert PluginCloneRequest(suffix=" Work ").suffix == "Work"
|
||||
|
||||
|
||||
def test_restore_previous_defaults_to_reusing_the_stored_settings():
|
||||
"""默认沿用留存设置:静默丢弃用户数据不能是默认行为。"""
|
||||
assert PluginCloneRequest().restore_previous is True
|
||||
|
||||
|
||||
def test_an_overlong_composed_instance_id_is_rejected_before_any_write():
|
||||
"""拼出来的实例 ID 超长时在落库之前就被拒绝,不留下半个实例。
|
||||
|
||||
非法 ID 若等到持久化层才报错,意味着已经写出一行、还要靠回滚去擦掉它;把判定
|
||||
提到构造阶段,失败路径上根本没有东西需要清理。
|
||||
"""
|
||||
long_id = "D" + "e" * 119
|
||||
world = _build_world()
|
||||
world.runtime.registry.classes[long_id] = DemoPlugin
|
||||
|
||||
success, message = world.clone(plugin_id=long_id, suffix="x" * 20)
|
||||
|
||||
assert success is False
|
||||
assert "不合法" in message
|
||||
assert world.rows == {}
|
||||
assert world.configs == {}
|
||||
assert world.removed == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 并发创建
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_concurrent_auto_allocation_never_hands_out_the_same_instance_id():
|
||||
"""两个并发的自动分配各自拿到一个号,不会落到同一行上。
|
||||
|
||||
「挑一个没被占用的 ID」与「把它占下来」之间存在窗口;用例把占用探测放慢来把窗口
|
||||
拉开,没有占位互斥时两个线程会选出同一个 ID,后写的顶掉先写的。
|
||||
"""
|
||||
world = _build_world()
|
||||
world.probe_delay = 0.005
|
||||
barrier = threading.Barrier(2)
|
||||
results: list[tuple[bool, str]] = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def _create() -> None:
|
||||
"""两个线程在同一时刻发起创建。"""
|
||||
barrier.wait(timeout=5)
|
||||
outcome = world.clone(plugin_id="DemoPlugin")
|
||||
with lock:
|
||||
results.append(outcome)
|
||||
|
||||
threads = [threading.Thread(target=_create) for _ in range(2)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(timeout=10)
|
||||
|
||||
assert all(success for success, _ in results)
|
||||
assert sorted(instance_id for _, instance_id in results) == [
|
||||
"DemoPlugin2",
|
||||
"DemoPlugin3",
|
||||
]
|
||||
assert sorted(world.rows) == ["DemoPlugin2", "DemoPlugin3"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 可恢复清单
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_restorable_listing_only_reports_disabled_clones():
|
||||
"""在册的分身与本体那一行都不在恢复清单里。
|
||||
|
||||
活着的分身配置正被使用,摆进恢复选择器只会让人误以为能把它再创建一遍;本体不是
|
||||
分身,也没有「恢复成分身」这回事。
|
||||
"""
|
||||
world = _build_world(
|
||||
rows={
|
||||
"DemoPlugin": PluginInstance(
|
||||
instance_id="DemoPlugin",
|
||||
source_plugin_id="DemoPlugin",
|
||||
is_enabled=False,
|
||||
),
|
||||
"DemoPlugin2": _disabled_clone("DemoPlugin2", is_enabled=True),
|
||||
"DemoPlugin3": _disabled_clone("DemoPlugin3"),
|
||||
},
|
||||
)
|
||||
|
||||
assert [item["instance_id"] for item in world.restorable()] == ["DemoPlugin3"]
|
||||
|
||||
|
||||
def test_restorable_listing_reports_suffix_display_info_and_stored_config():
|
||||
"""清单给出后缀、展示信息与是否留有业务参数,供恢复选择器直接渲染。"""
|
||||
world = _build_world(
|
||||
rows={
|
||||
"DemoPluginwork": _disabled_clone(
|
||||
"DemoPluginwork",
|
||||
plugin_name="工作实例",
|
||||
plugin_desc="独立配置",
|
||||
),
|
||||
"DemoPlugin9": _disabled_clone("DemoPlugin9"),
|
||||
},
|
||||
configs={"DemoPluginwork": {"token": "留着"}},
|
||||
)
|
||||
|
||||
listing = {item["instance_id"]: item for item in world.restorable()}
|
||||
|
||||
assert listing["DemoPluginwork"] == {
|
||||
"instance_id": "DemoPluginwork",
|
||||
"suffix": "work",
|
||||
"plugin_name": "工作实例",
|
||||
"plugin_desc": "独立配置",
|
||||
"has_config": True,
|
||||
}
|
||||
# 停用的实例不在类注册表里,「有没有配置」不能走要求插件在册的读取口去问
|
||||
assert listing["DemoPlugin9"]["has_config"] is False
|
||||
@@ -10,6 +10,7 @@ from starlette.responses import Response
|
||||
|
||||
from app import schemas
|
||||
from app.api.endpoints import plugin as plugin_endpoint
|
||||
from app.api.endpoints import pluginclone as plugin_clone_endpoint
|
||||
from app.api.endpoints import pluginfolder as plugin_folders_endpoint
|
||||
from app.api.endpoints.plugin import (
|
||||
plugin_capabilities,
|
||||
@@ -1317,11 +1318,11 @@ def test_sealed_http_clone_rejects_before_runtime_and_registration(monkeypatch):
|
||||
plugin_manager.mutation.side_effect = admission.hold
|
||||
register = MagicMock()
|
||||
add_to_folder = MagicMock()
|
||||
monkeypatch.setattr(plugin_endpoint, "get_plugin_manager", lambda: plugin_manager)
|
||||
monkeypatch.setattr(plugin_endpoint, "register_plugin", register)
|
||||
monkeypatch.setattr(plugin_endpoint, "add_clone_to_plugin_folder", add_to_folder)
|
||||
monkeypatch.setattr(plugin_clone_endpoint, "get_plugin_manager", lambda: plugin_manager)
|
||||
monkeypatch.setattr(plugin_clone_endpoint, "register_plugin", register)
|
||||
monkeypatch.setattr(plugin_clone_endpoint, "add_clone_to_plugin_folder", add_to_folder)
|
||||
|
||||
result = plugin_endpoint.clone_plugin(
|
||||
result = plugin_clone_endpoint.clone_plugin(
|
||||
"DemoPlugin",
|
||||
schemas.PluginCloneRequest(suffix="Work"),
|
||||
None,
|
||||
|
||||
@@ -656,13 +656,15 @@ def test_clone_service_persists_descriptor_without_copying_source_package():
|
||||
|
||||
service = PluginCloneService(
|
||||
plugin_class=lambda plugin_id: DemoPlugin if plugin_id == "DemoPlugin" else None,
|
||||
plugin_exists=lambda plugin_id: plugin_id in instances,
|
||||
instance_id_taken=lambda plugin_id: plugin_id in instances,
|
||||
get_instance=instances.get,
|
||||
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,
|
||||
disable_instance=lambda plugin_id: plugin_id in instances,
|
||||
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,
|
||||
@@ -704,13 +706,15 @@ def test_clone_service_rolls_back_descriptor_and_config_after_load_failure():
|
||||
|
||||
service = PluginCloneService(
|
||||
plugin_class=lambda _plugin_id: DemoPlugin,
|
||||
plugin_exists=lambda plugin_id: plugin_id in instances,
|
||||
instance_id_taken=lambda plugin_id: plugin_id in instances,
|
||||
get_instance=instances.get,
|
||||
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,
|
||||
disable_instance=lambda plugin_id: plugin_id in instances,
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user