feat(plugin): persist declared metadata snapshots (#6467)

This commit is contained in:
InfinityPacer
2026-08-26 13:15:12 +08:00
committed by GitHub
parent bd0795082a
commit ac2a2b1129
26 changed files with 1374 additions and 147 deletions
+43 -22
View File
@@ -28,6 +28,7 @@ from app.api.principal import ApiPrincipal
from app.api.response import ResponseAPIRouter
from app.application.commands import init_commands
from app.application.configuration import get_api_runtime_config_snapshot, get_configured_system_config
from app.application.plugin.catalog import apply_declared_metadata_fallback
from app.application.plugin.config import PluginConfigCommand
from app.application.plugin.folders import remove_plugin_from_folders
from app.application.plugin.gateway import get_plugin_install_service
@@ -234,14 +235,21 @@ async def _get_plugin_history_detail(
installed_plugin = next(
(
plugin
for plugin in plugin_manager.get_local_plugins()
if plugin.id == plugin_id and plugin.installed
for plugin in plugin_manager.get_installed_plugins()
if plugin.id == plugin_id
),
None,
)
if not installed_plugin:
return None
identity = await get_plugin_persistence().get_identity(plugin_id)
if identity is not None:
installed_plugin = apply_declared_metadata_fallback(
[installed_plugin],
{identity.normalized_plugin_id: identity},
)[0]
local_repo_plugin = next(
(plugin for plugin in plugin_manager.get_local_repo_plugins() if plugin.id == plugin_id),
None,
@@ -249,27 +257,35 @@ async def _get_plugin_history_detail(
if local_repo_plugin:
return _merge_plugin_market_metadata(installed_plugin, local_repo_plugin)
if installed_plugin.repo_url:
trusted_repo_url = None
if identity is not None and identity.trusted_source_key:
owner_repo = identity.trusted_source_key.removeprefix("github:")
trusted_repo_url = f"https://github.com/{owner_repo}"
if trusted_repo_url:
market_plugin = await _get_market_plugin_from_repo(
plugin_manager, plugin_id, installed_plugin.repo_url, force
plugin_manager, plugin_id, trusted_repo_url, force
)
if not market_plugin:
logger.debug(f"插件 {plugin_id} 未从来源仓库获取到更新说明,返回本地插件信息")
return installed_plugin
return _merge_plugin_market_metadata(installed_plugin, market_plugin)
market_plugin = next(
(
plugin
for plugin in await plugin_manager.async_get_online_plugins(force=force)
if plugin.id == plugin_id
),
None,
)
if not market_plugin:
return installed_plugin
return installed_plugin
return _merge_plugin_market_metadata(installed_plugin, market_plugin)
async def _installed_plugins_with_declared_metadata(
plugin_manager: PluginRuntime,
) -> list[_SchemaPlugin]:
"""批量读取身份,并为未加载插件补齐已提交的展示声明。"""
plugins = plugin_manager.get_installed_plugins()
identities = await get_plugin_persistence().list_identities(
[plugin.id for plugin in plugins if plugin.id]
)
return apply_declared_metadata_fallback(
plugins,
{identity.normalized_plugin_id: identity for identity in identities},
)
@router.get("/", summary="所有插件", response_model=List[_SchemaPlugin])
@@ -281,14 +297,13 @@ async def all_plugins(
"""
查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all
"""
# 本地插件
plugin_manager = get_plugin_manager()
local_plugins = plugin_manager.get_local_plugins()
# 已安装插件
installed_plugins = [plugin for plugin in local_plugins if plugin.installed]
installed_plugins = plugin_manager.get_installed_plugins()
if state == "installed":
return plugin_manager.get_installed_plugins()
return await _installed_plugins_with_declared_metadata(plugin_manager)
# 本地插件
local_plugins = plugin_manager.get_local_plugins()
# 未安装的本地插件
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed]
# 本地插件仓库目录中的插件
@@ -305,7 +320,10 @@ async def all_plugins(
if state == "market":
# 返回未安装的本地插件
return not_installed_plugins
return local_plugins
return (
await _installed_plugins_with_declared_metadata(plugin_manager)
+ not_installed_plugins
)
# 插件市场插件清单
market_plugins = []
@@ -328,7 +346,10 @@ async def all_plugins(
return market_plugins
# 返回所有插件
return installed_plugins + market_plugins
return (
await _installed_plugins_with_declared_metadata(plugin_manager)
+ market_plugins
)
@router.get("/installed", summary="已安装插件", response_model=List[str])
+8 -8
View File
@@ -6,6 +6,7 @@ from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
@@ -60,14 +61,13 @@ class PluginInstallAdmission:
payload_receipt: str,
applied_at: datetime,
declared_version: str | None = None,
manifest_matches_payload: bool = True,
) -> PluginIdentity:
"""在载荷落盘并生成收据后构造唯一数据库提交目标。"""
current = self.identity_before
plugin_id = current.plugin_id if current else self.candidate.plugin_id
metadata = self.candidate.dto if isinstance(self.candidate.dto, Mapping) else {}
system_version = metadata.get("system_version")
supports_v3 = metadata.get("v3")
supports_v3t = metadata.get("v3t")
installed_version = declared_version or self.candidate.plugin_version
source_binding_changed = (
self.trusted_source_type is not TrustedPluginSourceType.UNKNOWN
and (
@@ -89,13 +89,13 @@ class PluginInstallAdmission:
if isinstance(self.candidate, PluginLocalCandidate)
else self.candidate.source_key
),
declared_version=declared_version or self.candidate.plugin_version,
declared_version=installed_version,
package_generation=self.candidate.package_generation,
system_version=(
system_version if isinstance(system_version, str) else None
declared_metadata=PluginDeclaredMetadata.from_package(
metadata,
declaration_version=self.candidate.plugin_version,
manifest_matches_payload=manifest_matches_payload,
),
supports_v3=supports_v3 if isinstance(supports_v3, bool) else None,
supports_v3t=supports_v3t if isinstance(supports_v3t, bool) else None,
payload_receipt=payload_receipt,
revision=(current.revision + 1) if current else 1,
created_at=current.created_at if current else applied_at,
+41 -1
View File
@@ -4,9 +4,11 @@ from __future__ import annotations
import asyncio
import concurrent.futures
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any, Optional
from app.application.plugin.identity import PluginIdentity
from app.schemas.plugin import Plugin
MarketLoader = Callable[[str, Optional[str], bool], Optional[dict[str, dict]]]
AsyncMarketLoader = Callable[
@@ -17,6 +19,44 @@ PluginMapper = Callable[[str, dict, str, list[str], int, Optional[str]], Any]
ProgressCallback = Callable[..., Any]
def apply_declared_metadata_fallback(
plugins: Sequence[Plugin],
identities: Mapping[str, PluginIdentity],
) -> list[Plugin]:
"""用已提交快照补齐加载失败插件,不覆盖真实运行态字段。"""
result: list[Plugin] = []
for plugin in plugins:
identity = identities.get((plugin.id or "").lower())
if (
identity is None
or identity.declared_metadata is None
or identity.declared_version is None
):
result.append(plugin)
continue
fallback = identity.declared_metadata.display_fallback(
installed_version=identity.declared_version
)
updates: dict[str, str] = {}
if not plugin.plugin_version:
updates["plugin_version"] = fallback["plugin_version"]
if (
(not plugin.plugin_name or plugin.plugin_name == plugin.id)
and "plugin_name" in fallback
):
updates["plugin_name"] = fallback["plugin_name"]
if not plugin.plugin_desc and "plugin_desc" in fallback:
updates["plugin_desc"] = fallback["plugin_desc"]
if not plugin.plugin_icon and "plugin_icon" in fallback:
updates["plugin_icon"] = fallback["plugin_icon"]
if not plugin.plugin_author and "plugin_author" in fallback:
updates["plugin_author"] = fallback["plugin_author"]
if not plugin.plugin_label and "plugin_label" in fallback:
updates["plugin_label"] = fallback["plugin_label"]
result.append(plugin.model_copy(update=updates) if updates else plugin)
return result
class PluginCatalogService:
"""负责插件市场索引映射、并发收集、代际合并和来源去重。"""
+207
View File
@@ -0,0 +1,207 @@
"""已提交插件载荷的版本化 package 声明快照。"""
from __future__ import annotations
import json
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
_SCHEMA_VERSION = 1
_RUNTIME_FIELD_PATTERN = re.compile(r"^v[1-9][0-9]*t?$")
_MANIFEST_TEXT_FIELDS = (
"name",
"description",
"icon",
"author",
"system_version",
)
def _optional_text(
value: object,
*,
max_length: int | None = None,
) -> str | None:
"""把外部可选文本规范为非空字符串。"""
if not isinstance(value, str):
return None
normalized = value.strip()
if max_length is not None and len(normalized) > max_length:
raise ValueError(f"插件声明文本长度不能超过 {max_length}")
return normalized or None
def _labels(value: object) -> list[str]:
"""把历史逗号字符串和字符串数组统一为有序标签列表。"""
if isinstance(value, str):
items = value.split(",")
elif isinstance(value, list):
items = value
else:
return []
return [
normalized
for item in items
if isinstance(item, str) and (normalized := item.strip())
]
def _normalize_manifest(value: object) -> dict[str, object]:
"""仅保留当前载荷展示和声明消费者需要的 package 字段。"""
if not isinstance(value, Mapping):
return {}
manifest: dict[str, object] = {}
for field_name in _MANIFEST_TEXT_FIELDS:
if normalized := _optional_text(
value.get(field_name),
max_length=128 if field_name == "system_version" else None,
):
manifest[field_name] = normalized
labels = _labels(value.get("labels"))
if labels:
manifest["labels"] = labels
level = value.get("level")
if isinstance(level, int) and not isinstance(level, bool):
manifest["level"] = level
release = value.get("release")
if isinstance(release, bool):
manifest["release"] = release
return manifest
def _normalize_runtime(value: object) -> dict[str, bool]:
"""保留可向后扩展的宿主代际和运行时变体布尔声明。"""
if not isinstance(value, Mapping):
return {}
return {
str(field_name): field_value
for field_name, field_value in value.items()
if (
isinstance(field_name, str)
and _RUNTIME_FIELD_PATTERN.fullmatch(field_name)
and isinstance(field_value, bool)
)
}
def _encode(value: Mapping[str, object]) -> str:
"""生成不可从外部原地修改的稳定内部表示。"""
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
@dataclass(frozen=True, slots=True)
class PluginDeclaredMetadata:
"""封装一份受限、版本化且不参与来源授信的 package 声明。"""
_encoded: str = field(repr=False)
def __post_init__(self) -> None:
"""拒绝绕过工厂构造的非规范内部状态。"""
normalized = self._normalize_storage(json.loads(self._encoded))
object.__setattr__(self, "_encoded", _encode(normalized))
@classmethod
def from_package(
cls,
package: Mapping[str, Any],
*,
declaration_version: str | None,
manifest_matches_payload: bool,
) -> "PluginDeclaredMetadata":
"""从安装候选生成与载荷一起提交的受限声明快照。"""
runtime = _normalize_runtime(package)
value = {
"schema_version": _SCHEMA_VERSION,
"declaration_version": _optional_text(declaration_version),
"manifest_matches_payload": manifest_matches_payload,
"manifest": _normalize_manifest(package),
"runtime": runtime,
}
return cls(_encode(value))
@classmethod
def from_storage(cls, value: object) -> "PluginDeclaredMetadata":
"""校验数据库 JSON,并忽略当前合同未消费的额外字段。"""
return cls(_encode(cls._normalize_storage(value)))
@staticmethod
def _normalize_storage(value: object) -> dict[str, object]:
"""验证快照结构,同时宽容丢弃非法可选展示字段。"""
if not isinstance(value, Mapping):
raise ValueError("插件声明快照必须是 JSON 对象")
if value.get("schema_version") != _SCHEMA_VERSION:
raise ValueError("插件声明快照 schema_version 不受支持")
manifest_matches_payload = value.get("manifest_matches_payload")
if not isinstance(manifest_matches_payload, bool):
raise ValueError("插件声明快照必须说明 manifest 是否对应当前载荷")
return {
"schema_version": _SCHEMA_VERSION,
"declaration_version": _optional_text(
value.get("declaration_version")
),
"manifest_matches_payload": manifest_matches_payload,
"manifest": _normalize_manifest(value.get("manifest")),
"runtime": _normalize_runtime(value.get("runtime")),
}
@property
def declaration_version(self) -> str | None:
"""返回生成该快照的 package 条目版本。"""
return _optional_text(self.to_json().get("declaration_version"))
@property
def manifest_matches_payload(self) -> bool:
"""说明该 package 声明是否与当前已提交载荷对应。"""
return bool(self.to_json()["manifest_matches_payload"])
def runtime_support(self, runtime_name: str) -> bool | None:
"""读取一个规范运行时声明;缺省保持未声明语义。"""
if not _RUNTIME_FIELD_PATTERN.fullmatch(runtime_name):
raise ValueError("运行时声明必须使用 v<数字> 或 v<数字>t")
runtime = self.to_json().get("runtime")
if not isinstance(runtime, dict):
return None
value = runtime.get(runtime_name)
return value if isinstance(value, bool) else None
def display_fallback(self, *, installed_version: str) -> dict[str, str]:
"""生成插件加载失败时可安全补齐的展示字段。"""
manifest = self.to_json().get("manifest")
fallback = {
"plugin_version": installed_version,
}
if not isinstance(manifest, dict):
return fallback
display_fields = {
"name": "plugin_name",
"description": "plugin_desc",
"icon": "plugin_icon",
"author": "plugin_author",
}
for source_name, target_name in display_fields.items():
value = manifest.get(source_name)
if isinstance(value, str):
fallback[target_name] = value
labels = manifest.get("labels")
if isinstance(labels, list):
label = ",".join(item for item in labels if isinstance(item, str))
if label:
fallback["plugin_label"] = label
return fallback
def to_json(self) -> dict[str, object]:
"""返回可持久化且与内部状态相互隔离的 JSON 副本。"""
value: object = json.loads(self._encoded)
if not isinstance(value, dict):
raise ValueError("插件声明快照内部状态必须是 JSON 对象")
return {
str(field_name): field_value
for field_name, field_value in value.items()
}
+11 -15
View File
@@ -3,12 +3,14 @@
from __future__ import annotations
import re
from collections.abc import Callable
from collections.abc import Callable, Sequence
from dataclasses import dataclass, replace
from datetime import datetime
from enum import StrEnum
from typing import Protocol
from app.application.plugin.declaration import PluginDeclaredMetadata
_PLUGIN_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,127}$")
_ONLINE_SOURCE_KEY_PATTERN = re.compile(
r"^github:[a-z0-9](?:[a-z0-9-]{0,38})/"
@@ -116,9 +118,7 @@ class PluginIdentity:
payload_source_key: str | None
declared_version: str | None
package_generation: str | None
system_version: str | None
supports_v3: bool | None
supports_v3t: bool | None
declared_metadata: PluginDeclaredMetadata | None
payload_receipt: str | None
revision: int
created_at: datetime
@@ -200,9 +200,7 @@ class PluginIdentity:
if any((
self.declared_version,
self.package_generation,
self.system_version,
self.supports_v3 is not None,
self.supports_v3t is not None,
self.declared_metadata,
self.payload_receipt,
self.payload_applied_at,
)):
@@ -210,6 +208,8 @@ class PluginIdentity:
else:
if not self.declared_version or not self.package_generation:
raise ValueError("已知载荷必须携带声明版本和包代际")
if self.declared_metadata is None:
raise ValueError("已知载荷必须携带已提交 package 声明快照")
if self.payload_applied_at is None or self.payload_receipt is None:
raise ValueError("已知载荷必须携带应用时间和内容收据")
if (
@@ -234,11 +234,6 @@ class PluginIdentity:
field_name="插件声明版本",
max_length=64,
)
_validate_optional_text(
self.system_version,
field_name="插件系统版本要求",
max_length=128,
)
object.__setattr__(self, "trusted_source_key", trusted_key)
object.__setattr__(self, "payload_source_key", payload_key)
object.__setattr__(self, "payload_receipt", _validate_receipt(self.payload_receipt))
@@ -320,9 +315,7 @@ def plan_legacy_plugin_identity(
payload_source_key=None,
declared_version=None,
package_generation=None,
system_version=None,
supports_v3=None,
supports_v3t=None,
declared_metadata=None,
payload_receipt=None,
revision=1,
created_at=now,
@@ -356,6 +349,9 @@ class PluginIdentityStore(Protocol):
def get(self, plugin_id: str) -> PluginIdentity | None:
"""读取一个物理插件的来源身份。"""
def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]:
"""批量读取指定物理插件的来源身份。"""
def compare_and_set(
self,
identity: PluginIdentity,
+4
View File
@@ -356,6 +356,10 @@ class PluginInstallCommand:
payload_receipt=receipt,
applied_at=self.__clock(),
declared_version=release_version,
manifest_matches_payload=(
release_version is None
or release_version == candidate.plugin_version
),
)
await self.__await_side_effect(
self.__persistence.set_installation_target(
+13
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import re
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
@@ -166,6 +167,9 @@ class PluginIdentityPersistence(Protocol):
def get(self, plugin_id: str) -> PluginIdentity | None:
"""读取一个物理插件的来源身份。"""
def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]:
"""批量读取指定物理插件的来源身份。"""
def compare_and_set(
self,
identity: PluginIdentity,
@@ -202,6 +206,15 @@ class PluginPersistenceService:
"""在数据库 worker 中读取插件来源身份。"""
return await self.__executor.run(partial(self.__identities.get, plugin_id))
async def list_identities(
self,
plugin_ids: Sequence[str],
) -> list[PluginIdentity]:
"""在一次数据库任务中批量读取插件来源身份。"""
return await self.__executor.run(
partial(self.__identities.list, tuple(plugin_ids))
)
async def migrate_identity(
self,
identity: PluginIdentity,
+33 -7
View File
@@ -1,11 +1,12 @@
"""插件来源身份 Application Port 的 SQLAlchemy 实现。"""
from collections.abc import Callable
from collections.abc import Callable, Sequence
from datetime import datetime
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
BindLocalPluginIdentityCommand,
BindOnlinePluginIdentityCommand,
@@ -40,9 +41,11 @@ def _to_record(model: IdentityModel) -> PluginIdentity:
payload_source_key=model.payload_source_key,
declared_version=model.declared_version,
package_generation=model.package_generation,
system_version=model.system_version,
supports_v3=model.supports_v3,
supports_v3t=model.supports_v3t,
declared_metadata=(
PluginDeclaredMetadata.from_storage(model.declared_metadata)
if model.declared_metadata is not None
else None
),
payload_receipt=model.payload_receipt,
revision=model.revision,
created_at=datetime.fromisoformat(model.created_at),
@@ -64,9 +67,11 @@ def _to_model(identity: PluginIdentity) -> IdentityModel:
payload_source_key=identity.payload_source_key,
declared_version=identity.declared_version,
package_generation=identity.package_generation,
system_version=identity.system_version,
supports_v3=identity.supports_v3,
supports_v3t=identity.supports_v3t,
declared_metadata=(
identity.declared_metadata.to_json()
if identity.declared_metadata is not None
else None
),
payload_receipt=identity.payload_receipt,
revision=identity.revision,
created_at=identity.created_at.isoformat(),
@@ -92,6 +97,13 @@ class _SqlAlchemyIdentityRepository:
model = self._oper.get_by_plugin_id(plugin_id)
return _to_record(model) if model else None
def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]:
"""批量读取并映射指定来源身份。"""
return [
_to_record(model)
for model in self._oper.list_by_plugin_ids(plugin_ids)
]
def stage_create(self, identity: PluginIdentity) -> None:
"""暂存首次身份。"""
try:
@@ -131,6 +143,20 @@ class TransactionalPluginIdentityStore:
finally:
session.close()
def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]:
"""在一个短会话内批量读取规范化插件身份。"""
normalized_ids = tuple(
dict.fromkeys(
normalize_physical_plugin_id(plugin_id)
for plugin_id in plugin_ids
)
)
session = self._session_factory()
try:
return _SqlAlchemyIdentityRepository(session).list(normalized_ids)
finally:
session.close()
def compare_and_set(
self,
identity: PluginIdentity,
+11 -6
View File
@@ -10,6 +10,7 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
@@ -50,9 +51,11 @@ def _identity_from_model(model: IdentityModel) -> PluginIdentity:
payload_source_key=model.payload_source_key,
declared_version=model.declared_version,
package_generation=model.package_generation,
system_version=model.system_version,
supports_v3=model.supports_v3,
supports_v3t=model.supports_v3t,
declared_metadata=(
PluginDeclaredMetadata.from_storage(model.declared_metadata)
if model.declared_metadata is not None
else None
),
payload_receipt=model.payload_receipt,
revision=model.revision,
created_at=datetime.fromisoformat(model.created_at),
@@ -82,9 +85,11 @@ def _identity_model_values(identity: PluginIdentity) -> dict[str, object]:
"payload_source_key": identity.payload_source_key,
"declared_version": identity.declared_version,
"package_generation": identity.package_generation,
"system_version": identity.system_version,
"supports_v3": identity.supports_v3,
"supports_v3t": identity.supports_v3t,
"declared_metadata": (
identity.declared_metadata.to_json()
if identity.declared_metadata is not None
else None
),
"payload_receipt": identity.payload_receipt,
"revision": identity.revision,
"created_at": identity.created_at.isoformat(),
+5 -5
View File
@@ -1,8 +1,8 @@
"""已安装物理插件来源身份模型。"""
from typing import Optional
from typing import Any, Optional
from sqlalchemy import Boolean, CheckConstraint, Integer, String, UniqueConstraint
from sqlalchemy import JSON, CheckConstraint, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, get_id_column
@@ -21,9 +21,9 @@ class PluginIdentity(Base):
payload_source_key: Mapped[Optional[str]] = mapped_column(String(255))
declared_version: Mapped[Optional[str]] = mapped_column(String(64))
package_generation: Mapped[Optional[str]] = mapped_column(String(8))
system_version: Mapped[Optional[str]] = mapped_column(String(128))
supports_v3: Mapped[Optional[bool]] = mapped_column(Boolean)
supports_v3t: Mapped[Optional[bool]] = mapped_column(Boolean)
declared_metadata: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSON(none_as_null=True)
)
payload_receipt: Mapped[Optional[str]] = mapped_column(String(71))
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
created_at: Mapped[str] = mapped_column(String(40), nullable=False)
+18
View File
@@ -1,5 +1,6 @@
"""插件来源身份的数据访问原语。"""
from collections.abc import Sequence
from typing import cast
from sqlalchemy import select, update
@@ -25,6 +26,23 @@ class PluginIdentityOper(DbOper):
)
)
def list_by_plugin_ids(
self,
plugin_ids: Sequence[str],
) -> list[PluginIdentity]:
"""批量读取规范化物理插件 ID 对应的身份。"""
if not plugin_ids:
return []
return list(
self._execute_sync_query(
lambda session: session.execute(
select(PluginIdentity).where(
PluginIdentity.normalized_plugin_id.in_(plugin_ids)
)
).scalars()
)
)
def stage_create(self, identity: PluginIdentity) -> None:
"""暂存首次身份并立即暴露数据库唯一键竞争。"""
def stage(session: Session) -> None:
+185
View File
@@ -0,0 +1,185 @@
"""3.0.12 consolidate committed plugin package declarations.
Revision ID: 5f2a9c1e7b4d
Revises: a6c8e2f4b1d3
Create Date: 2026-08-26
"""
from collections.abc import Mapping
import sqlalchemy as sa
from alembic import op
revision = "5f2a9c1e7b4d"
down_revision = "a6c8e2f4b1d3"
branch_labels = None
depends_on = None
_LEGACY_COLUMNS = {
"system_version",
"supports_v3",
"supports_v3t",
}
def _column_names() -> set[str]:
"""返回当前插件身份表字段集合。"""
inspector = sa.inspect(op.get_bind())
if "pluginidentity" not in inspector.get_table_names():
return set()
return {
column["name"]
for column in inspector.get_columns("pluginidentity")
}
def _legacy_snapshot(row: Mapping[str, object]) -> dict[str, object] | None:
"""把旧固定声明列保守回填为仅供展示的版本化快照。"""
if row["payload_source_type"] == "unknown":
return None
manifest: dict[str, object] = {}
system_version = row.get("system_version")
if isinstance(system_version, str) and system_version.strip():
manifest["system_version"] = system_version.strip()
runtime = {
field_name.removeprefix("supports_"): row[field_name]
for field_name in ("supports_v3", "supports_v3t")
if isinstance(row.get(field_name), bool)
}
return {
"schema_version": 1,
"declaration_version": None,
"manifest_matches_payload": False,
"manifest": manifest,
"runtime": runtime,
}
def _backfill_declared_metadata() -> None:
"""只为仍有旧载荷事实且尚无快照的身份生成保守声明。"""
identity = sa.table(
"pluginidentity",
sa.column("id", sa.Integer()),
sa.column("payload_source_type", sa.String()),
sa.column("system_version", sa.String()),
sa.column("supports_v3", sa.Boolean()),
sa.column("supports_v3t", sa.Boolean()),
sa.column("declared_metadata", sa.JSON(none_as_null=True)),
)
connection = op.get_bind()
rows = connection.execute(
sa.select(
identity.c.id,
identity.c.payload_source_type,
identity.c.system_version,
identity.c.supports_v3,
identity.c.supports_v3t,
).where(identity.c.declared_metadata.is_(None))
).mappings().all()
for row in rows:
connection.execute(
identity.update()
.where(identity.c.id == row["id"])
.values(declared_metadata=_legacy_snapshot(row))
)
def _legacy_values(value: object) -> dict[str, object]:
"""从版本化快照恢复旧版可表达的三个固定声明字段。"""
if not isinstance(value, Mapping) or value.get("schema_version") != 1:
return {
"system_version": None,
"supports_v3": None,
"supports_v3t": None,
}
manifest = value.get("manifest")
runtime = value.get("runtime")
return {
"system_version": (
manifest.get("system_version")
if isinstance(manifest, Mapping)
and isinstance(manifest.get("system_version"), str)
else None
),
"supports_v3": (
runtime.get("v3")
if isinstance(runtime, Mapping)
and isinstance(runtime.get("v3"), bool)
else None
),
"supports_v3t": (
runtime.get("v3t")
if isinstance(runtime, Mapping)
and isinstance(runtime.get("v3t"), bool)
else None
),
}
def _restore_legacy_columns() -> None:
"""在降级前把当前快照投影回旧版固定声明列。"""
identity = sa.table(
"pluginidentity",
sa.column("id", sa.Integer()),
sa.column("declared_metadata", sa.JSON(none_as_null=True)),
sa.column("system_version", sa.String()),
sa.column("supports_v3", sa.Boolean()),
sa.column("supports_v3t", sa.Boolean()),
)
connection = op.get_bind()
rows = connection.execute(
sa.select(identity.c.id, identity.c.declared_metadata)
).mappings().all()
for row in rows:
connection.execute(
identity.update()
.where(identity.c.id == row["id"])
.values(**_legacy_values(row["declared_metadata"]))
)
def upgrade() -> None:
"""新增声明快照、离线回填并移除会随 package 扩张的固定列。"""
columns = _column_names()
if not columns:
return
legacy_columns = _LEGACY_COLUMNS & columns
if legacy_columns and legacy_columns != _LEGACY_COLUMNS:
raise RuntimeError("pluginidentity 旧声明字段不完整,拒绝继续迁移")
if "declared_metadata" not in columns:
op.add_column(
"pluginidentity",
sa.Column(
"declared_metadata",
sa.JSON(none_as_null=True),
nullable=True,
),
)
columns.add("declared_metadata")
if _LEGACY_COLUMNS <= columns:
_backfill_declared_metadata()
if legacy_columns:
with op.batch_alter_table("pluginidentity") as batch_op:
for column_name in sorted(legacy_columns):
batch_op.drop_column(column_name)
def downgrade() -> None:
"""恢复旧版固定声明列,并删除版本化声明快照。"""
columns = _column_names()
if not columns or "declared_metadata" not in columns:
return
missing_legacy = _LEGACY_COLUMNS - columns
if missing_legacy:
with op.batch_alter_table("pluginidentity") as batch_op:
if "system_version" in missing_legacy:
batch_op.add_column(
sa.Column("system_version", sa.String(length=128))
)
if "supports_v3" in missing_legacy:
batch_op.add_column(sa.Column("supports_v3", sa.Boolean()))
if "supports_v3t" in missing_legacy:
batch_op.add_column(sa.Column("supports_v3t", sa.Boolean()))
_restore_legacy_columns()
with op.batch_alter_table("pluginidentity") as batch_op:
batch_op.drop_column("declared_metadata")
+16 -3
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6773,
"edge_sha256": "3ba25180753ef7e15d9d08b7af25b66191306e8c0b465a27bed5613ed8d30c5d",
"edge_count": 6785,
"edge_sha256": "077e003bf195195e4e75dc65c9596e0b5a45c0e33441e3a22829f0cdb8d98e45",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -2112,6 +2112,7 @@
"app.api.endpoints.plugin -> app.application.commands",
"app.api.endpoints.plugin -> app.application.configuration",
"app.api.endpoints.plugin -> app.application.plugin",
"app.api.endpoints.plugin -> app.application.plugin.catalog",
"app.api.endpoints.plugin -> app.application.plugin.config",
"app.api.endpoints.plugin -> app.application.plugin.folders",
"app.api.endpoints.plugin -> app.application.plugin.gateway",
@@ -2744,9 +2745,15 @@
"app.application.outbox -> app.schemas.types",
"app.application.plugin.admission -> app.application",
"app.application.plugin.admission -> app.application.plugin",
"app.application.plugin.admission -> app.application.plugin.declaration",
"app.application.plugin.admission -> app.application.plugin.identity",
"app.application.plugin.admission -> app.application.plugin.inventory",
"app.application.plugin.admission -> app.application.plugin.source",
"app.application.plugin.catalog -> app.application",
"app.application.plugin.catalog -> app.application.plugin",
"app.application.plugin.catalog -> app.application.plugin.identity",
"app.application.plugin.catalog -> app.schemas",
"app.application.plugin.catalog -> app.schemas.plugin",
"app.application.plugin.config -> app.schemas",
"app.application.plugin.config -> app.schemas.exception",
"app.application.plugin.folders -> app.application",
@@ -2763,6 +2770,9 @@
"app.application.plugin.gateway -> app.application.plugin.inventory",
"app.application.plugin.gateway -> app.application.plugin.lifecycle",
"app.application.plugin.gateway -> app.application.plugin.source",
"app.application.plugin.identity -> app.application",
"app.application.plugin.identity -> app.application.plugin",
"app.application.plugin.identity -> app.application.plugin.declaration",
"app.application.plugin.identity_migration -> app.application",
"app.application.plugin.identity_migration -> app.application.plugin",
"app.application.plugin.identity_migration -> app.application.plugin.identity",
@@ -3666,6 +3676,7 @@
"app.db.adapters.outbox -> app.db.models.outbox",
"app.db.adapters.pluginidentity -> app.application",
"app.db.adapters.pluginidentity -> app.application.plugin",
"app.db.adapters.pluginidentity -> app.application.plugin.declaration",
"app.db.adapters.pluginidentity -> app.application.plugin.identity",
"app.db.adapters.pluginidentity -> app.db",
"app.db.adapters.pluginidentity -> app.db.models",
@@ -3675,6 +3686,7 @@
"app.db.adapters.pluginidentity -> app.db.uow",
"app.db.adapters.plugininstallation -> app.application",
"app.db.adapters.plugininstallation -> app.application.plugin",
"app.db.adapters.plugininstallation -> app.application.plugin.declaration",
"app.db.adapters.plugininstallation -> app.application.plugin.identity",
"app.db.adapters.plugininstallation -> app.application.plugin.transaction",
"app.db.adapters.plugininstallation -> app.db",
@@ -6790,7 +6802,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 833,
"module_count": 834,
"modules": [
"app",
"app.adapters",
@@ -7085,6 +7097,7 @@
"app.application.plugin.catalog",
"app.application.plugin.config",
"app.application.plugin.data",
"app.application.plugin.declaration",
"app.application.plugin.folders",
"app.application.plugin.gateway",
"app.application.plugin.identity",
+207
View File
@@ -0,0 +1,207 @@
"""插件 package 声明快照和值对象展示回退测试。"""
from datetime import datetime, timezone
import pytest
from app.application.plugin.catalog import apply_declared_metadata_fallback
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.schemas.plugin import Plugin, PluginRuntimeStatus
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins"
def _metadata(
package: dict[str, object],
*,
version: str = "1.0.0",
matches_payload: bool = True,
) -> PluginDeclaredMetadata:
"""构造一份可用于身份测试的 package 声明快照。"""
return PluginDeclaredMetadata.from_package(
package,
declaration_version=version,
manifest_matches_payload=matches_payload,
)
def _identity(
metadata: PluginDeclaredMetadata,
*,
version: str = "1.0.0",
plugin_id: str = "DemoPlugin",
) -> PluginIdentity:
"""构造带已提交声明快照的官方在线身份。"""
return PluginIdentity(
plugin_id=plugin_id,
normalized_plugin_id=plugin_id.lower(),
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key=OFFICIAL_SOURCE,
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.OFFICIAL,
payload_source_key=OFFICIAL_SOURCE,
declared_version=version,
package_generation="v3",
declared_metadata=metadata,
payload_receipt="sha256:" + "0" * 64,
revision=1,
created_at=NOW,
updated_at=NOW,
bound_at=NOW,
payload_applied_at=NOW,
)
def test_declared_metadata_round_trips_package_and_storage_without_extra_fields() -> None:
"""package 声明保存后应可稳定往返,并丢弃未纳入合同的字段。"""
package = {
"name": "Demo",
"description": "A demo plugin",
"icon": "demo.svg",
"author": "MoviePilot",
"system_version": ">=3.0.0",
"labels": "alpha, beta",
"level": 2,
"release": True,
"v4": True,
"v4t": False,
"unknown_field": "ignored",
"repo_url": "https://github.com/attacker/repo",
"source": "github:attacker/repo",
"token": "secret",
"plugin_public_key": "public-key",
}
snapshot = _metadata(package, version="2.0.0")
restored = PluginDeclaredMetadata.from_storage(snapshot.to_json())
stored = restored.to_json()
assert restored == snapshot
assert restored.declaration_version == "2.0.0"
assert restored.manifest_matches_payload is True
assert restored.runtime_support("v4") is True
assert restored.runtime_support("v4t") is False
assert stored["manifest"] == {
"author": "MoviePilot",
"description": "A demo plugin",
"icon": "demo.svg",
"labels": ["alpha", "beta"],
"level": 2,
"name": "Demo",
"release": True,
"system_version": ">=3.0.0",
}
assert stored["runtime"] == {"v4": True, "v4t": False}
assert "unknown_field" not in str(stored)
assert "attacker" not in str(stored)
assert "secret" not in str(stored)
assert "public-key" not in str(stored)
def test_declared_metadata_preserves_system_version_storage_limit() -> None:
"""系统版本声明继续遵守旧数据库列的长度合同,保证迁移可降级。"""
with pytest.raises(ValueError, match="长度不能超过 128"):
_metadata({"system_version": "v" * 129})
def test_declared_metadata_ignores_unknown_runtime_shapes_and_is_deep_copied() -> None:
"""未知代际和外部可变对象不能污染已提交快照。"""
labels = ["alpha"]
package = {
"name": "Demo",
"labels": labels,
"v4": True,
"v4t": False,
"v5": "not-a-bool",
"vX": True,
}
snapshot = _metadata(package)
labels.append("mutated-after-build")
exported = snapshot.to_json()
exported["manifest"]["labels"].append("mutated-export")
assert snapshot.to_json()["manifest"]["labels"] == ["alpha"]
assert snapshot.runtime_support("v4") is True
assert snapshot.runtime_support("v4t") is False
assert snapshot.runtime_support("v5") is None
def test_declared_metadata_records_current_and_historical_release_truth() -> None:
"""当前 package 与历史 Release 的声明对应关系必须可区分。"""
current = _metadata({"name": "Demo", "v3": True}, version="2.0.0")
historical = _metadata(
{"name": "Demo", "v3": True},
version="1.0.0",
matches_payload=False,
)
assert current.manifest_matches_payload is True
assert historical.manifest_matches_payload is False
assert historical.declaration_version == "1.0.0"
def test_declared_metadata_fallback_is_batch_safe_and_preserves_runtime_fields() -> None:
"""批量展示回退只补空展示字段,不覆盖运行态或另一插件身份。"""
first = _identity(
_metadata(
{
"name": "Demo from snapshot",
"description": "Saved description",
"icon": "saved.svg",
"author": "Saved author",
"labels": ["saved", "plugin"],
},
version="2.0.0",
),
version="2.0.0",
plugin_id="DemoPlugin",
)
second = _identity(
_metadata({"name": "Other from snapshot"}, version="3.0.0"),
version="3.0.0",
plugin_id="OtherPlugin",
)
plugins = [
Plugin(
id="DemoPlugin",
plugin_name="DemoPlugin",
plugin_version=None,
runtime_status=PluginRuntimeStatus.LOAD_FAILED,
state=True,
plugin_desc=None,
),
Plugin(
id="OtherPlugin",
plugin_name="Runtime name",
plugin_desc="Runtime description",
plugin_version="loaded-version",
runtime_status=PluginRuntimeStatus.READY,
),
Plugin(id="MissingPlugin", plugin_name="MissingPlugin"),
]
result = apply_declared_metadata_fallback(
plugins,
{"demoplugin": first, "otherplugin": second},
)
assert result[0].plugin_name == "Demo from snapshot"
assert result[0].plugin_desc == "Saved description"
assert result[0].plugin_icon == "saved.svg"
assert result[0].plugin_author == "Saved author"
assert result[0].plugin_version == "2.0.0"
assert result[0].plugin_label == "saved,plugin"
assert result[0].runtime_status is PluginRuntimeStatus.LOAD_FAILED
assert result[0].state is True
assert result[1].plugin_name == "Runtime name"
assert result[1].plugin_desc == "Runtime description"
assert result[1].plugin_version == "loaded-version"
assert result[1].runtime_status is PluginRuntimeStatus.READY
assert result[2].plugin_name == "MissingPlugin"
+127 -24
View File
@@ -1,18 +1,29 @@
import asyncio
from contextlib import nullcontext
from unittest.mock import ANY, AsyncMock, MagicMock, patch
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
from app.api.endpoints import plugin as plugin_endpoint
from app import schemas
from app.api.endpoints.plugin import plugin_history
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 import plugin as plugin_endpoint
from app.api.endpoints.plugin import (
plugin_history,
plugin_releases,
plugin_static_file,
reload_plugin,
reset_plugin,
runtime_status,
uninstall_plugin,
)
from app.api.endpoints.system import sync_plugin_market_from_wiki
from app.application.plugin.config import PluginConfigCommand
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.foundation.singleton import Singleton
from app.runtime.config import settings
from app.runtime.extensions.plugin.admission import PluginMutationAdmission
from app.runtime.extensions.plugin_manager import PluginManager
@@ -20,7 +31,47 @@ from app.runtime.tasks import TaskRegistry
from app.schemas.event import PluginDataResetEventData
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
from app.schemas.types import ChainEventType, SystemConfigKey
from app.foundation.singleton import Singleton
NOW = datetime(2026, 8, 26, 12, 0, tzinfo=timezone.utc)
SOURCE_KEY = "github:demo/plugins"
SOURCE_URL = "https://github.com/demo/plugins"
def _plugin_identity(
*,
metadata: PluginDeclaredMetadata | None = None,
) -> PluginIdentity:
"""构造绑定到测试仓库的插件身份。"""
has_payload = metadata is not None
return PluginIdentity(
plugin_id="DemoPlugin",
normalized_plugin_id="demoplugin",
trusted_source_type=TrustedPluginSourceType.THIRD_PARTY,
trusted_source_key=SOURCE_KEY,
binding_basis=PluginBindingBasis.EXPLICIT_INSTALL,
payload_source_type=(
PluginPayloadSourceType.THIRD_PARTY
if has_payload
else PluginPayloadSourceType.UNKNOWN
),
payload_source_key=SOURCE_KEY if has_payload else None,
declared_version="1.0.0" if has_payload else None,
package_generation="v3" if has_payload else None,
declared_metadata=metadata,
payload_receipt="sha256:" + "0" * 64 if has_payload else None,
revision=1,
created_at=NOW,
updated_at=NOW,
bound_at=NOW,
payload_applied_at=NOW if has_payload else None,
)
def _persistence(identity: PluginIdentity) -> MagicMock:
"""构造只暴露身份读取合同的异步持久化替身。"""
persistence = MagicMock()
persistence.get_identity = AsyncMock(return_value=identity)
return persistence
def test_plugin_history_merges_remote_metadata():
@@ -36,24 +87,31 @@ def test_plugin_history_merges_remote_metadata():
)
market_plugin = schemas.Plugin(
id="DemoPlugin",
repo_url="https://github.com/demo/plugins",
repo_url=SOURCE_URL,
history={"v1.1.0": "- 新增更新说明"},
system_version=">=2.0.0",
system_version_compatible=True,
has_update=True,
)
plugin_manager = MagicMock()
plugin_manager.get_local_plugins.return_value = [installed_plugin]
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
persistence = _persistence(_plugin_identity())
with patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager):
with (
patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager),
patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence),
):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
assert result.repo_url == "https://github.com/demo/plugins"
assert result.history == {"v1.1.0": "- 新增更新说明"}
assert result.system_version == ">=2.0.0"
assert result.has_update
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(
SOURCE_URL, settings.VERSION_FLAG, True
)
def test_runtime_status_reports_pending_and_terminal_counts():
@@ -103,49 +161,94 @@ def test_plugin_history_returns_installed_plugin_when_remote_missing():
installed=True,
)
plugin_manager = MagicMock()
plugin_manager.get_local_plugins.return_value = [installed_plugin]
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[])
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[])
persistence = _persistence(_plugin_identity())
with patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager):
with (
patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager),
patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence),
):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
assert result.id == "DemoPlugin"
assert result.history == {}
def test_plugin_history_uses_installed_repo_without_refreshing_all_markets():
def test_plugin_history_uses_bound_repo_without_refreshing_all_markets():
"""
已安装插件记录了来源仓库时,更新说明只刷新该仓库,避免弹窗触发全市场慢刷新
更新说明只读取持久化绑定仓库,不信任运行态 DTO 中可漂移的来源地址
"""
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
repo_url="https://github.com/demo/plugins",
repo_url="https://github.com/attacker/plugins",
installed=True,
)
market_plugin = schemas.Plugin(
id="DemoPlugin",
repo_url="https://github.com/demo/plugins",
repo_url=SOURCE_URL,
history={"v1.1.0": "- 新增更新说明"},
)
plugin_manager = MagicMock()
plugin_manager.get_local_plugins.return_value = [installed_plugin]
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[])
persistence = _persistence(_plugin_identity())
with patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager):
with (
patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager),
patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence),
):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
assert result.history == {"v1.1.0": "- 新增更新说明"}
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(
"https://github.com/demo/plugins", settings.VERSION_FLAG, True
SOURCE_URL, settings.VERSION_FLAG, True
)
plugin_manager.async_get_online_plugins.assert_not_awaited()
def test_plugin_history_uses_declared_metadata_when_bound_market_is_unavailable():
"""加载失败且绑定市场不可用时,详情仍返回已提交载荷的展示信息。"""
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="DemoPlugin",
installed=True,
runtime_status=PluginRuntimeStatus.LOAD_FAILED,
)
metadata = PluginDeclaredMetadata.from_package(
{
"name": "Saved Demo",
"description": "Saved description",
"author": "Saved author",
"v3": True,
},
declaration_version="1.0.0",
manifest_matches_payload=True,
)
plugin_manager = MagicMock()
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[])
persistence = _persistence(_plugin_identity(metadata=metadata))
with (
patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager),
patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence),
):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
assert result.plugin_name == "Saved Demo"
assert result.plugin_desc == "Saved description"
assert result.plugin_author == "Saved author"
assert result.plugin_version == "1.0.0"
assert result.runtime_status is PluginRuntimeStatus.LOAD_FAILED
def test_plugin_releases_returns_supported_versions_with_latest_and_current(monkeypatch):
"""
release 列表接口返回可安装版本,并标记当前 package 最新版本与本地已安装版本。
+23 -8
View File
@@ -9,6 +9,7 @@ import pytest
import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
@@ -34,6 +35,7 @@ def _identity(
declared_version: str | None = None,
) -> PluginIdentity:
"""构造一份已从官方仓成功安装的物理插件身份。"""
installed_version = declared_version or "1.0.0"
return PluginIdentity(
plugin_id=plugin_id,
normalized_plugin_id=plugin_id.lower(),
@@ -42,11 +44,19 @@ def _identity(
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.OFFICIAL,
payload_source_key=OFFICIAL_SOURCE,
declared_version=declared_version or "1.0.0",
declared_version=installed_version,
package_generation="v3",
system_version=None,
supports_v3=None,
supports_v3t=None,
declared_metadata=PluginDeclaredMetadata.from_package(
{
"name": "Demo",
"description": "Demo plugin",
"v3": True,
"v3t": False,
"release": True,
},
declaration_version=installed_version,
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "0" * 64,
revision=1,
created_at=NOW,
@@ -197,9 +207,15 @@ def test_local_payload_preserves_trusted_online_binding() -> None:
payload_source_key=None,
declared_version="2.0.0-dev",
package_generation="v3",
system_version=">=3.0.0",
supports_v3=True,
supports_v3t=False,
declared_metadata=PluginDeclaredMetadata.from_package(
{
"name": "Demo local",
"v3": True,
"v3t": False,
},
declaration_version="2.0.0-dev",
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "a" * 64,
payload_applied_at=NOW + timedelta(seconds=1),
updated_at=NOW + timedelta(seconds=1),
@@ -321,7 +337,6 @@ def test_plugin_identity_rejects_noncanonical_physical_id(plugin_id) -> None:
("field_name", "value", "message"),
(
("declared_version", "v" * 65, "插件声明版本"),
("system_version", ">" * 129, "插件系统版本要求"),
),
)
def test_plugin_identity_rejects_values_longer_than_database_columns(
+251 -12
View File
@@ -22,12 +22,18 @@ except ModuleNotFoundError:
from app.db.models.pluginidentity import PluginIdentity
MIGRATION = "database.versions.d2e4f6a8b0c1_3_0_9"
BASE_MIGRATION = "database.versions.d2e4f6a8b0c1_3_0_9"
INSTALLATION_MIGRATION = "database.versions.e4f7a1b2c3d5_3_0_10"
DECLARATION_MIGRATION = "database.versions.5f2a9c1e7b4d_3_0_12"
def _bind_migration(monkeypatch, connection):
def _bind_migration(
monkeypatch,
connection,
module_name: str = BASE_MIGRATION,
):
"""把迁移绑定到隔离数据库连接。"""
migration = importlib.import_module(MIGRATION)
migration = importlib.import_module(module_name)
context = MigrationContext.configure(connection)
monkeypatch.setattr(migration, "op", Operations(context))
return migration
@@ -36,13 +42,27 @@ def _bind_migration(monkeypatch, connection):
def test_plugin_identity_migration_upgrades_twice_and_downgrades(
monkeypatch,
) -> None:
"""SQLite schema 应可重复升级并完整删除 dormant 身份表"""
"""SQLite 应可从旧身份表升级到声明快照并按逆序完整回滚"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
migration.upgrade()
installation = _bind_migration(
monkeypatch,
connection,
INSTALLATION_MIGRATION,
)
installation.upgrade()
installation.upgrade()
declaration = _bind_migration(
monkeypatch,
connection,
DECLARATION_MIGRATION,
)
declaration.upgrade()
declaration.upgrade()
inspector = sa.inspect(connection)
assert "pluginidentity" in inspector.get_table_names()
@@ -65,9 +85,23 @@ def test_plugin_identity_migration_upgrades_twice_and_downgrades(
"ck_pluginidentity_normalized_plugin_id",
"ck_pluginidentity_revision",
}
assert "plugininstallation" in inspector.get_table_names()
declaration.downgrade()
installation.downgrade()
migration.downgrade()
assert "pluginidentity" not in sa.inspect(connection).get_table_names()
assert not {
"pluginidentity",
"plugininstallation",
} & set(sa.inspect(connection).get_table_names())
migration.upgrade()
installation.upgrade()
declaration.upgrade()
assert {
column["name"]
for column in sa.inspect(connection).get_columns("pluginidentity")
} == {column.name for column in PluginIdentity.__table__.columns}
def test_plugin_identity_migration_accepts_fresh_current_schema(monkeypatch) -> None:
@@ -75,7 +109,11 @@ def test_plugin_identity_migration_accepts_fresh_current_schema(monkeypatch) ->
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
PluginIdentity.__table__.create(connection)
migration = _bind_migration(monkeypatch, connection)
migration = _bind_migration(
monkeypatch,
connection,
DECLARATION_MIGRATION,
)
migration.upgrade()
migration.upgrade()
@@ -86,9 +124,126 @@ def test_plugin_identity_migration_accepts_fresh_current_schema(monkeypatch) ->
} == {column.name for column in PluginIdentity.__table__.columns}
def test_plugin_identity_declaration_migration_backfills_and_restores_legacy_fields(
monkeypatch,
) -> None:
"""声明迁移应保守回填旧字段,并能在降级时恢复旧投影。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
base = _bind_migration(monkeypatch, connection)
base.upgrade()
table = sa.Table(
"pluginidentity",
sa.MetaData(),
autoload_with=connection,
)
connection.execute(
table.insert().values(
plugin_id="DemoPlugin",
normalized_plugin_id="demoplugin",
trusted_source_type="official",
trusted_source_key="github:jxxghp/moviepilot-plugins",
binding_basis="official_default",
payload_source_type="official",
payload_source_key="github:jxxghp/moviepilot-plugins",
declared_version="1.0.0",
package_generation="v3",
system_version=">=3.0.0",
supports_v3=True,
supports_v3t=False,
payload_receipt="sha256:" + "0" * 64,
revision=1,
created_at="2026-08-25T12:00:00+00:00",
updated_at="2026-08-25T12:00:00+00:00",
bound_at="2026-08-25T12:00:00+00:00",
payload_applied_at="2026-08-25T12:00:00+00:00",
)
)
unknown_id = connection.execute(
table.insert().values(
plugin_id="UnknownPlugin",
normalized_plugin_id="unknownplugin",
trusted_source_type="unknown",
binding_basis="legacy_unbound",
payload_source_type="unknown",
revision=1,
created_at="2026-08-25T12:00:00+00:00",
updated_at="2026-08-25T12:00:00+00:00",
).returning(table.c.id)
).scalar_one()
declaration = _bind_migration(
monkeypatch,
connection,
DECLARATION_MIGRATION,
)
declaration.upgrade()
current = sa.Table(
"pluginidentity",
sa.MetaData(),
autoload_with=connection,
)
row = connection.execute(
sa.select(current).where(current.c.plugin_id == "DemoPlugin")
).mappings().one()
assert row["declared_metadata"] == {
"schema_version": 1,
"declaration_version": None,
"manifest_matches_payload": False,
"manifest": {"system_version": ">=3.0.0"},
"runtime": {"v3": True, "v3t": False},
}
assert connection.execute(
sa.select(current.c.id).where(current.c.declared_metadata.is_(None))
).scalars().all() == [unknown_id]
declaration.downgrade()
restored = sa.Table(
"pluginidentity",
sa.MetaData(),
autoload_with=connection,
)
restored_row = connection.execute(
sa.select(restored).where(restored.c.plugin_id == "DemoPlugin")
).mappings().one()
assert restored_row["system_version"] == ">=3.0.0"
assert restored_row["supports_v3"] is True
assert restored_row["supports_v3t"] is False
assert "declared_metadata" not in restored.c
def test_plugin_identity_declaration_migration_rejects_partial_legacy_schema(
monkeypatch,
) -> None:
"""非 canonical 旧字段集合必须停止迁移,不能静默丢弃残余声明。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
base = _bind_migration(monkeypatch, connection)
base.upgrade()
with Operations(MigrationContext.configure(connection)).batch_alter_table(
"pluginidentity"
) as batch_op:
batch_op.drop_column("supports_v3t")
declaration = _bind_migration(
monkeypatch,
connection,
DECLARATION_MIGRATION,
)
with pytest.raises(RuntimeError, match="旧声明字段不完整"):
declaration.upgrade()
columns = {
column["name"]
for column in sa.inspect(connection).get_columns("pluginidentity")
}
assert "declared_metadata" not in columns
assert {"system_version", "supports_v3"} <= columns
def test_plugin_identity_migration_matches_postgresql_identity() -> None:
"""独立 Alembic 路径应保留 PostgreSQL 循环 Identity 主键。"""
migration = importlib.import_module(MIGRATION)
migration = importlib.import_module(BASE_MIGRATION)
metadata = sa.MetaData()
table = sa.Table(
"pluginidentity",
@@ -148,25 +303,80 @@ def test_plugin_identity_migration_runs_on_postgresql(monkeypatch) -> None:
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
migration.upgrade()
table = sa.Table(
installation = _bind_migration(
monkeypatch,
connection,
INSTALLATION_MIGRATION,
)
installation.upgrade()
legacy_table = sa.Table(
"pluginidentity",
sa.MetaData(),
autoload_with=connection,
)
inserted_id = connection.execute(
table.insert().values(
legacy_table.insert().values(
plugin_id="DemoPlugin",
normalized_plugin_id="demoplugin",
trusted_source_type="official",
trusted_source_key="github:jxxghp/moviepilot-plugins",
binding_basis="official_default",
payload_source_type="official",
payload_source_key="github:jxxghp/moviepilot-plugins",
declared_version="1.0.0",
package_generation="v3",
system_version=">=3.0.0",
supports_v3=True,
supports_v3t=False,
payload_receipt="sha256:" + "0" * 64,
revision=1,
created_at="2026-08-25T12:00:00+00:00",
updated_at="2026-08-25T12:00:00+00:00",
bound_at="2026-08-25T12:00:00+00:00",
payload_applied_at="2026-08-25T12:00:00+00:00",
).returning(legacy_table.c.id)
).scalar_one()
assert inserted_id == 1
unknown_id = connection.execute(
legacy_table.insert().values(
plugin_id="UnknownPlugin",
normalized_plugin_id="unknownplugin",
trusted_source_type="unknown",
binding_basis="legacy_unbound",
payload_source_type="unknown",
revision=1,
created_at="2026-08-25T12:00:00+00:00",
updated_at="2026-08-25T12:00:00+00:00",
).returning(table.c.id)
).returning(legacy_table.c.id)
).scalar_one()
assert inserted_id == 1
assert unknown_id == 2
declaration = _bind_migration(
monkeypatch,
connection,
DECLARATION_MIGRATION,
)
declaration.upgrade()
declaration.upgrade()
table = sa.Table(
"pluginidentity",
sa.MetaData(),
autoload_with=connection,
)
row = connection.execute(
sa.select(table).where(table.c.id == inserted_id)
).mappings().one()
assert row["declared_metadata"] == {
"schema_version": 1,
"declaration_version": None,
"manifest_matches_payload": False,
"manifest": {"system_version": ">=3.0.0"},
"runtime": {"v3": True, "v3t": False},
}
unknown_row = connection.execute(
sa.select(table).where(table.c.id == unknown_id)
).mappings().one()
assert unknown_row["declared_metadata"] is None
with pytest.raises(sa.exc.IntegrityError):
with connection.begin_nested():
@@ -183,6 +393,35 @@ def test_plugin_identity_migration_runs_on_postgresql(monkeypatch) -> None:
)
)
declaration.downgrade()
restored = sa.Table(
"pluginidentity",
sa.MetaData(),
autoload_with=connection,
)
restored_row = connection.execute(
sa.select(restored).where(restored.c.id == inserted_id)
).mappings().one()
assert restored_row["system_version"] == ">=3.0.0"
assert restored_row["supports_v3"] is True
assert restored_row["supports_v3t"] is False
declaration.upgrade()
reupgraded = sa.Table(
"pluginidentity",
sa.MetaData(),
autoload_with=connection,
)
reupgraded_row = connection.execute(
sa.select(reupgraded).where(reupgraded.c.id == inserted_id)
).mappings().one()
assert reupgraded_row["declared_metadata"]["runtime"] == {
"v3": True,
"v3t": False,
}
declaration.downgrade()
installation.downgrade()
migration.downgrade()
assert "pluginidentity" not in sa.inspect(connection).get_table_names()
finally:
@@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
@@ -34,6 +35,15 @@ THIRD_PARTY_REPO = "https://github.com/example/MoviePilot-Plugins"
THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins"
def _metadata(version: str, *, matches_payload: bool = True) -> PluginDeclaredMetadata:
"""构造测试用 package 声明快照。"""
return PluginDeclaredMetadata.from_package(
{"name": "Demo", "v3": True, "v3t": False},
declaration_version=version,
manifest_matches_payload=matches_payload,
)
class _Persistence:
"""提供可观察 CAS 竞争的内存迁移持久化端口。"""
@@ -140,9 +150,7 @@ def _legacy(plugin_id: str = "DemoPlugin") -> PluginIdentity:
payload_source_key=None,
declared_version=None,
package_generation=None,
system_version=None,
supports_v3=None,
supports_v3t=None,
declared_metadata=None,
payload_receipt=None,
revision=1,
created_at=NOW,
@@ -356,6 +364,7 @@ async def test_migration_does_not_replace_existing_bound_or_local_identity() ->
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="1.0.0-dev",
package_generation="v3",
declared_metadata=_metadata("1.0.0-dev"),
payload_receipt="sha256:" + "1" * 64,
payload_applied_at=NOW,
)
@@ -399,6 +408,7 @@ async def test_collect_online_restore_plugins_requires_trust_and_local_payload()
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="9.9.10",
package_generation="v3",
declared_metadata=_metadata("9.9.10"),
payload_receipt="sha256:" + "2" * 64,
bound_at=NOW,
payload_applied_at=NOW,
@@ -409,6 +419,7 @@ async def test_collect_online_restore_plugins_requires_trust_and_local_payload()
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="1.0.0-dev",
package_generation="v3",
declared_metadata=_metadata("1.0.0-dev"),
payload_receipt="sha256:" + "3" * 64,
payload_applied_at=NOW,
)
@@ -421,6 +432,7 @@ async def test_collect_online_restore_plugins_requires_trust_and_local_payload()
payload_source_key=OFFICIAL_SOURCE,
declared_version="1.2.0",
package_generation="v3",
declared_metadata=_metadata("1.2.0"),
payload_receipt="sha256:" + "4" * 64,
bound_at=NOW,
payload_applied_at=NOW,
@@ -470,6 +482,7 @@ async def test_sync_runs_identity_migration_before_automatic_install(
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="9.9.10",
package_generation="v3",
declared_metadata=_metadata("9.9.10"),
payload_receipt="sha256:" + "5" * 64,
bound_at=NOW,
payload_applied_at=NOW,
+25 -6
View File
@@ -7,6 +7,7 @@ import pytest
import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
@@ -23,6 +24,15 @@ OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins"
THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins"
def _metadata(version: str, *, matches_payload: bool = True) -> PluginDeclaredMetadata:
"""构造测试用 package 声明快照。"""
return PluginDeclaredMetadata.from_package(
{"name": "Demo", "v3": True, "v3t": False},
declaration_version=version,
manifest_matches_payload=matches_payload,
)
def _identity(
plugin_id: str = "DemoPlugin",
*,
@@ -43,9 +53,13 @@ def _identity(
payload_source_key=payload_source_key,
declared_version="1.0.0",
package_generation="v3",
system_version=None,
supports_v3=None,
supports_v3t=None,
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo", "v3": True, "v3t": False},
declaration_version="1.0.0",
manifest_matches_payload=True,
)
if payload_source_type is not PluginPayloadSourceType.UNKNOWN
else None,
payload_receipt="sha256:" + "0" * 64,
revision=1,
created_at=NOW,
@@ -95,9 +109,7 @@ def _legacy_identity(plugin_id: str = "DemoPlugin") -> PluginIdentity:
payload_source_key=None,
declared_version=None,
package_generation=None,
system_version=None,
supports_v3=None,
supports_v3t=None,
declared_metadata=None,
payload_receipt=None,
revision=1,
created_at=NOW,
@@ -124,6 +136,7 @@ def _online_binding_target(
payload_source_key=source_key,
declared_version="2.0.0",
package_generation="v3",
declared_metadata=_metadata("2.0.0"),
payload_receipt="sha256:" + "2" * 64,
updated_at=updated_at,
bound_at=updated_at,
@@ -214,6 +227,7 @@ def test_bind_local_commits_only_legacy_unbound_transition(identity_store) -> No
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="2.0.0-dev",
package_generation="v3",
declared_metadata=_metadata("2.0.0-dev"),
payload_receipt="sha256:" + "1" * 64,
updated_at=NOW + timedelta(seconds=1),
payload_applied_at=NOW + timedelta(seconds=1),
@@ -249,6 +263,7 @@ def test_bind_online_commits_legacy_and_local_first_bindings(identity_store) ->
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="2.0.0-dev",
package_generation="v3",
declared_metadata=_metadata("2.0.0-dev"),
payload_receipt="sha256:" + "1" * 64,
updated_at=NOW + timedelta(seconds=1),
payload_applied_at=NOW + timedelta(seconds=1),
@@ -302,6 +317,7 @@ def test_first_local_install_still_uses_ordinary_create(identity_store) -> None:
payload_source_type=PluginPayloadSourceType.LOCAL,
payload_source_key=None,
declared_version="2.0.0-dev",
declared_metadata=_metadata("2.0.0-dev"),
payload_receipt="sha256:" + "1" * 64,
bound_at=None,
)
@@ -325,6 +341,7 @@ def test_bind_local_rejects_nonlegacy_state_and_stale_revision(identity_store) -
payload_source_key=None,
bound_at=None,
declared_version="2.0.0-dev",
declared_metadata=_metadata("2.0.0-dev"),
payload_receipt="sha256:" + "1" * 64,
updated_at=NOW + timedelta(seconds=1),
payload_applied_at=NOW + timedelta(seconds=1),
@@ -341,6 +358,7 @@ def test_bind_local_rejects_nonlegacy_state_and_stale_revision(identity_store) -
payload_source_key=None,
declared_version=None,
package_generation=None,
declared_metadata=None,
payload_receipt=None,
bound_at=None,
payload_applied_at=None,
@@ -352,6 +370,7 @@ def test_bind_local_rejects_nonlegacy_state_and_stale_revision(identity_store) -
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="2.0.0-dev",
package_generation="v3",
declared_metadata=_metadata("2.0.0-dev"),
payload_receipt="sha256:" + "1" * 64,
updated_at=NOW + timedelta(seconds=1),
payload_applied_at=NOW + timedelta(seconds=1),
+56 -4
View File
@@ -10,6 +10,7 @@ from app.application.plugin.admission import (
PluginSourceAdmissionError,
admit_plugin_install,
)
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
@@ -58,9 +59,11 @@ def _identity() -> PluginIdentity:
payload_source_key=OFFICIAL,
declared_version="1.0.0",
package_generation="v3",
system_version=None,
supports_v3=True,
supports_v3t=None,
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo", "v3": True, "v3t": False},
declaration_version="1.0.0",
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "0" * 64,
revision=3,
created_at=NOW,
@@ -104,6 +107,55 @@ def test_same_source_update_preserves_binding_and_advances_payload() -> None:
assert target.declared_version == "2.0.0"
def test_current_release_marks_declaration_as_matching_payload() -> None:
"""按当前 package 版本安装时,声明快照应标记为对应当前载荷。"""
admission = admit_plugin_install(
_inventory(_online_candidate()),
request=PluginInstallAdmissionRequest(
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
requested_repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
),
identity=None,
now=NOW,
)
target = admission.build_identity(
payload_receipt="sha256:" + "7" * 64,
applied_at=NOW,
)
assert target.declared_metadata is not None
assert target.declared_metadata.declaration_version == "2.0.0"
assert target.declared_metadata.manifest_matches_payload is True
def test_historical_release_marks_declaration_as_not_matching_payload() -> None:
"""安装历史 Release 时必须保留其声明版本,并标记与当前载荷不对应。"""
admission = admit_plugin_install(
_inventory(_online_candidate()),
request=PluginInstallAdmissionRequest(
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
requested_repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
),
identity=None,
now=NOW,
)
target = admission.build_identity(
payload_receipt="sha256:" + "8" * 64,
applied_at=NOW,
declared_version="1.0.0",
manifest_matches_payload=False,
)
assert target.declared_version == "1.0.0"
assert target.declared_metadata is not None
assert target.declared_metadata.declaration_version == "2.0.0"
assert target.declared_metadata.manifest_matches_payload is False
def test_first_online_binding_uses_payload_commit_time() -> None:
"""首次在线绑定在载荷提交时生效,不能早于身份创建时间。"""
applied_at = NOW + timedelta(seconds=1)
@@ -315,7 +367,7 @@ def test_legacy_identity_can_bind_explicit_online_source() -> None:
payload_source_key=None,
declared_version=None,
package_generation=None,
supports_v3=None,
declared_metadata=None,
payload_receipt=None,
bound_at=None,
payload_applied_at=None,
+35 -3
View File
@@ -14,6 +14,7 @@ from app.application.plugin.admission import (
PluginInstallAdmissionRequest,
admit_plugin_install,
)
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
@@ -56,9 +57,11 @@ def _identity(*, version: str = "1.0.0", revision: int = 1) -> PluginIdentity:
payload_source_key=SOURCE_KEY,
declared_version=version,
package_generation="v3",
system_version=None,
supports_v3=True,
supports_v3t=False,
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo", "v3": True, "v3t": False},
declaration_version=version,
manifest_matches_payload=True,
),
payload_receipt=RECEIPT,
revision=revision,
created_at=NOW,
@@ -197,6 +200,8 @@ class _PersistenceSpy:
self.calls.append("journal_commit")
if self.commit_error:
raise self.commit_error
if identity_target is not None:
self.identity = identity_target
record = self.records[transaction_id]
record = replace(
record,
@@ -624,6 +629,33 @@ async def test_precommit_failure_restores_files_and_runtime(
rollback_reloader.assert_not_awaited()
@pytest.mark.asyncio
async def test_precommit_failure_keeps_previous_declared_metadata_snapshot():
"""提交前失败不得把新声明快照写成当前身份事实。"""
previous = _identity(version="1.0.0", revision=3)
persistence = _PersistenceSpy([], identity=previous)
rollback_reloader = AsyncMock()
target_reloader = AsyncMock(side_effect=RuntimeError("reload failed"))
command, _, _ = _command(
persistence=persistence,
target_reloader=target_reloader,
rollback_reloader=rollback_reloader,
)
result = await _execute(
command,
admission=_admission(identity=previous),
)
assert result.success is False
assert result.failure_stage == "runtime_reload"
assert result.rollback.journal_deleted is True
assert persistence.identity is previous
assert persistence.identity.declared_version == "1.0.0"
assert persistence.identity.declared_metadata is previous.declared_metadata
rollback_reloader.assert_awaited_once_with("DemoPlugin")
@pytest.mark.asyncio
async def test_database_commit_failure_restores_runtime_before_deleting_journal():
"""数据库最终提交失败时,文件、运行态和 PREPARED journal 必须一起补偿。"""
+11 -6
View File
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, Mock
import pytest
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.gateway import PluginInstallGateway
from app.application.plugin.identity import (
PluginBindingBasis,
@@ -141,9 +142,11 @@ async def test_gateway_checks_compatibility_on_final_trusted_candidate() -> None
payload_source_key=None,
declared_version="9.9.9",
package_generation="v3",
system_version=None,
supports_v3=True,
supports_v3t=None,
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo local", "v3": True},
declaration_version="9.9.9",
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "1" * 64,
revision=3,
created_at=NOW,
@@ -253,9 +256,11 @@ async def test_gateway_forwards_explicit_source_change_revision() -> None:
payload_source_key="github:jxxghp/moviepilot-plugins",
declared_version="1.0.0",
package_generation="v3",
system_version=None,
supports_v3=True,
supports_v3t=None,
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo", "v3": True},
declaration_version="1.0.0",
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "0" * 64,
revision=4,
created_at=NOW,
+15 -5
View File
@@ -26,6 +26,7 @@ except ModuleNotFoundError:
POSTGRESQL_DIALECT = "postgresql+psycopg"
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
@@ -63,9 +64,11 @@ def _identity(
payload_source_key="github:jxxghp/moviepilot-plugins",
declared_version=version,
package_generation="v3",
system_version=None,
supports_v3=True,
supports_v3t=False,
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo", "v3": True, "v3t": False},
declaration_version=version,
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "0" * 64,
revision=revision,
created_at=NOW,
@@ -238,8 +241,11 @@ def _identity_model(identity: PluginIdentity) -> PluginIdentityModel:
payload_source_key=identity.payload_source_key,
declared_version=identity.declared_version,
package_generation=identity.package_generation,
supports_v3=identity.supports_v3,
supports_v3t=identity.supports_v3t,
declared_metadata=(
identity.declared_metadata.to_json()
if identity.declared_metadata is not None
else None
),
payload_receipt=identity.payload_receipt,
revision=identity.revision,
created_at=identity.created_at.isoformat(),
@@ -610,6 +616,10 @@ def postgresql_installation_stores():
connection,
"database.versions.e4f7a1b2c3d5_3_0_10",
)
_upgrade_migration(
connection,
"database.versions.5f2a9c1e7b4d_3_0_12",
)
factory = sessionmaker(bind=engine, expire_on_commit=False)
_set_config(factory, [])
first = TransactionalPluginInstallationStore(
+6 -3
View File
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, Mock
import pytest
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
@@ -38,9 +39,11 @@ def _identity(*, revision: int = 2, receipt: str = RECEIPT) -> PluginIdentity:
payload_source_key="github:jxxghp/moviepilot-plugins",
declared_version="2.0.0",
package_generation="v3",
system_version=None,
supports_v3=True,
supports_v3t=True,
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo", "v3": True, "v3t": True},
declaration_version="2.0.0",
manifest_matches_payload=True,
),
payload_receipt=receipt,
revision=revision,
created_at=NOW,
+1 -3
View File
@@ -64,9 +64,7 @@ def _identity(source_type: TrustedPluginSourceType, source_key: str) -> PluginId
payload_source_key=None,
declared_version=None,
package_generation=None,
system_version=None,
supports_v3=None,
supports_v3t=None,
declared_metadata=None,
payload_receipt=None,
revision=1,
created_at=now,
+6 -3
View File
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, Mock
import pytest
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.gateway import PluginInstallGateway
from app.application.plugin.identity import (
PluginBindingBasis,
@@ -165,9 +166,11 @@ async def test_market_sync_reuses_startup_lease_through_real_gateway(
payload_source_key=None,
declared_version="9.9.10",
package_generation="v3",
system_version=None,
supports_v3=True,
supports_v3t=None,
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo local", "v3": True},
declaration_version="9.9.10",
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "0" * 64,
revision=1,
created_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),