Files
MoviePilot/app/runtime/compat/resources.py
Aqr-K 6610a8c008 fix: retain plugin lifecycle ownership and recover installation state (#7146)
* fix: retain plugin lifecycle ownership and recover installation state

* test: cover plugin rollback and uninstall lifecycle boundaries

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 07:44:07 +08:00

228 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
从旧插件源码导入中识别必须提前就绪的宿主资源。
"""
from __future__ import annotations
import ast
import hashlib
import threading
import tokenize
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, FrozenSet, Iterable, Set, Tuple
@dataclass(frozen=True, slots=True)
class ResourceImportRule:
"""
描述第三方模块导入与宿主资源能力之间的静态映射。
:param capability_id: 导入前应准备的托管资源能力标识
:param module_prefixes: 需要此资源的第三方模块前缀集合
:param headed_entrypoints: 需要有界面显示资源的精确导入入口集合
"""
capability_id: str
module_prefixes: tuple[str, ...]
headed_entrypoints: tuple[str, ...]
# 旧插件可能绕过宿主浏览器门面直接调用 CloakBrowser。其六个 launch
# 入口均允许 headed 模式,因此导入该包或任意子模块时保守准备虚拟显示。
RESOURCE_IMPORT_RULES: tuple[ResourceImportRule, ...] = (
ResourceImportRule(
capability_id="host.display",
module_prefixes=("cloakbrowser",),
headed_entrypoints=(
"launch",
"launch_async",
"launch_context",
"launch_context_async",
"launch_persistent_context",
"launch_persistent_context_async",
),
),
)
_scan_cache_lock = threading.RLock()
_scan_cache: Dict[Path, Tuple[bytes, FrozenSet[str]]] = {}
class PluginResourceImportScanError(RuntimeError):
"""
表示单个插件源码无法生成可靠的精确资源集合。
"""
def _all_resource_capabilities() -> FrozenSet[str]:
"""
扫描不完整时返回全部已登记资源,避免漏失导入前置条件。
"""
return frozenset(rule.capability_id for rule in RESOURCE_IMPORT_RULES)
def _matches_module(module_name: str, module_prefixes: Iterable[str]) -> bool:
"""
按完整包边界匹配模块,避免相似名称产生误报。
"""
return any(
module_name == prefix or module_name.startswith(f"{prefix}.")
for prefix in module_prefixes
)
def _dynamic_import_aliases(tree: ast.AST) -> tuple[Set[str], Set[str]]:
"""
收集 importlib 模块及 import_module 函数的本地别名。
"""
module_aliases = {"importlib"}
function_aliases: Set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for imported in node.names:
if imported.name == "importlib":
module_aliases.add(imported.asname or imported.name)
elif isinstance(node, ast.ImportFrom) and node.module == "importlib":
for imported in node.names:
if imported.name == "import_module":
function_aliases.add(imported.asname or imported.name)
return module_aliases, function_aliases
def _constant_dynamic_import(
node: ast.Call,
*,
importlib_aliases: Set[str],
import_module_aliases: Set[str],
) -> str | None:
"""
提取受支持动态导入调用中的常量模块名。
"""
if not node.args:
return None
is_import_call = isinstance(node.func, ast.Name) and (
node.func.id == "__import__" or node.func.id in import_module_aliases
)
if (
isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id in importlib_aliases
and node.func.attr == "import_module"
):
is_import_call = True
if not is_import_call:
return None
argument = node.args[0]
if isinstance(argument, ast.Constant) and isinstance(argument.value, str):
return argument.value
return None
def _imported_modules(tree: ast.AST) -> FrozenSet[str]:
"""
提取静态导入以及可确定目标的动态导入模块名。
"""
modules: Set[str] = set()
importlib_aliases, import_module_aliases = _dynamic_import_aliases(tree)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
modules.update(imported.name for imported in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
modules.add(node.module)
elif isinstance(node, ast.Call):
module_name = _constant_dynamic_import(
node,
importlib_aliases=importlib_aliases,
import_module_aliases=import_module_aliases,
)
if module_name:
modules.add(module_name)
return frozenset(modules)
def _scan_source(plugin_id: str, path: Path) -> FrozenSet[str]:
"""
读取并解析单个源码文件;不完整结果不能进入插件导入阶段。
"""
try:
before_stat = path.stat()
# 文件系统时间粒度不足以证明内容不变,每次读取内容摘要;只复用相同内容的AST结论。
cache_key = (
before_stat.st_mtime_ns,
before_stat.st_ctime_ns,
before_stat.st_size,
before_stat.st_dev,
before_stat.st_ino,
)
with tokenize.open(path) as source_file:
source = source_file.read()
content_key = hashlib.sha256(source.encode("utf-8")).digest()
after_stat = path.stat()
except (OSError, SyntaxError, UnicodeError) as error:
raise PluginResourceImportScanError(
f"无法扫描插件 {plugin_id} 源码 {path.name}:{error}"
) from error
after_key = (
after_stat.st_mtime_ns,
after_stat.st_ctime_ns,
after_stat.st_size,
after_stat.st_dev,
after_stat.st_ino,
)
if cache_key != after_key:
raise PluginResourceImportScanError(
f"扫描插件 {plugin_id} 时源码 {path.name} 发生变化"
)
with _scan_cache_lock:
cached = _scan_cache.get(path)
if cached and cached[0] == content_key:
return cached[1]
try:
tree = ast.parse(source, filename=str(path))
except (SyntaxError, UnicodeError) as error:
raise PluginResourceImportScanError(
f"无法扫描插件 {plugin_id} 源码 {path.name}:{error}"
) from error
capabilities: Set[str] = set()
for module_name in _imported_modules(tree):
for rule in RESOURCE_IMPORT_RULES:
if _matches_module(module_name, rule.module_prefixes):
capabilities.add(rule.capability_id)
result = frozenset(capabilities)
with _scan_cache_lock:
_scan_cache[path] = (content_key, result)
return result
def scan_plugin_resource_imports(
plugin_id: str,
plugin_dir: Path,
) -> tuple[str, ...]:
"""
递归扫描插件源码并返回导入前必须准备的 capability ID。
"""
if not plugin_dir.is_dir():
raise PluginResourceImportScanError(
f"插件 {plugin_id} 源码目录不存在:{plugin_dir}"
)
capabilities: Set[str] = set()
try:
source_files = sorted(plugin_dir.rglob("*.py"))
except OSError:
return tuple(sorted(_all_resource_capabilities()))
for path in source_files:
if "__pycache__" in path.parts:
continue
try:
capabilities.update(_scan_source(plugin_id, path))
except PluginResourceImportScanError:
# Python 最终只会导入真实依赖链;无法解析的残留或平台专用文件不应
# 阻断整个插件,但必须按最保守资源集合准备后再交给 loader 判断。
capabilities.update(_all_resource_capabilities())
return tuple(sorted(capabilities))