mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-30 12:36:55 +08:00
301 lines
11 KiB
Python
301 lines
11 KiB
Python
import importlib
|
|
import importlib.abc
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import sys
|
|
import threading
|
|
from types import ModuleType
|
|
from typing import Dict
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
_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):
|
|
"""惰性导入 canonical 模块并复用其对象,禁止以旧名称二次执行源码。"""
|
|
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):
|
|
"""使用默认模块创建流程。"""
|
|
return None
|
|
|
|
def exec_module(self, module: ModuleType) -> None:
|
|
"""为合成包安装精确符号解析器和稳定的空搜索路径。"""
|
|
exports = PACKAGE_EXPORTS.get(self.package_name, {})
|
|
|
|
def resolve_export(name: str):
|
|
"""按 manifest 惰性解析旧包级公开符号。"""
|
|
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():
|
|
"""返回合成包已声明的公开符号。"""
|
|
return sorted(set(module.__dict__) | set(exports))
|
|
|
|
module.__path__ = []
|
|
module.__all__ = sorted(exports)
|
|
module.__getattr__ = resolve_export
|
|
module.__dir__ = list_exports
|
|
if self.package_name in PACKAGE_ALIASES:
|
|
record_legacy_import(self.package_name)
|
|
|
|
|
|
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):
|
|
"""把资源读取等非核心 Loader 能力转交给原始 Loader。"""
|
|
return getattr(self.original_loader, name)
|
|
|
|
def create_module(self, spec):
|
|
"""沿用原始 Loader 的模块创建逻辑。"""
|
|
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]
|
|
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):
|
|
"""惰性解析物理模块中已经迁走的旧符号。"""
|
|
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():
|
|
"""返回物理模块原有名称与兼容符号的并集。"""
|
|
names = set(module.__dict__) | set(exports)
|
|
if previous_dir:
|
|
names.update(previous_dir())
|
|
return sorted(names)
|
|
|
|
module.__getattr__ = resolve_export
|
|
module.__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.__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):
|
|
"""使用默认模块创建流程,错误在执行阶段给出。"""
|
|
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():
|
|
"""计算显式虚拟根及其已登记模块所需的中间父包。"""
|
|
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=None, target=None):
|
|
"""为精确旧模块返回 alias spec,并封锁虚拟包中的未知后代。"""
|
|
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:
|
|
loader = VirtualLegacyPackageLoader(fullname)
|
|
spec = importlib.util.spec_from_loader(fullname, 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)
|
|
]
|