Files
MoviePilot/tests/test_legacy_import_compat.py
T

518 lines
20 KiB
Python

import builtins
import importlib
import subprocess
import sys
from pathlib import Path
import pytest
from app.runtime.compat.diagnostics import (
configure_legacy_import_diagnostics,
get_legacy_import_diagnostics,
reset_legacy_import_diagnostics,
scan_plugin_legacy_imports,
)
from app.runtime.compat.imports import install_legacy_import_hook
from app.runtime.compat.manifest import (
_MESSAGE_NOTIFICATION_SYMBOL_ALIASES,
MODULE_ALIASES,
PACKAGE_ALIASES,
PACKAGE_EXPORTS,
SYMBOL_ALIASES,
VIRTUAL_PACKAGES,
ModuleAlias,
)
LEGACY_PACKAGE = "legacy_compat_test"
LEGACY_MODULE = f"{LEGACY_PACKAGE}.target"
CANONICAL_PACKAGE = "canonical_compat_test"
CANONICAL_MODULE = f"{CANONICAL_PACKAGE}.target"
@pytest.fixture
def compatibility_modules(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""创建可计数初始化次数的临时 canonical 模块和旧路径映射。"""
package_dir = tmp_path / CANONICAL_PACKAGE
package_dir.mkdir()
(package_dir / "__init__.py").write_text("", encoding="utf-8")
(package_dir / "target.py").write_text(
"import builtins\n"
"builtins._legacy_compat_test_count = "
"getattr(builtins, '_legacy_compat_test_count', 0) + 1\n"
"TOKEN = object()\n",
encoding="utf-8",
)
monkeypatch.syspath_prepend(str(tmp_path))
monkeypatch.setitem(
MODULE_ALIASES,
LEGACY_MODULE,
ModuleAlias(
target=CANONICAL_MODULE,
replacement="app.sdk.test",
introduced="test",
owner="test",
),
)
VIRTUAL_PACKAGES.add(LEGACY_PACKAGE)
install_legacy_import_hook()
reset_legacy_import_diagnostics()
yield
MODULE_ALIASES.pop(LEGACY_MODULE, None)
VIRTUAL_PACKAGES.discard(LEGACY_PACKAGE)
for module_name in (LEGACY_MODULE, LEGACY_PACKAGE, CANONICAL_MODULE, CANONICAL_PACKAGE):
sys.modules.pop(module_name, None)
if hasattr(builtins, "_legacy_compat_test_count"):
delattr(builtins, "_legacy_compat_test_count")
reset_legacy_import_diagnostics()
def test_legacy_import_reuses_canonical_module_identity(compatibility_modules):
"""旧路径先导入时应复用 canonical 模块且只执行一次源码。"""
legacy = importlib.import_module(LEGACY_MODULE)
canonical = importlib.import_module(CANONICAL_MODULE)
assert legacy is canonical
assert sys.modules[LEGACY_MODULE] is sys.modules[CANONICAL_MODULE]
assert legacy.__name__ == CANONICAL_MODULE
assert legacy.__spec__.name == CANONICAL_MODULE
assert builtins._legacy_compat_test_count == 1
def test_canonical_first_import_keeps_same_legacy_identity(compatibility_modules):
"""canonical 路径先导入时,旧路径仍应绑定同一模块对象。"""
canonical = importlib.import_module(CANONICAL_MODULE)
legacy = importlib.import_module(LEGACY_MODULE)
assert legacy is canonical
assert legacy.TOKEN is canonical.TOKEN
assert builtins._legacy_compat_test_count == 1
def test_virtual_package_blocks_unregistered_descendants(compatibility_modules):
"""合成旧父包不得向其他 Finder 泄漏未登记的子模块。"""
package = importlib.import_module(LEGACY_PACKAGE)
assert package.__path__ == []
with pytest.raises(ModuleNotFoundError, match="未在兼容映射中登记"):
importlib.import_module(f"{LEGACY_PACKAGE}.unknown")
def test_debug_diagnostics_warn_once_for_runtime_alias(compatibility_modules):
"""DEBUG 运行时兼容命中应输出一次包含迁移目标的警告。"""
messages = []
configure_legacy_import_diagnostics(enabled=True, emitter=messages.append)
importlib.import_module(LEGACY_MODULE)
importlib.import_module(LEGACY_MODULE)
assert len(messages) == 1
assert LEGACY_MODULE in messages[0]
assert CANONICAL_MODULE in messages[0]
assert "app.sdk.test" in messages[0]
def test_production_diagnostics_stay_silent(compatibility_modules):
"""DEBUG 关闭时兼容导入继续生效但不输出告警。"""
messages = []
configure_legacy_import_diagnostics(enabled=False, emitter=messages.append)
module = importlib.import_module(LEGACY_MODULE)
assert module is importlib.import_module(CANONICAL_MODULE)
assert messages == []
def test_plugin_scan_reports_cached_legacy_import(compatibility_modules, tmp_path: Path):
"""模块已缓存时,插件 AST 扫描仍应报告其静态旧导入。"""
importlib.import_module(LEGACY_MODULE)
reset_legacy_import_diagnostics()
messages = []
configure_legacy_import_diagnostics(enabled=True, emitter=messages.append)
plugin_dir = tmp_path / "sampleplugin"
plugin_dir.mkdir()
(plugin_dir / "__init__.py").write_text(
f"from {LEGACY_MODULE} import TOKEN\n",
encoding="utf-8",
)
scan_plugin_legacy_imports("SamplePlugin", plugin_dir)
scan_plugin_legacy_imports("SamplePlugin", plugin_dir)
assert len(messages) == 1
assert "插件 sampleplugin" in messages[0]
assert "__init__.py:1" in messages[0]
snapshot = get_legacy_import_diagnostics()
assert ("app.plugins.sampleplugin", LEGACY_MODULE) in snapshot["reported"]
def test_plugin_scan_accepts_utf8_bom(compatibility_modules, tmp_path: Path):
"""插件源码带 UTF-8 BOM 时仍应识别旧导入并输出迁移警告。"""
messages = []
configure_legacy_import_diagnostics(enabled=True, emitter=messages.append)
plugin_dir = tmp_path / "bomplugin"
plugin_dir.mkdir()
source = f"from {LEGACY_MODULE} import TOKEN\n".encode("utf-8")
(plugin_dir / "__init__.py").write_bytes(b"\xef\xbb\xbf" + source)
scan_plugin_legacy_imports("BomPlugin", plugin_dir)
assert len(messages) == 1
assert "插件 bomplugin" in messages[0]
assert LEGACY_MODULE in messages[0]
def test_manifest_aliases_reuse_real_canonical_modules():
"""正式映射表中的旧路径应在隔离进程中复用全部 canonical 模块。"""
code = """
import importlib
from app.runtime.compat.manifest import MODULE_ALIASES
# 独立探针不经过 pytest 引导,先隔离站点原生制品再校验兼容映射。
from app.testing.bootstrap import ensure_sites_stub
ensure_sites_stub()
for legacy_name, alias in MODULE_ALIASES.items():
try:
canonical = importlib.import_module(alias.target)
except ModuleNotFoundError:
if alias.target == "app.application.site.sites":
continue
raise
legacy = importlib.import_module(legacy_name)
assert legacy is canonical, (legacy_name, alias.target)
assert legacy.__name__ == alias.target, legacy_name
assert legacy.__spec__.name == alias.target, legacy_name
"""
subprocess.run(
[sys.executable, "-c", code],
cwd=Path(__file__).parents[1],
check=True,
)
def test_renamed_modules_use_exact_compat_routes() -> None:
"""旧模块路径只经 manifest 复用新的 canonical 模块。"""
expected = {
"app.application.chain.durable_events": "app.application.chain.events",
"app.application.transfer_execution": "app.application.transfer.execution",
"app.runtime.managed_resources": "app.runtime.resources",
}
configure_legacy_import_diagnostics(enabled=False, emitter=lambda _: None)
try:
for legacy_name, target_name in expected.items():
assert MODULE_ALIASES[legacy_name].target == target_name
assert importlib.import_module(legacy_name) is importlib.import_module(target_name)
finally:
reset_legacy_import_diagnostics()
def test_transfer_package_exposes_plugin_symbols_only_through_overlay() -> None:
"""整理包不重导出宿主实现,只按兼容清单惰性提供插件旧符号。"""
configure_legacy_import_diagnostics(enabled=False, emitter=lambda _: None)
try:
package = importlib.import_module("app.application.transfer")
legacy = importlib.import_module("app.sdk._legacy.transfer")
assert package.TransferTask is legacy.TransferTask
assert package.TransferQueue is legacy.TransferQueue
assert "TransferTask" not in package.__all__
assert "TransferQueue" not in package.__all__
finally:
reset_legacy_import_diagnostics()
def test_transfer_legacy_symbols_support_all_explicit_imports() -> None:
"""六个旧整理符号显式导入应在隔离进程中共享同一兼容类型。"""
code = """
from app.application.transfer import TransferTask as ApplicationTask
from app.application.transfer import TransferQueue as ApplicationQueue
from app.schemas import TransferTask as SchemaTask
from app.schemas import TransferQueue as SchemaQueue
from app.schemas.transfer import TransferTask as TransferSchemaTask
from app.schemas.transfer import TransferQueue as TransferSchemaQueue
import app.application.transfer as application_package
import app.schemas as schemas_package
import app.schemas.transfer as transfer_schema
from app.application.transfer.workflow import TransferQueue as CanonicalQueue
from app.application.transfer.workflow import TransferTask as CanonicalTask
from app.sdk._legacy.transfer import TransferQueue as LegacyQueue
from app.sdk._legacy.transfer import TransferTask as LegacyTask
class LegacyPayload:
def model_dump(self):
return {"kind": "legacy"}
task_types = (ApplicationTask, SchemaTask, TransferSchemaTask)
queue_types = (ApplicationQueue, SchemaQueue, TransferSchemaQueue)
assert all(task_type is LegacyTask for task_type in task_types)
assert all(queue_type is LegacyQueue for queue_type in queue_types)
assert issubclass(LegacyTask, CanonicalTask)
assert issubclass(LegacyQueue, CanonicalQueue)
task = ApplicationTask(
fileitem={"storage": "local", "path": "/downloads/movie.mkv", "type": "file"},
meta=LegacyPayload(),
)
assert isinstance(task, CanonicalTask)
assert task.to_dict()["meta"] == {"kind": "legacy"}
for queue_type in queue_types:
queue = queue_type(task=task)
assert queue.task is task
for package in (application_package, schemas_package, transfer_schema):
assert "TransferTask" not in package.__all__
assert "TransferQueue" not in package.__all__
"""
subprocess.run(
[sys.executable, "-c", code],
cwd=Path(__file__).parents[1],
check=True,
)
def test_virtual_package_exports_resolve_exact_manifest_symbols():
"""合成旧包仅公开 manifest 声明的符号,并记录 DEBUG 兼容警告。"""
legacy_package = "app.core.meta"
sys.modules.pop(legacy_package, None)
messages = []
configure_legacy_import_diagnostics(enabled=True, emitter=messages.append)
package = importlib.import_module(legacy_package)
assert set(package.__all__) == set(PACKAGE_EXPORTS[legacy_package])
assert package.MetaBase is importlib.import_module(
"app.domain.meta.metabase"
).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")
history_schema = importlib.import_module("app.schemas.history")
system_schema = importlib.import_module("app.schemas.system")
tmdb_schema = importlib.import_module("app.schemas.tmdb")
types_schema = importlib.import_module("app.schemas.types")
agent_schema = importlib.import_module("app.schemas.agent")
sdk_logging = importlib.import_module("app.sdk.logging")
legacy_logging = importlib.import_module("app.log")
runtime_logging = importlib.import_module("app.runtime.log")
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 transfer_schema.DownloadHistory is history_schema.DownloadHistory
assert transfer_schema.TransferDirectoryConf is system_schema.TransferDirectoryConf
assert transfer_schema.TmdbEpisode is tmdb_schema.TmdbEpisode
assert transfer_schema.MediaType is types_schema.MediaType
assert agent_schema.ReplyMode is types_schema.ReplyMode
assert sdk_logging.LoggerManager is runtime_logging.LoggerManager
assert legacy_logging.LoggerManager is runtime_logging.LoggerManager
assert schemas_package.TransferTask is legacy_transfer.TransferTask
assert schemas_package.TransferQueue is legacy_transfer.TransferQueue
def test_legacy_transfer_task_hides_internal_admission_identity():
"""持久准入身份不得改变插件旧任务字典的公开字段集合。"""
legacy_transfer = importlib.import_module("app.sdk._legacy.transfer")
task = legacy_transfer.TransferTask(fileitem={
"storage": "local",
"path": "/downloads/movie.mkv",
"type": "file",
})
public_fields = set(task.to_dict())
task.bind_admission_task_id("internal-task-id")
task.bind_execution_lease(
owner_id="internal-worker",
lease_token="internal-lease-token",
)
assert set(task.to_dict()) == public_fields
assert "task_id" not in task.to_dict()
assert "admission_task_id" not in task.to_dict()
assert "lease_owner" not in task.to_dict()
assert "lease_token" not in task.to_dict()
def test_chain_media_legacy_scraping_symbols_resolve_to_scraping_chain():
"""刮削拆分后,旧 app.chain.media 路径应能继续取用刮削公开符号。"""
legacy_media = importlib.import_module("app.chain.media")
canonical_scraping = importlib.import_module("app.chain.scraping")
assert legacy_media.ScrapingChain is canonical_scraping.ScrapingChain
assert legacy_media.ScrapingOption is canonical_scraping.ScrapingOption
assert legacy_media.ScrapingConfig is canonical_scraping.ScrapingConfig
def test_workflow_manager_legacy_name_resolves_only_through_symbol_overlay():
"""旧 WorkFlowManager 仍可显式导入,但不进入 canonical 模块公开面。"""
install_legacy_import_hook()
workflow_module = importlib.import_module("app.workflow")
assert workflow_module.WorkFlowManager is workflow_module.WorkflowManager
assert "WorkFlowManager" not in vars(workflow_module)
assert "WorkFlowManager" not in workflow_module.__all__
def test_rules_domain_legacy_modules_resolve_to_rules():
"""规则域收敛后,filter/filter_rules 旧路径应复用 rules 模块。"""
canonical = importlib.import_module("app.application.rules")
for legacy_name in ("app.application.filter", "app.application.filter_rules"):
legacy = importlib.import_module(legacy_name)
assert legacy is canonical, legacy_name
assert legacy.RuleHelper is canonical.RuleHelper
assert legacy.RuleParser is canonical.RuleParser
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.workflow"]) == {"WorkFlowManager"}
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.chain.media"]) == {
"ScrapingChain",
"ScrapingOption",
"ScrapingConfig",
}
assert set(SYMBOL_ALIASES["app.schemas"]) == {
"TransferTask",
"TransferQueue",
} | set(_MESSAGE_NOTIFICATION_SYMBOL_ALIASES)
assert set(SYMBOL_ALIASES["app.schemas.transfer"]) == {
"TransferTask",
"TransferQueue",
"DownloadHistory",
"TransferDirectoryConf",
"TmdbEpisode",
"MediaType",
}
assert set(SYMBOL_ALIASES["app.schemas.agent"]) == {"ReplyMode"}
assert set(SYMBOL_ALIASES["app.sdk.logging"]) == {
"CustomFormatter",
"LogConfigModel",
"LogEntry",
"LogSettings",
"LoggerManager",
"NonBlockingFileHandler",
"configure_log_settings",
"configure_log_writer",
"log_settings",
}
assert set(SYMBOL_ALIASES["app.schemas.types"]) == {
"MessageChannel",
"NotificationType",
}
assert set(SYMBOL_ALIASES["app.schemas.message"]) == set(
_MESSAGE_NOTIFICATION_SYMBOL_ALIASES
)