mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 12:24:39 +08:00
feat(plugin): skip invalid plugin instance records during read operations
This commit is contained in:
@@ -14,7 +14,6 @@ from app.runtime.extensions.plugin.database import PluginDatabase
|
||||
from app.schemas.plugin import PluginInstance
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
ConfigReader = Callable[[Any], Any]
|
||||
ConfigWriter = Callable[[Any, Any], Any]
|
||||
AsyncConfigWriter = Callable[[Any, Any], Awaitable[Any]]
|
||||
@@ -293,6 +292,9 @@ class PluginInstanceDirectory:
|
||||
``list_all``/``get`` 一律返回全部登记行(含停用的),``list_enabled`` 才是运行期
|
||||
装载的取数口:是否实例化由 ``is_enabled`` 单独表达,读取口不替调用方把停用的行
|
||||
藏起来——藏起来会让卡片、卸载守卫和默认目标候选一并丢掉这些行。
|
||||
|
||||
历史脏行如果无法投影为运行时 ``PluginInstance``,由启动组合根的读取适配器记录并
|
||||
跳过;本端口仍只向上层暴露可安全使用的实例描述,避免单个旧插件记录阻断整个启动。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.adapters.external.market import (
|
||||
LOCAL_REPO_PREFIX,
|
||||
@@ -258,21 +259,46 @@ def _prime_plugin_instance_log_levels() -> None:
|
||||
session.close()
|
||||
|
||||
|
||||
def _plugin_instance_from_record(record: PluginInstanceRecord) -> PluginInstance:
|
||||
def _plugin_instance_from_record(record: PluginInstanceRecord) -> PluginInstance | None:
|
||||
"""把插件实例表的 ORM 行投影为运行时端口使用的 Pydantic 描述。
|
||||
|
||||
只投影描述符各列:业务参数走插件配置读取端口,运行时端口拿到的应当是一份实例
|
||||
身份、展示信息与调用目标置位的视图。
|
||||
|
||||
历史版本写入的脏实例行不能阻断整个插件目录的读取;严格模型校验仍保留,只把
|
||||
当前行视为不可装载并交给上层跳过。数据库异常不在这里捕获,避免把真正的存储故障
|
||||
伪装成单个插件问题。
|
||||
"""
|
||||
return PluginInstance(
|
||||
instance_id=record.instance_id,
|
||||
source_plugin_id=record.source_plugin_id,
|
||||
plugin_name=record.plugin_name,
|
||||
plugin_desc=record.plugin_desc,
|
||||
plugin_icon=record.plugin_icon,
|
||||
is_default_target=record.is_default_target,
|
||||
is_enabled=record.is_enabled,
|
||||
)
|
||||
try:
|
||||
return PluginInstance(
|
||||
instance_id=record.instance_id,
|
||||
source_plugin_id=record.source_plugin_id,
|
||||
plugin_name=record.plugin_name,
|
||||
plugin_desc=record.plugin_desc,
|
||||
plugin_icon=record.plugin_icon,
|
||||
is_default_target=record.is_default_target,
|
||||
is_enabled=record.is_enabled,
|
||||
)
|
||||
except ValidationError as error:
|
||||
logger.warning(
|
||||
"跳过无效插件实例记录:instance_id=%r, source_plugin_id=%r, error=%s",
|
||||
record.instance_id,
|
||||
record.source_plugin_id,
|
||||
error,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _plugin_instances_from_records(
|
||||
records: list[PluginInstanceRecord],
|
||||
) -> list[PluginInstance]:
|
||||
"""逐行投影实例记录,跳过不能通过运行时模型校验的历史行。"""
|
||||
instances: list[PluginInstance] = []
|
||||
for record in records:
|
||||
instance = _plugin_instance_from_record(record)
|
||||
if instance is not None:
|
||||
instances.append(instance)
|
||||
return instances
|
||||
|
||||
|
||||
def _save_plugin_instance_record(instance: PluginInstance) -> None:
|
||||
@@ -326,20 +352,25 @@ def _build_plugin_instance_directory() -> PluginInstanceDirectory:
|
||||
record = oper.get(instance_id)
|
||||
return _plugin_instance_from_record(record) if record is not None else None
|
||||
|
||||
def _list_all() -> list[PluginInstance]:
|
||||
"""列出全部可用实例描述,历史无效行只影响自身。"""
|
||||
return _plugin_instances_from_records(oper.list_all())
|
||||
|
||||
def _list_by_source(source_plugin_id: str) -> list[PluginInstance]:
|
||||
"""列出指定源插件的可用实例描述,跳过历史无效行。"""
|
||||
return _plugin_instances_from_records(oper.list_by_source(source_plugin_id))
|
||||
|
||||
def _list_enabled() -> list[PluginInstance]:
|
||||
"""列出启用且可投影的实例描述,供启动依赖分类使用。"""
|
||||
return _plugin_instances_from_records(oper.list_enabled())
|
||||
|
||||
return PluginInstanceDirectory(
|
||||
get=_get,
|
||||
list_all=lambda: [
|
||||
_plugin_instance_from_record(record) for record in oper.list_all()
|
||||
],
|
||||
list_by_source=lambda source_plugin_id: [
|
||||
_plugin_instance_from_record(record)
|
||||
for record in oper.list_by_source(source_plugin_id)
|
||||
],
|
||||
list_all=_list_all,
|
||||
list_by_source=_list_by_source,
|
||||
save=_save_plugin_instance_record,
|
||||
delete=oper.delete,
|
||||
list_enabled=lambda: [
|
||||
_plugin_instance_from_record(record) for record in oper.list_enabled()
|
||||
],
|
||||
list_enabled=_list_enabled,
|
||||
set_enabled=lambda instance_id, is_enabled: oper.set_enabled(
|
||||
instance_id=instance_id,
|
||||
is_enabled=is_enabled,
|
||||
|
||||
45
tests/test_plugin_instance_directory.py
Normal file
45
tests/test_plugin_instance_directory.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""插件实例目录对历史无效持久化行的读取隔离测试。"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.startup.initializers import plugins as plugins_initializer
|
||||
|
||||
|
||||
def _record(instance_id: str, source_plugin_id: str) -> SimpleNamespace:
|
||||
"""构造包含 ORM 所需字段的轻量实例记录替身。"""
|
||||
return SimpleNamespace(
|
||||
instance_id=instance_id,
|
||||
source_plugin_id=source_plugin_id,
|
||||
plugin_name=None,
|
||||
plugin_desc=None,
|
||||
plugin_icon=None,
|
||||
is_default_target=False,
|
||||
is_enabled=True,
|
||||
)
|
||||
|
||||
|
||||
def test_plugin_instance_directory_skips_invalid_records_on_every_read(monkeypatch) -> None:
|
||||
"""单条历史非法实例记录不得阻断其它实例读取或启动分类。"""
|
||||
invalid = _record("__ConfigCenter__", "__ConfigCenter__")
|
||||
valid = _record("DemoPlugin", "DemoPlugin")
|
||||
oper = MagicMock()
|
||||
oper.get.return_value = invalid
|
||||
oper.list_all.return_value = [invalid, valid]
|
||||
oper.list_by_source.return_value = [invalid, valid]
|
||||
oper.list_enabled.return_value = [invalid, valid]
|
||||
logger = MagicMock()
|
||||
|
||||
monkeypatch.setattr(plugins_initializer, "PluginInstanceOper", lambda: oper)
|
||||
monkeypatch.setattr(plugins_initializer, "logger", logger)
|
||||
|
||||
directory = plugins_initializer._build_plugin_instance_directory()
|
||||
|
||||
assert directory.get(invalid.instance_id) is None
|
||||
assert [item.instance_id for item in directory.list_all()] == [valid.instance_id]
|
||||
assert [item.instance_id for item in directory.list_by_source("DemoPlugin")] == [
|
||||
valid.instance_id
|
||||
]
|
||||
assert [item.instance_id for item in directory.list_enabled()] == [valid.instance_id]
|
||||
assert logger.warning.call_count == 4
|
||||
assert invalid.instance_id in str(logger.warning.call_args)
|
||||
Reference in New Issue
Block a user