mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-15 11:04:12 +08:00
fix(compat): preserve legacy plugin imports
This commit is contained in:
@@ -4,11 +4,10 @@
|
||||
认证依赖(get_current_user 等八个)已迁至 app/api/deps.py——那是 HTTP 层的关注点,
|
||||
产出 403/400 而非数据。本模块只保留 UserOper。
|
||||
|
||||
这里不为那八个名字留惰性转发。转发曾是给仓外插件备的软着陆,代价是把
|
||||
app.db.oper.user -> app.api.deps -> app.application.security 这条边永久焊进依赖图:
|
||||
数据访问模块从此在静态分析里牵着整个鉴权栈,而仓内没有任何调用方需要它。插件生态
|
||||
既已确定迭代,就让旧名字直接以 AttributeError 报错——指向明确、当场可改,好过一条
|
||||
悄悄成立的反向依赖。
|
||||
这里不为那八个名字留惰性转发,否则会把
|
||||
app.db.oper.user -> app.api.deps -> app.application.security 这条边永久焊进依赖图,
|
||||
让数据访问模块在静态分析里牵着整个鉴权栈。仓外插件的旧 ``app.db.user_oper`` 路径由
|
||||
runtime 兼容映射指向 SDK 薄门面;canonical 数据访问模块仍只依赖模型,不承担兼容职责。
|
||||
"""
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@@ -3,9 +3,15 @@ import inspect
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional, Set, Tuple
|
||||
from typing import Callable, Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
from app.runtime.compat.manifest import MODULE_ALIASES, PACKAGE_ALIASES, ModuleAlias
|
||||
from app.runtime.compat.manifest import (
|
||||
MODULE_ALIASES,
|
||||
PACKAGE_ALIASES,
|
||||
SYMBOL_ALIASES,
|
||||
ModuleAlias,
|
||||
SymbolAlias,
|
||||
)
|
||||
|
||||
|
||||
WarningEmitter = Callable[[str], object]
|
||||
@@ -47,7 +53,23 @@ def _find_import_consumer() -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _format_warning(usage: LegacyImportUsage, alias: ModuleAlias) -> str:
|
||||
def _find_alias(legacy_path: str) -> Optional[Union[ModuleAlias, SymbolAlias]]:
|
||||
"""查找旧模块或旧符号对应的精确兼容规则。"""
|
||||
module_alias = MODULE_ALIASES.get(legacy_path) or PACKAGE_ALIASES.get(
|
||||
legacy_path
|
||||
)
|
||||
if module_alias:
|
||||
return module_alias
|
||||
module_name, separator, symbol_name = legacy_path.rpartition(".")
|
||||
if not separator:
|
||||
return None
|
||||
return SYMBOL_ALIASES.get(module_name, {}).get(symbol_name)
|
||||
|
||||
|
||||
def _format_warning(
|
||||
usage: LegacyImportUsage,
|
||||
alias: Union[ModuleAlias, SymbolAlias],
|
||||
) -> str:
|
||||
"""生成包含实际目标和推荐 SDK 路径的单行兼容警告。"""
|
||||
consumer = usage.consumer
|
||||
if consumer.startswith("app.plugins."):
|
||||
@@ -56,17 +78,20 @@ def _format_warning(usage: LegacyImportUsage, alias: ModuleAlias) -> str:
|
||||
else:
|
||||
source = f"模块 {consumer}"
|
||||
origin = f"({usage.origin})" if usage.origin else ""
|
||||
target = (
|
||||
alias.target
|
||||
if isinstance(alias, ModuleAlias)
|
||||
else f"{alias.target_module}.{alias.target_name}"
|
||||
)
|
||||
return (
|
||||
f"[兼容导入] {source}{origin} 使用旧路径 {usage.legacy_module},"
|
||||
f"已映射到 {alias.target};请迁移到 {alias.replacement}"
|
||||
f"已映射到 {target};请迁移到 {alias.replacement}"
|
||||
)
|
||||
|
||||
|
||||
def _emit_usage(usage: LegacyImportUsage) -> None:
|
||||
"""按调用方和旧路径去重后输出兼容警告。"""
|
||||
alias = MODULE_ALIASES.get(usage.legacy_module) or PACKAGE_ALIASES.get(
|
||||
usage.legacy_module
|
||||
)
|
||||
alias = _find_alias(usage.legacy_module)
|
||||
if not alias:
|
||||
return
|
||||
key = (usage.consumer, usage.legacy_module)
|
||||
@@ -129,7 +154,11 @@ def _extract_legacy_imports(tree: ast.AST) -> Tuple[Tuple[str, int], ...]:
|
||||
matches.add((node.module, node.lineno))
|
||||
for imported in node.names:
|
||||
candidate = f"{node.module}.{imported.name}"
|
||||
if candidate in MODULE_ALIASES or candidate in PACKAGE_ALIASES:
|
||||
if (
|
||||
candidate in MODULE_ALIASES
|
||||
or candidate in PACKAGE_ALIASES
|
||||
or imported.name in SYMBOL_ALIASES.get(node.module, {})
|
||||
):
|
||||
matches.add((candidate, node.lineno))
|
||||
elif isinstance(node, ast.Call) and node.args:
|
||||
function_name = ""
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.runtime.compat.manifest import (
|
||||
MODULE_ALIASES,
|
||||
PACKAGE_ALIASES,
|
||||
PACKAGE_EXPORTS,
|
||||
SYMBOL_ALIASES,
|
||||
VIRTUAL_PACKAGES,
|
||||
ModuleAlias,
|
||||
)
|
||||
@@ -115,6 +116,91 @@ class VirtualLegacyPackageLoader(importlib.abc.Loader):
|
||||
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
|
||||
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 | set(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 泄漏未登记的新内部模块。"""
|
||||
|
||||
@@ -163,6 +249,12 @@ class LegacyImportFinder(importlib.abc.MetaPathFinder):
|
||||
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)
|
||||
|
||||
@@ -30,6 +30,108 @@ MODULE_ALIASES: Dict[str, ModuleAlias] = {
|
||||
introduced="v3.0.0",
|
||||
owner="sdk",
|
||||
),
|
||||
"app.db.agentchat_oper": ModuleAlias(
|
||||
target="app.db.oper.agentchat",
|
||||
replacement="app.db.oper.agentchat",
|
||||
introduced="v3.0.0",
|
||||
owner="db",
|
||||
),
|
||||
"app.db.agenttask_oper": ModuleAlias(
|
||||
target="app.db.oper.agenttask",
|
||||
replacement="app.db.oper.agenttask",
|
||||
introduced="v3.0.0",
|
||||
owner="db",
|
||||
),
|
||||
"app.db.downloadfailure_oper": ModuleAlias(
|
||||
target="app.db.oper.downloadfailure",
|
||||
replacement="app.db.oper.downloadfailure",
|
||||
introduced="v3.0.0",
|
||||
owner="db",
|
||||
),
|
||||
"app.db.downloadhistory_oper": ModuleAlias(
|
||||
target="app.db.oper.downloadhistory",
|
||||
replacement="app.db.oper.downloadhistory",
|
||||
introduced="v3.0.0",
|
||||
owner="db",
|
||||
),
|
||||
"app.db.init": ModuleAlias(
|
||||
target="app.startup.database_initializer",
|
||||
replacement="app.startup.database_initializer",
|
||||
introduced="v3.0.0",
|
||||
owner="startup",
|
||||
),
|
||||
"app.db.mediaserver_oper": ModuleAlias(
|
||||
target="app.db.oper.mediaserver",
|
||||
replacement="app.db.oper.mediaserver",
|
||||
introduced="v3.0.0",
|
||||
owner="db",
|
||||
),
|
||||
"app.db.message_oper": ModuleAlias(
|
||||
target="app.db.oper.message",
|
||||
replacement="app.db.oper.message",
|
||||
introduced="v3.0.0",
|
||||
owner="db",
|
||||
),
|
||||
"app.db.plugindata_oper": ModuleAlias(
|
||||
target="app.db.oper.plugindata",
|
||||
replacement="app.db.oper.plugindata",
|
||||
introduced="v3.0.0",
|
||||
owner="db",
|
||||
),
|
||||
"app.db.site_oper": ModuleAlias(
|
||||
target="app.db.oper.site",
|
||||
replacement="app.db.oper.site",
|
||||
introduced="v3.0.0",
|
||||
owner="db",
|
||||
),
|
||||
"app.db.subscribe_oper": ModuleAlias(
|
||||
target="app.sdk._legacy.subscribe",
|
||||
replacement="app.application.subscribe.add_subscribe",
|
||||
introduced="v3.0.0",
|
||||
owner="sdk",
|
||||
),
|
||||
"app.db.subscribehistory_oper": ModuleAlias(
|
||||
target="app.db.oper.subscribehistory",
|
||||
replacement="app.db.oper.subscribehistory",
|
||||
introduced="v3.0.0",
|
||||
owner="db",
|
||||
),
|
||||
"app.db.systemconfig_oper": ModuleAlias(
|
||||
target="app.db.oper.systemconfig",
|
||||
replacement="app.db.oper.systemconfig",
|
||||
introduced="v3.0.0",
|
||||
owner="db",
|
||||
),
|
||||
"app.db.transferhistory_oper": ModuleAlias(
|
||||
target="app.sdk._legacy.history",
|
||||
replacement="app.application.history",
|
||||
introduced="v3.0.0",
|
||||
owner="sdk",
|
||||
),
|
||||
"app.db.transferpending_oper": ModuleAlias(
|
||||
target="app.db.oper.transferpending",
|
||||
replacement="app.db.oper.transferpending",
|
||||
introduced="v3.0.0",
|
||||
owner="db",
|
||||
),
|
||||
"app.db.user_oper": ModuleAlias(
|
||||
target="app.sdk._legacy.user",
|
||||
replacement="app.db.oper.user 或 app.api.deps",
|
||||
introduced="v3.0.0",
|
||||
owner="sdk",
|
||||
),
|
||||
"app.db.userconfig_oper": ModuleAlias(
|
||||
target="app.db.oper.userconfig",
|
||||
replacement="app.db.oper.userconfig",
|
||||
introduced="v3.0.0",
|
||||
owner="db",
|
||||
),
|
||||
"app.db.workflow_oper": ModuleAlias(
|
||||
target="app.db.oper.workflow",
|
||||
replacement="app.db.oper.workflow",
|
||||
introduced="v3.0.0",
|
||||
owner="db",
|
||||
),
|
||||
"app.utils.crypto": ModuleAlias(
|
||||
target="app.foundation.crypto",
|
||||
replacement="app.sdk.utilities",
|
||||
@@ -151,10 +253,10 @@ MODULE_ALIASES: Dict[str, ModuleAlias] = {
|
||||
owner="runtime",
|
||||
),
|
||||
"app.utils.media": ModuleAlias(
|
||||
target="app.domain.media",
|
||||
replacement="app.domain.media",
|
||||
target="app.sdk.media",
|
||||
replacement="app.sdk.media",
|
||||
introduced="v3.0.0",
|
||||
owner="domain",
|
||||
owner="sdk",
|
||||
),
|
||||
"app.utils.mixins": ModuleAlias(
|
||||
target="app.runtime.reload",
|
||||
@@ -562,3 +664,40 @@ PACKAGE_EXPORTS: Dict[str, Dict[str, SymbolAlias]] = {
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# 物理模块仍存在、仅部分公开符号迁走时,由导入器在标准 Loader 执行后叠加惰性符号路由。
|
||||
# canonical 源码不反向依赖兼容层,目标符号也只在旧调用方真正取用时加载。
|
||||
SYMBOL_ALIASES: Dict[str, Dict[str, SymbolAlias]] = {
|
||||
"app.domain.media": {
|
||||
name: SymbolAlias(
|
||||
target_module="app.schemas.media",
|
||||
target_name=name,
|
||||
replacement=f"app.schemas.media.{name}",
|
||||
)
|
||||
for name in (
|
||||
"MEDIA_SOURCE_ALIASES",
|
||||
"MEDIA_SOURCE_PREFIXES",
|
||||
"normalize_media_source",
|
||||
"parse_media_key",
|
||||
"resolve_media_identity",
|
||||
"normalize_media_identity_payload",
|
||||
"build_media_key",
|
||||
)
|
||||
},
|
||||
"app.schemas": {
|
||||
name: SymbolAlias(
|
||||
target_module="app.sdk._legacy.transfer",
|
||||
target_name=name,
|
||||
replacement=f"app.application.transfer.{name}",
|
||||
)
|
||||
for name in ("TransferTask", "TransferQueue")
|
||||
},
|
||||
"app.schemas.transfer": {
|
||||
name: SymbolAlias(
|
||||
target_module="app.sdk._legacy.transfer",
|
||||
target_name=name,
|
||||
replacement=f"app.application.transfer.{name}",
|
||||
)
|
||||
for name in ("TransferTask", "TransferQueue")
|
||||
},
|
||||
}
|
||||
|
||||
1
app/sdk/_legacy/__init__.py
Normal file
1
app/sdk/_legacy/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""只供旧导入映射使用的插件兼容门面;新插件不得直接依赖本包。"""
|
||||
70
app/sdk/_legacy/history.py
Normal file
70
app/sdk/_legacy/history.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""把旧整理历史 Oper 的业务写入方法转交给应用服务。"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from app.application.history import add_transfer_fail, add_transfer_success
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.oper.transferhistory import TransferHistoryOper as CanonicalTransferHistoryOper
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.transfer import TransferInfo
|
||||
|
||||
|
||||
class TransferHistoryOper(CanonicalTransferHistoryOper):
|
||||
"""继承新的查询接口,并保留旧的成功/失败历史写入方法。"""
|
||||
|
||||
def add_success(
|
||||
self,
|
||||
fileitem: FileItem,
|
||||
mode: str,
|
||||
meta: MetaBase,
|
||||
mediainfo: MediaInfo | MusicInfo,
|
||||
transferinfo: TransferInfo,
|
||||
downloader: Optional[str] = None,
|
||||
download_hash: Optional[str] = None,
|
||||
) -> Optional[TransferHistory]:
|
||||
"""
|
||||
按旧签名新增整理成功历史。
|
||||
|
||||
:return: 落库后的整理记录
|
||||
"""
|
||||
return add_transfer_success(
|
||||
fileitem=fileitem,
|
||||
mode=mode,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
transferinfo=transferinfo,
|
||||
downloader=downloader,
|
||||
download_hash=download_hash,
|
||||
transfer_history_oper=self,
|
||||
)
|
||||
|
||||
def add_fail(
|
||||
self,
|
||||
fileitem: FileItem,
|
||||
mode: str,
|
||||
meta: MetaBase,
|
||||
mediainfo: Optional[MediaInfo | MusicInfo] = None,
|
||||
transferinfo: Optional[TransferInfo] = None,
|
||||
downloader: Optional[str] = None,
|
||||
download_hash: Optional[str] = None,
|
||||
) -> Optional[TransferHistory]:
|
||||
"""
|
||||
按旧签名新增整理失败历史。
|
||||
|
||||
:return: 落库后的整理记录
|
||||
"""
|
||||
return add_transfer_fail(
|
||||
fileitem=fileitem,
|
||||
mode=mode,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
transferinfo=transferinfo,
|
||||
downloader=downloader,
|
||||
download_hash=download_hash,
|
||||
transfer_history_oper=self,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["TransferHistoryOper"]
|
||||
75
app/sdk/_legacy/subscribe.py
Normal file
75
app/sdk/_legacy/subscribe.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""把旧订阅 Oper 写入调用转交给新的应用服务。"""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.application.subscribe import add_subscribe, async_add_subscribe
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.oper.subscribe import SubscribeOper as CanonicalSubscribeOper
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
|
||||
|
||||
class SubscribeOper(CanonicalSubscribeOper):
|
||||
"""保留旧 ``mediainfo`` 写入签名,同时继承新的查询接口。"""
|
||||
|
||||
def add(
|
||||
self,
|
||||
mediainfo: Optional[MediaInfo | MusicInfo] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
兼容旧订阅写入;应用服务回调的新字典签名直接交给 canonical Oper。
|
||||
|
||||
:param mediainfo: 旧调用传入的媒体识别结果
|
||||
:param kwargs: 旧订阅配置,或新 Oper 的 identity/payload/username
|
||||
:return: 订阅 ID 与结果说明
|
||||
"""
|
||||
if mediainfo is None and "identity" in kwargs and "payload" in kwargs:
|
||||
identity = kwargs.pop("identity")
|
||||
payload = kwargs.pop("payload")
|
||||
username = kwargs.pop("username", None)
|
||||
if kwargs:
|
||||
unexpected = ", ".join(sorted(kwargs))
|
||||
raise TypeError(f"SubscribeOper.add 收到未知参数:{unexpected}")
|
||||
return super().add(
|
||||
identity=identity,
|
||||
payload=payload,
|
||||
username=username,
|
||||
)
|
||||
return add_subscribe(
|
||||
mediainfo=mediainfo,
|
||||
subscribe_oper=self,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def async_add(
|
||||
self,
|
||||
mediainfo: Optional[MediaInfo | MusicInfo] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
异步兼容旧订阅写入;新字典签名直接交给 canonical Oper。
|
||||
|
||||
:param mediainfo: 旧调用传入的媒体识别结果
|
||||
:param kwargs: 旧订阅配置,或新 Oper 的 identity/payload/username
|
||||
:return: 订阅 ID 与结果说明
|
||||
"""
|
||||
if mediainfo is None and "identity" in kwargs and "payload" in kwargs:
|
||||
identity = kwargs.pop("identity")
|
||||
payload = kwargs.pop("payload")
|
||||
username = kwargs.pop("username", None)
|
||||
if kwargs:
|
||||
unexpected = ", ".join(sorted(kwargs))
|
||||
raise TypeError(f"SubscribeOper.async_add 收到未知参数:{unexpected}")
|
||||
return await super().async_add(
|
||||
identity=identity,
|
||||
payload=payload,
|
||||
username=username,
|
||||
)
|
||||
return await async_add_subscribe(
|
||||
mediainfo=mediainfo,
|
||||
subscribe_oper=self,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["Subscribe", "SubscribeOper"]
|
||||
44
app/sdk/_legacy/transfer.py
Normal file
44
app/sdk/_legacy/transfer.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""保留旧 schemas 整理工作项的宽松构造契约。"""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.application.transfer import (
|
||||
TransferQueue as CanonicalTransferQueue,
|
||||
TransferTask as CanonicalTransferTask,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_legacy_value(value: Any) -> Any:
|
||||
"""同时支持旧 Pydantic 对象与新的领域对象序列化接口。"""
|
||||
if value is None:
|
||||
return None
|
||||
if callable(getattr(value, "to_dict", None)):
|
||||
return value.to_dict()
|
||||
if callable(getattr(value, "model_dump", None)):
|
||||
return value.model_dump()
|
||||
return value
|
||||
|
||||
|
||||
class TransferTask(CanonicalTransferTask):
|
||||
"""兼容旧任务允许插件传入自定义 meta/mediainfo 对象的行为。"""
|
||||
|
||||
meta: Optional[Any] = None
|
||||
mediainfo: Optional[Any] = None
|
||||
|
||||
def to_dict(self):
|
||||
"""返回兼容领域对象和旧 Pydantic 对象的任务字典。"""
|
||||
values = vars(self).copy()
|
||||
values["fileitem"] = _serialize_legacy_value(self.fileitem)
|
||||
values["meta"] = _serialize_legacy_value(self.meta)
|
||||
values["mediainfo"] = _serialize_legacy_value(self.mediainfo)
|
||||
values["target_directory"] = _serialize_legacy_value(self.target_directory)
|
||||
return values
|
||||
|
||||
|
||||
class TransferQueue(CanonicalTransferQueue):
|
||||
"""让旧宽松任务可以继续进入新的整理队列。"""
|
||||
|
||||
task: Optional[TransferTask] = None
|
||||
|
||||
|
||||
__all__ = ["TransferQueue", "TransferTask"]
|
||||
28
app/sdk/_legacy/user.py
Normal file
28
app/sdk/_legacy/user.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""兼容旧 ``app.db.user_oper`` 中混合的数据访问与认证依赖。"""
|
||||
|
||||
from app.api.deps import (
|
||||
get_current_active_manage_user,
|
||||
get_current_active_manage_user_async,
|
||||
get_current_active_superuser,
|
||||
get_current_active_superuser_async,
|
||||
get_current_active_user,
|
||||
get_current_active_user_async,
|
||||
get_current_user,
|
||||
get_current_user_async,
|
||||
)
|
||||
from app.db.oper.user import UserOper
|
||||
from app.db.models.user import User
|
||||
|
||||
|
||||
__all__ = [
|
||||
"UserOper",
|
||||
"User",
|
||||
"get_current_active_manage_user",
|
||||
"get_current_active_manage_user_async",
|
||||
"get_current_active_superuser",
|
||||
"get_current_active_superuser_async",
|
||||
"get_current_active_user",
|
||||
"get_current_active_user_async",
|
||||
"get_current_user",
|
||||
"get_current_user_async",
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""插件使用的媒体上下文、标题解析和识别类型。"""
|
||||
"""插件使用的媒体上下文、标题解析、识别类型和媒体身份规则。"""
|
||||
|
||||
from app.domain.context import Context, MediaInfo, TorrentInfo
|
||||
from app.domain.meta.metaanime import MetaAnime
|
||||
@@ -17,10 +17,33 @@ from app.domain.meta.words import WordsMatcher
|
||||
from app.domain.metainfo import MetaInfo, MetaInfoPath
|
||||
from app.domain.scraper import NfoReader
|
||||
from app.domain.tokens import Tokens
|
||||
from app.domain.media import (
|
||||
MUSIC_MEDIA_SOURCE_ORDER,
|
||||
MUSIC_MEDIA_SOURCES,
|
||||
configure_search_source_provider,
|
||||
is_media_source_enabled,
|
||||
is_media_source_selected,
|
||||
is_music_media_source,
|
||||
normalize_music_type,
|
||||
parse_media_source_selection,
|
||||
)
|
||||
from app.schemas.media import (
|
||||
MEDIA_SOURCE_ALIASES,
|
||||
MEDIA_SOURCE_PREFIXES,
|
||||
build_media_key,
|
||||
normalize_media_identity_payload,
|
||||
normalize_media_source,
|
||||
parse_media_key,
|
||||
resolve_media_identity,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Context",
|
||||
"MEDIA_SOURCE_ALIASES",
|
||||
"MEDIA_SOURCE_PREFIXES",
|
||||
"MUSIC_MEDIA_SOURCE_ORDER",
|
||||
"MUSIC_MEDIA_SOURCES",
|
||||
"MediaInfo",
|
||||
"MetaAnime",
|
||||
"MetaBase",
|
||||
@@ -38,4 +61,15 @@ __all__ = [
|
||||
"MusicNameRegistry",
|
||||
"TorrentInfo",
|
||||
"WordsMatcher",
|
||||
"build_media_key",
|
||||
"configure_search_source_provider",
|
||||
"is_media_source_enabled",
|
||||
"is_media_source_selected",
|
||||
"is_music_media_source",
|
||||
"normalize_media_identity_payload",
|
||||
"normalize_media_source",
|
||||
"normalize_music_type",
|
||||
"parse_media_key",
|
||||
"parse_media_source_selection",
|
||||
"resolve_media_identity",
|
||||
]
|
||||
|
||||
@@ -243,6 +243,12 @@ policy. `app/db` therefore has no dependency on `app/domain`.
|
||||
behavior stays in `app/api/endpoints/message.py`.
|
||||
- `app/runtime/compat` stores string mappings and resolves aliases lazily. It may
|
||||
not eagerly import canonical MoviePilot modules.
|
||||
- 已删除的 `app.db.<entity>_oper` 路径继续由精确模块映射提供给旧插件;其中订阅写入、
|
||||
整理历史写入和拆分后的用户认证依赖通过 `app.sdk._legacy` 薄门面委托 canonical
|
||||
Application/Oper,不把领域对象或 HTTP 依赖重新引回 DB 层。
|
||||
- 物理模块仍存在但公开符号已经迁走时(例如 `app.domain.media` 的身份原语、
|
||||
`app.schemas` 的整理工作项),兼容 Finder 在标准 Loader 执行后叠加白名单符号路由;
|
||||
canonical 模块不得为兼容而反向 import `app.runtime.compat`。
|
||||
- Canonical implementation packages may not import `app/runtime/compat` or
|
||||
`app/sdk`.
|
||||
- Host code uses canonical paths. Only `app/plugins/` and compatibility tests
|
||||
|
||||
166
tests/test_legacy_db_behavior_compat.py
Normal file
166
tests/test_legacy_db_behavior_compat.py
Normal file
@@ -0,0 +1,166 @@
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.transfer import TransferTask as CanonicalTransferTask
|
||||
from app.schemas.file import FileItem
|
||||
|
||||
|
||||
def test_legacy_subscribe_add_delegates_to_application_service(monkeypatch):
|
||||
"""旧 SubscribeOper.add 应保留 mediainfo 写入签名。"""
|
||||
legacy = importlib.import_module("app.db.subscribe_oper")
|
||||
oper = object.__new__(legacy.SubscribeOper)
|
||||
mediainfo = object()
|
||||
captured = {}
|
||||
|
||||
def fake_add_subscribe(*, mediainfo, subscribe_oper, **kwargs):
|
||||
"""记录同步兼容门面转交的参数。"""
|
||||
captured.update({
|
||||
"mediainfo": mediainfo,
|
||||
"subscribe_oper": subscribe_oper,
|
||||
"kwargs": kwargs,
|
||||
})
|
||||
return 7, "新增订阅成功"
|
||||
|
||||
monkeypatch.setattr(legacy, "add_subscribe", fake_add_subscribe)
|
||||
|
||||
assert oper.add(mediainfo=mediainfo, season=1) == (7, "新增订阅成功")
|
||||
assert captured == {
|
||||
"mediainfo": mediainfo,
|
||||
"subscribe_oper": oper,
|
||||
"kwargs": {"season": 1},
|
||||
}
|
||||
|
||||
|
||||
def test_legacy_subscribe_facade_accepts_application_dictionary_callback(
|
||||
monkeypatch,
|
||||
):
|
||||
"""应用服务回调兼容 Oper 时应进入新字典签名,不能再次转回应用服务。"""
|
||||
legacy = importlib.import_module("app.db.subscribe_oper")
|
||||
canonical = importlib.import_module("app.db.oper.subscribe")
|
||||
oper = object.__new__(legacy.SubscribeOper)
|
||||
captured = {}
|
||||
|
||||
def fake_canonical_add(self, identity, payload, username=None):
|
||||
"""记录兼容类转交给 canonical Oper 的持久化参数。"""
|
||||
captured.update({
|
||||
"self": self,
|
||||
"identity": identity,
|
||||
"payload": payload,
|
||||
"username": username,
|
||||
})
|
||||
return 9, "新增订阅成功"
|
||||
|
||||
monkeypatch.setattr(canonical.SubscribeOper, "add", fake_canonical_add)
|
||||
|
||||
result = oper.add(
|
||||
identity={"media_source": "themoviedb", "media_id": "1"},
|
||||
payload={"name": "Test"},
|
||||
username="admin",
|
||||
)
|
||||
|
||||
assert result == (9, "新增订阅成功")
|
||||
assert captured == {
|
||||
"self": oper,
|
||||
"identity": {"media_source": "themoviedb", "media_id": "1"},
|
||||
"payload": {"name": "Test"},
|
||||
"username": "admin",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_subscribe_async_add_delegates_to_application_service(
|
||||
monkeypatch,
|
||||
):
|
||||
"""旧 SubscribeOper.async_add 应保留异步 mediainfo 写入签名。"""
|
||||
legacy = importlib.import_module("app.db.subscribe_oper")
|
||||
oper = object.__new__(legacy.SubscribeOper)
|
||||
mediainfo = object()
|
||||
captured = {}
|
||||
|
||||
async def fake_async_add_subscribe(*, mediainfo, subscribe_oper, **kwargs):
|
||||
"""记录异步兼容门面转交的参数。"""
|
||||
captured.update({
|
||||
"mediainfo": mediainfo,
|
||||
"subscribe_oper": subscribe_oper,
|
||||
"kwargs": kwargs,
|
||||
})
|
||||
return 8, "新增订阅成功"
|
||||
|
||||
monkeypatch.setattr(legacy, "async_add_subscribe", fake_async_add_subscribe)
|
||||
|
||||
result = await oper.async_add(mediainfo=mediainfo, season=2)
|
||||
|
||||
assert result == (8, "新增订阅成功")
|
||||
assert captured == {
|
||||
"mediainfo": mediainfo,
|
||||
"subscribe_oper": oper,
|
||||
"kwargs": {"season": 2},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method_name", "service_name"),
|
||||
[
|
||||
("add_success", "add_transfer_success"),
|
||||
("add_fail", "add_transfer_fail"),
|
||||
],
|
||||
)
|
||||
def test_legacy_transfer_history_writes_delegate_to_application_service(
|
||||
monkeypatch,
|
||||
method_name,
|
||||
service_name,
|
||||
):
|
||||
"""旧整理历史写入方法应只做代理,不把业务逻辑搬回 Oper。"""
|
||||
legacy = importlib.import_module("app.db.transferhistory_oper")
|
||||
oper = object.__new__(legacy.TransferHistoryOper)
|
||||
arguments = {
|
||||
"fileitem": object(),
|
||||
"mode": "copy",
|
||||
"meta": object(),
|
||||
"mediainfo": object(),
|
||||
"transferinfo": object(),
|
||||
"downloader": "qb",
|
||||
"download_hash": "hash",
|
||||
}
|
||||
captured = {}
|
||||
|
||||
def fake_service(**kwargs):
|
||||
"""记录整理历史兼容门面转交的参数。"""
|
||||
captured.update(kwargs)
|
||||
return "history"
|
||||
|
||||
monkeypatch.setattr(legacy, service_name, fake_service)
|
||||
|
||||
assert getattr(oper, method_name)(**arguments) == "history"
|
||||
assert captured == {**arguments, "transfer_history_oper": oper}
|
||||
|
||||
|
||||
class LegacyPydanticValue:
|
||||
"""模拟旧插件放进 TransferTask 的 Pydantic 风格对象。"""
|
||||
|
||||
def model_dump(self):
|
||||
"""返回测试用序列化结果。"""
|
||||
return {"kind": "pydantic"}
|
||||
|
||||
|
||||
class LegacyDomainValue:
|
||||
"""模拟新领域对象的 to_dict 序列化接口。"""
|
||||
|
||||
def to_dict(self):
|
||||
"""返回测试用序列化结果。"""
|
||||
return {"kind": "domain"}
|
||||
|
||||
|
||||
def test_legacy_transfer_task_keeps_wide_plugin_input_contract():
|
||||
"""旧 schemas.TransferTask 应接受自定义对象且仍可进入新整理链。"""
|
||||
schemas_package = importlib.import_module("app.schemas")
|
||||
task = schemas_package.TransferTask(
|
||||
fileitem=FileItem(path="/downloads/test.mkv", storage="local"),
|
||||
meta=LegacyPydanticValue(),
|
||||
mediainfo=LegacyDomainValue(),
|
||||
)
|
||||
|
||||
assert isinstance(task, CanonicalTransferTask)
|
||||
assert task.to_dict()["meta"] == {"kind": "pydantic"}
|
||||
assert task.to_dict()["mediainfo"] == {"kind": "domain"}
|
||||
@@ -17,6 +17,7 @@ from app.runtime.compat.manifest import (
|
||||
MODULE_ALIASES,
|
||||
PACKAGE_ALIASES,
|
||||
PACKAGE_EXPORTS,
|
||||
SYMBOL_ALIASES,
|
||||
VIRTUAL_PACKAGES,
|
||||
ModuleAlias,
|
||||
)
|
||||
@@ -203,3 +204,133 @@ def test_virtual_package_exports_resolve_exact_manifest_symbols():
|
||||
).MetaBase
|
||||
assert PACKAGE_ALIASES[legacy_package].replacement in messages[0]
|
||||
reset_legacy_import_diagnostics()
|
||||
|
||||
|
||||
def test_db_refactor_legacy_modules_are_all_registered():
|
||||
"""DB 分层迁移删除的旧模块必须全部有精确兼容入口。"""
|
||||
expected = {
|
||||
"app.db.agentchat_oper",
|
||||
"app.db.agenttask_oper",
|
||||
"app.db.downloadfailure_oper",
|
||||
"app.db.downloadhistory_oper",
|
||||
"app.db.init",
|
||||
"app.db.mediaserver_oper",
|
||||
"app.db.message_oper",
|
||||
"app.db.plugindata_oper",
|
||||
"app.db.site_oper",
|
||||
"app.db.subscribe_oper",
|
||||
"app.db.subscribehistory_oper",
|
||||
"app.db.systemconfig_oper",
|
||||
"app.db.transferhistory_oper",
|
||||
"app.db.transferpending_oper",
|
||||
"app.db.user_oper",
|
||||
"app.db.userconfig_oper",
|
||||
"app.db.workflow_oper",
|
||||
}
|
||||
|
||||
assert expected <= set(MODULE_ALIASES)
|
||||
|
||||
|
||||
def test_split_user_oper_facade_exports_data_and_auth_contracts():
|
||||
"""旧 user_oper 同时提供 UserOper 与八个认证依赖。"""
|
||||
legacy = importlib.import_module("app.db.user_oper")
|
||||
canonical_user = importlib.import_module("app.db.oper.user")
|
||||
canonical_deps = importlib.import_module("app.api.deps")
|
||||
|
||||
assert legacy.UserOper is canonical_user.UserOper
|
||||
for name in (
|
||||
"get_current_user",
|
||||
"get_current_user_async",
|
||||
"get_current_active_user",
|
||||
"get_current_active_user_async",
|
||||
"get_current_active_manage_user",
|
||||
"get_current_active_manage_user_async",
|
||||
"get_current_active_superuser",
|
||||
"get_current_active_superuser_async",
|
||||
):
|
||||
assert getattr(legacy, name) is getattr(canonical_deps, name)
|
||||
|
||||
|
||||
def test_legacy_utils_media_facade_combines_strategy_and_identity_symbols():
|
||||
"""旧 utils.media 同时保留领域策略和迁至 schemas 的身份原语。"""
|
||||
legacy = importlib.import_module("app.utils.media")
|
||||
domain_media = importlib.import_module("app.domain.media")
|
||||
schema_media = importlib.import_module("app.schemas.media")
|
||||
|
||||
assert legacy.is_music_media_source is domain_media.is_music_media_source
|
||||
assert legacy.resolve_media_identity is schema_media.resolve_media_identity
|
||||
assert legacy.build_media_key is schema_media.build_media_key
|
||||
assert legacy.MEDIA_SOURCE_ALIASES is schema_media.MEDIA_SOURCE_ALIASES
|
||||
|
||||
|
||||
def test_physical_modules_resolve_moved_symbols_without_reverse_imports():
|
||||
"""仍存在的旧物理模块应通过 Loader 叠加迁走的符号。"""
|
||||
domain_media = importlib.import_module("app.domain.media")
|
||||
schema_media = importlib.import_module("app.schemas.media")
|
||||
transfer_schema = importlib.import_module("app.schemas.transfer")
|
||||
legacy_transfer = importlib.import_module("app.sdk._legacy.transfer")
|
||||
schemas_package = importlib.import_module("app.schemas")
|
||||
|
||||
assert domain_media.build_media_key is schema_media.build_media_key
|
||||
assert domain_media.resolve_media_identity is schema_media.resolve_media_identity
|
||||
assert transfer_schema.TransferTask is legacy_transfer.TransferTask
|
||||
assert transfer_schema.TransferQueue is legacy_transfer.TransferQueue
|
||||
assert schemas_package.TransferTask is legacy_transfer.TransferTask
|
||||
assert schemas_package.TransferQueue is legacy_transfer.TransferQueue
|
||||
|
||||
|
||||
def test_debug_diagnostics_reports_moved_symbol_path():
|
||||
"""DEBUG 模式应对物理模块中的旧符号路径给出一次迁移提示。"""
|
||||
messages = []
|
||||
reset_legacy_import_diagnostics()
|
||||
configure_legacy_import_diagnostics(enabled=True, emitter=messages.append)
|
||||
|
||||
domain_media = importlib.import_module("app.domain.media")
|
||||
domain_media.build_media_key
|
||||
domain_media.build_media_key
|
||||
|
||||
assert len(messages) == 1
|
||||
assert "app.domain.media.build_media_key" in messages[0]
|
||||
assert "app.schemas.media.build_media_key" in messages[0]
|
||||
reset_legacy_import_diagnostics()
|
||||
|
||||
|
||||
def test_plugin_scan_reports_moved_symbol_import(tmp_path: Path):
|
||||
"""插件静态扫描应识别仍存在模块中的旧符号导入。"""
|
||||
messages = []
|
||||
reset_legacy_import_diagnostics()
|
||||
configure_legacy_import_diagnostics(enabled=True, emitter=messages.append)
|
||||
plugin_dir = tmp_path / "symbolplugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"from app.domain.media import build_media_key\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
scan_plugin_legacy_imports("SymbolPlugin", plugin_dir)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert "app.domain.media.build_media_key" in messages[0]
|
||||
assert "__init__.py:1" in messages[0]
|
||||
reset_legacy_import_diagnostics()
|
||||
|
||||
|
||||
def test_symbol_alias_manifest_covers_all_moved_public_symbols():
|
||||
"""符号级映射清单应覆盖媒体身份与整理工作项的旧入口。"""
|
||||
assert set(SYMBOL_ALIASES["app.domain.media"]) == {
|
||||
"MEDIA_SOURCE_ALIASES",
|
||||
"MEDIA_SOURCE_PREFIXES",
|
||||
"normalize_media_source",
|
||||
"parse_media_key",
|
||||
"resolve_media_identity",
|
||||
"normalize_media_identity_payload",
|
||||
"build_media_key",
|
||||
}
|
||||
assert set(SYMBOL_ALIASES["app.schemas"]) == {
|
||||
"TransferTask",
|
||||
"TransferQueue",
|
||||
}
|
||||
assert set(SYMBOL_ALIASES["app.schemas.transfer"]) == {
|
||||
"TransferTask",
|
||||
"TransferQueue",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user