Files
MoviePilot/app/runtime/compat/imports.py
DDSRem e0642eafa7 chore(types): reduce v3 host mypy debt (#7057)
* chore(types): clear AniList endpoint mypy debt

* chore(types): clear Douban endpoint mypy debt

* chore(types): clear recommendation endpoint mypy debt

* chore(types): clear subscription notification mypy debt

* chore(types): clear SDK string compatibility mypy debt

* chore(types): ratchet v3 host mypy across remaining files

* chore(types): reduce v3 host mypy debt and retire ledger

* chore(types): reconcile host snapshot with upstream HTTP pool

* test(types): cover host paths exposed by mypy cleanup
2026-10-18 09:37:49 +08:00

502 lines
18 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.
"""
按精确清单注册历史导入路由,延迟加载规范实现并保留插件兼容入口。
"""
import ast
import importlib
import importlib.abc
import importlib.machinery
import importlib.util
import sys
import threading
from pathlib import Path
from types import ModuleType
from typing import Dict, List, Optional, Sequence, Set, Tuple
from app.runtime.compat.diagnostics import record_legacy_import
from app.runtime.compat.manifest import (
MODULE_ALIASES,
PACKAGE_ALIASES,
PACKAGE_EXPORTS,
SYMBOL_ALIASES,
VIRTUAL_PACKAGES,
ModuleAlias,
SymbolAlias,
)
_resolution_state = threading.local()
class LegacyAliasLoader(importlib.abc.Loader):
"""
将旧模块键绑定到已按 canonical 名称加载的同一模块对象。
"""
_METADATA_NAMES = (
"__name__",
"__loader__",
"__package__",
"__spec__",
"__path__",
"__file__",
"__cached__",
)
def __init__(self, legacy_name: str, alias: ModuleAlias):
"""
保存当前旧路径规则,不在构造阶段导入目标模块。
"""
self.legacy_name = legacy_name
self.alias = alias
self._metadata: Dict[str, object] = {}
def create_module(self, spec: importlib.machinery.ModuleSpec) -> ModuleType:
"""
惰性导入 canonical 模块并复用其对象,禁止以旧名称二次执行源码。
:param spec: 旧路径的导入规格;目标由兼容清单确定
:return: canonical 路径下的同一模块对象
:raises ImportError: 目标导入失败或检测到兼容导入循环
"""
stack = list(getattr(_resolution_state, "stack", ()))
if self.legacy_name in stack:
chain = " -> ".join([*stack, self.legacy_name])
raise ImportError(f"检测到兼容导入循环:{chain}")
stack.append(self.legacy_name)
_resolution_state.stack = stack
try:
module = importlib.import_module(self.alias.target)
except ImportError as err:
if hasattr(err, "add_note"):
err.add_note(
f"兼容导入 {self.legacy_name} 指向 {self.alias.target} 时失败"
)
raise
finally:
stack.pop()
_resolution_state.stack = stack
self._metadata = {
name: getattr(module, name)
for name in self._METADATA_NAMES
if hasattr(module, name)
}
return module
def exec_module(self, module: ModuleType) -> None:
"""
恢复 canonical 元数据并记录旧路径命中,不重复执行目标模块。
"""
target_module = sys.modules.get(self.alias.target)
if module is not target_module:
raise ImportError(
f"兼容导入模块身份不一致:{self.legacy_name} -> {self.alias.target}"
)
for name, value in self._metadata.items():
setattr(module, name, value)
record_legacy_import(self.legacy_name)
class VirtualLegacyPackageLoader(importlib.abc.Loader):
"""
创建无文件系统搜索路径的旧父包,只允许 manifest 白名单成员。
"""
def __init__(self, package_name: str):
"""
保存待创建的旧父包名称。
"""
self.package_name = package_name
def create_module(self, spec: importlib.machinery.ModuleSpec) -> None:
"""
使用默认模块创建流程。
:param spec: 虚拟包的导入规格
:return: None,交由导入系统创建模块对象
"""
return None
def exec_module(self, module: ModuleType) -> None:
"""
为合成包安装精确符号解析器和稳定的空搜索路径。
"""
exports = PACKAGE_EXPORTS.get(self.package_name, {})
def resolve_export(name: str) -> object:
"""
按 manifest 惰性解析旧包级公开符号。
:param name: 旧包中请求的符号名
:return: 清单指定模块中的原始符号
:raises AttributeError: 符号未在清单中登记
"""
symbol = exports.get(name)
if not symbol:
raise AttributeError(
f"module {self.package_name!r} has no attribute {name!r}"
)
value = getattr(importlib.import_module(symbol.target_module), symbol.target_name)
setattr(module, name, value)
return value
def list_exports() -> list[str]:
"""
返回合成包已声明的公开符号。
:return: 已加载名称与清单符号的有序并集
"""
return sorted(set(module.__dict__) | set(exports))
module.__dict__["__path__"] = []
module.__dict__["__all__"] = sorted(exports)
module.__dict__["__getattr__"] = resolve_export
module.__dict__["__dir__"] = list_exports
if self.package_name in PACKAGE_ALIASES:
record_legacy_import(self.package_name)
def detect_shadowed_exports(
module: ModuleType,
exports: Dict[str, SymbolAlias],
) -> List[Tuple[str, str]]:
"""
返回物理模块用第二份实现遮蔽的兼容符号及其 canonical 路径。
模块级 ``__getattr__`` 只在属性查找失败时触发,物理模块自带同名定义时兼容叠加
完全不会被调用。重新导入同一个 canonical 对象是无害的,只有身份不同才是遮蔽。
:param module: 已执行完毕的物理模块
:param exports: 该模块在兼容清单中登记的符号
:return: ``(符号名, canonical 路径)`` 列表,无遮蔽时为空
"""
shadowed = []
for name, symbol in exports.items():
if name not in module.__dict__:
continue
target = importlib.import_module(symbol.target_module)
if module.__dict__[name] is not getattr(target, symbol.target_name):
shadowed.append((name, symbol.replacement))
return shadowed
PLUGINS_PACKAGE = "app.plugins"
PLUGINS_PACKAGE_ROOT_BACKUP_SUFFIX = ".legacy-bak"
# 必须与 app/plugins/__init__.py 完全一致,tests/test_legacy_import_compat.py 校验同步
PLUGINS_PACKAGE_ROOT_SOURCE = (
'"""\n'
'插件安装命名空间;契约基类由 app.sdk.plugin 拥有,旧包根符号由 Compat 惰性解析。\n'
'"""\n'
)
def _top_level_bindings(source: str) -> Set[str]:
"""
返回模块顶层由类、函数或赋值绑定的名称,导入绑定不计入。
"""
names: Set[str] = set()
for node in ast.parse(source).body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
names.add(node.name)
elif isinstance(node, ast.Assign):
names.update(
target.id for target in node.targets if isinstance(target, ast.Name)
)
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
names.add(node.target.id)
return names
def detect_plugins_package_root_shadowing(init_file: Path) -> List[str]:
"""
返回插件安装包根源码里自带第二份实现的兼容符号名。
只统计顶层类、函数和赋值:重新导入同一个 canonical 对象无害,判定口径与
:func:`detect_shadowed_exports` 一致。源码缺失、不可读或语法错误时返回空列表,
把这些故障留给标准导入流程如实报告。
:param init_file: 运行目录中的 ``app/plugins/__init__.py``
:return: 被遮蔽的兼容符号名,按名称排序;正常包根为空列表
"""
try:
bindings = _top_level_bindings(init_file.read_text(encoding="utf-8-sig"))
except (OSError, SyntaxError, ValueError):
return []
return sorted(bindings & set(SYMBOL_ALIASES.get(PLUGINS_PACKAGE, {})))
def restore_plugins_package_root(init_file: Path) -> Path:
"""
备份自带实现的插件安装包根,并写回只含说明的命名空间入口。
包根属于后端源码提供的兼容入口,旧版本的同名实现随插件运行目录迁移进来后会遮蔽
兼容符号;改写后本进程尚未导入的插件即可正常解析旧符号。运行目录只读时抛出
``OSError``,由调用方决定如何降级。
:param init_file: 运行目录中的 ``app/plugins/__init__.py``
:return: 备份文件路径
"""
backup_file = init_file.with_name(
init_file.name + PLUGINS_PACKAGE_ROOT_BACKUP_SUFFIX
)
backup_file.write_bytes(init_file.read_bytes())
init_file.write_text(PLUGINS_PACKAGE_ROOT_SOURCE, encoding="utf-8")
importlib.invalidate_caches()
return backup_file
class LegacySymbolOverlayLoader(importlib.abc.Loader):
"""
在标准物理模块执行后叠加旧符号的惰性解析,不修改 canonical 源码。
"""
_STATE_KEY = "__legacy_symbol_overlay_state__"
def __init__(self, module_name: str, original_loader: importlib.abc.Loader):
"""
保存物理模块名称和 PathFinder 已选择的原始 Loader。
"""
self.module_name = module_name
self.original_loader = original_loader
def __getattr__(self, name: str) -> object:
"""
把资源读取等非核心 Loader 能力转交给原始 Loader。
:param name: 待访问的 Loader 属性
:return: 原始 Loader 对应的属性
"""
return getattr(self.original_loader, name)
def create_module(self, spec: importlib.machinery.ModuleSpec) -> Optional[ModuleType]:
"""
沿用原始 Loader 的模块创建逻辑。
:param spec: 物理模块的导入规格
:return: 原始 Loader 创建的模块;未自定义创建流程时返回 None
"""
creator = getattr(self.original_loader, "create_module", None)
return creator(spec) if creator else None
@classmethod
def _restore_previous_overlay(cls, module: ModuleType) -> None:
"""
reload 前恢复物理模块原有的动态属性和 __all__ 状态。
"""
state = module.__dict__.pop(cls._STATE_KEY, None)
if not state:
return
for name in ("__getattr__", "__dir__"):
previous = state.get(name)
if previous is None:
module.__dict__.pop(name, None)
else:
module.__dict__[name] = previous
if state.get("had_all"):
module.__dict__["__all__"] = state.get("all")
else:
module.__dict__.pop("__all__", None)
def exec_module(self, module: ModuleType) -> None:
"""
执行真实模块后安装只对已登记旧符号生效的 __getattr__。
"""
self._restore_previous_overlay(module)
executor = getattr(self.original_loader, "exec_module", None)
if not executor:
raise ImportError(f"模块 {self.module_name} 的原始 Loader 不支持 exec_module")
executor(module)
exports = SYMBOL_ALIASES[self.module_name]
shadowed = detect_shadowed_exports(module, exports)
if shadowed:
detail = ";".join(
f"{self.module_name}.{name} 覆盖了 {replacement}"
for name, replacement in shadowed
)
raise ImportError(
f"模块 {self.module_name} 自带第二份实现遮蔽了兼容符号:{detail}。"
f"通常是把旧版本的同名目录挂载或复制到了运行目录,"
f"请让该包只保留不含实现的 __init__.py",
name=self.module_name,
)
previous_getattr = module.__dict__.get("__getattr__")
previous_dir = module.__dict__.get("__dir__")
had_all = "__all__" in module.__dict__
previous_all = module.__dict__.get("__all__")
def resolve_export(name: str) -> object:
"""
惰性解析物理模块中已经迁走的旧符号。
:param name: 物理模块上请求的符号名
:return: 清单指定的原始符号,或物理模块原有动态属性
:raises AttributeError: 两处均未提供该名称
"""
symbol = exports.get(name)
if symbol:
record_legacy_import(f"{self.module_name}.{name}")
target = importlib.import_module(symbol.target_module)
return getattr(target, symbol.target_name)
if previous_getattr:
return previous_getattr(name)
raise AttributeError(
f"module {self.module_name!r} has no attribute {name!r}"
)
def list_exports() -> list[str]:
"""
返回物理模块原有名称与兼容符号的并集。
:return: 当前可见名称的有序列表
"""
names = set(module.__dict__) | set(exports)
if previous_dir:
names.update(previous_dir())
return sorted(names)
module.__dict__["__getattr__"] = resolve_export
module.__dict__["__dir__"] = list_exports
# 兼容符号不并入 __all__:避免 `from <module> import *` 在包初始化期
# 急切解析旧符号、反向拉起应用层模块形成循环导入;显式导入与属性
# 访问仍由上方 __getattr__ 惰性解析兜底
public_names = {
name for name in module.__dict__ if not name.startswith("_")
}
declared_exports = set(previous_all or ()) if had_all else public_names
module.__dict__["__all__"] = sorted(declared_exports)
module.__dict__[self._STATE_KEY] = {
"__getattr__": previous_getattr,
"__dir__": previous_dir,
"had_all": had_all,
"all": previous_all,
}
class BlockedLegacyModuleLoader(importlib.abc.Loader):
"""
阻止合成旧包从其他 Finder 泄漏未登记的新内部模块。
"""
def __init__(self, module_name: str):
"""
保存应拒绝的旧模块路径。
"""
self.module_name = module_name
def create_module(self, spec: importlib.machinery.ModuleSpec) -> None:
"""
使用默认模块创建流程,错误在执行阶段给出。
:param spec: 被阻止的旧模块导入规格
:return: None,交由导入系统创建模块对象
"""
return None
def exec_module(self, module: ModuleType) -> None:
"""
对未登记旧路径抛出标准 ModuleNotFoundError。
"""
raise ModuleNotFoundError(
f"旧模块路径 {self.module_name} 未在兼容映射中登记",
name=self.module_name,
)
class LegacyImportFinder(importlib.abc.MetaPathFinder):
"""
仅解析 manifest 声明的旧模块和合成父包。
"""
@staticmethod
def _virtual_package_names() -> set[str]:
"""
计算显式虚拟根及其已登记模块所需的中间父包。
:return: 兼容清单允许的虚拟包完整名称
"""
package_names = set(VIRTUAL_PACKAGES)
for legacy_name in [*MODULE_ALIASES, *PACKAGE_ALIASES, *PACKAGE_EXPORTS]:
parts = legacy_name.split(".")
for index in range(1, len(parts)):
candidate = ".".join(parts[:index])
if any(
candidate == root or candidate.startswith(f"{root}.")
for root in VIRTUAL_PACKAGES
):
package_names.add(candidate)
return package_names
def find_spec(
self,
fullname: str,
path: Optional[Sequence[str]] = None,
target: Optional[ModuleType] = None,
) -> Optional[importlib.machinery.ModuleSpec]:
"""
为精确旧模块返回 alias spec,并封锁虚拟包中的未知后代。
:param fullname: 导入系统请求的完整模块名
:param path: 父包搜索路径;顶层导入时为 None
:param target: reload 时的既有模块;普通导入时为 None
:return: 已登记路径的导入规格;非兼容路径返回 None
"""
alias = MODULE_ALIASES.get(fullname)
if alias:
loader = LegacyAliasLoader(fullname, alias)
return importlib.util.spec_from_loader(
fullname,
loader,
is_package=alias.is_package,
)
if fullname in SYMBOL_ALIASES:
spec = importlib.machinery.PathFinder.find_spec(fullname, path, target)
if spec and spec.loader:
spec.loader = LegacySymbolOverlayLoader(fullname, spec.loader)
return spec
virtual_packages = self._virtual_package_names()
if fullname in virtual_packages:
virtual_loader = VirtualLegacyPackageLoader(fullname)
spec = importlib.util.spec_from_loader(fullname, virtual_loader, is_package=True)
if spec:
spec.submodule_search_locations = []
return spec
if any(fullname.startswith(f"{root}.") for root in VIRTUAL_PACKAGES):
return importlib.util.spec_from_loader(
fullname,
BlockedLegacyModuleLoader(fullname),
is_package=False,
)
return None
def install_legacy_import_hook() -> LegacyImportFinder:
"""
幂等安装旧导入 Finder,并确保它位于标准 PathFinder 之前。
"""
for finder in sys.meta_path:
if isinstance(finder, LegacyImportFinder):
return finder
finder = LegacyImportFinder()
index = next(
(
position
for position, candidate in enumerate(sys.meta_path)
if candidate is importlib.machinery.PathFinder
),
len(sys.meta_path),
)
sys.meta_path.insert(index, finder)
return finder
def uninstall_legacy_import_hook() -> None:
"""
移除全部旧导入 Finder,仅供隔离测试恢复进程状态。
"""
sys.meta_path[:] = [
finder for finder in sys.meta_path if not isinstance(finder, LegacyImportFinder)
]