Files
MoviePilot/tests/test_legacy_import_compat.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

829 lines
30 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 builtins
import importlib
import subprocess
import sys
from pathlib import Path
from types import ModuleType
import pytest
import app.startup.initializers.plugins as plugins_initializer
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 (
PLUGINS_PACKAGE_ROOT_BACKUP_SUFFIX,
PLUGINS_PACKAGE_ROOT_SOURCE,
detect_plugins_package_root_shadowing,
detect_shadowed_exports,
install_legacy_import_hook,
restore_plugins_package_root,
)
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_workflow_oper_compatibility_is_only_exposed_by_overlay():
"""
旧工作流写入口只由 Legacy facade 和精确符号映射提供。
"""
legacy = importlib.import_module("app.db.workflow_oper")
canonical = importlib.import_module("app.db.oper.workflow")
oper_package = importlib.import_module("app.db.oper")
assert MODULE_ALIASES["app.db.workflow_oper"].target == "app.sdk._legacy.workflow"
assert issubclass(legacy.WorkflowOper, canonical.WorkflowOper)
assert legacy.WorkflowOper is not canonical.WorkflowOper
assert oper_package.WorkflowOper is legacy.WorkflowOper
assert "WorkflowOper" not in oper_package.__all__
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.chain"]) == {"ChainBase"}
assert set(SYMBOL_ALIASES["app.plugins"]) == {"PluginChian", "_PluginBase"}
assert set(SYMBOL_ALIASES["app.db.oper"]) == {
"SiteOper",
"SubscribeHistoryOper",
"SubscribeOper",
"TransferHistoryOper",
"WorkflowOper",
}
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",
"BaseMessage",
"ConversationMemory",
} | 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"]) == {"ConversationMemory", "ReplyMode"}
assert set(SYMBOL_ALIASES["app.sdk.cache"]) == {"AsyncRedisBackend", "RedisBackend"}
assert set(SYMBOL_ALIASES["app.adapters.cache.backends"]) == {"AsyncRedisBackend", "RedisBackend"}
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
)
def test_chain_base_legacy_sdk_and_canonical_imports_share_identity(monkeypatch):
"""
旧包根与 SDK 必须解析到 canonical ChainBase,方法补丁对三条路径同时可见。
"""
legacy_chain = importlib.import_module("app.chain")
canonical = importlib.import_module("app.chain.base").ChainBase
sdk = importlib.import_module("app.sdk.chain").ChainBase
marker = object()
monkeypatch.setattr(canonical, "_compat_identity_marker", marker, raising=False)
assert legacy_chain.ChainBase is canonical
assert sdk is canonical
assert legacy_chain.ChainBase._compat_identity_marker is marker
assert sdk._compat_identity_marker is marker
def test_plugin_base_legacy_and_sdk_imports_share_canonical_identity(monkeypatch):
"""
插件安装包根与 SDK 必须解析到同一个契约基类,属性补丁对两条路径同时可见。
"""
legacy_plugins = importlib.import_module("app.plugins")
canonical = importlib.import_module("app.sdk.plugin.base")._PluginBase
sdk = importlib.import_module("app.sdk.plugin")._PluginBase
marker = object()
monkeypatch.setattr(canonical, "_compat_identity_marker", marker, raising=False)
assert legacy_plugins._PluginBase is canonical
assert sdk is canonical
assert legacy_plugins._PluginBase._compat_identity_marker is marker
def test_legacy_plugin_chain_spelling_resolves_to_renamed_sdk_class():
"""
历史拼写 PluginChian 只存在于兼容层,解析到 SDK 的正名 PluginChain。
"""
configure_legacy_import_diagnostics(enabled=False, emitter=lambda _: None)
try:
legacy_plugins = importlib.import_module("app.plugins")
sdk = importlib.import_module("app.sdk.plugin")
assert legacy_plugins.PluginChian is sdk.PluginChain
assert not hasattr(sdk, "PluginChian")
assert "PluginChian" not in legacy_plugins.__all__
finally:
reset_legacy_import_diagnostics()
def test_shadow_detection_flags_second_implementation():
"""
物理模块自带第二份实现时必须被判定为遮蔽兼容符号。
"""
module = ModuleType("app.plugins")
module._PluginBase = type("_PluginBase", (), {})
shadowed = detect_shadowed_exports(module, SYMBOL_ALIASES["app.plugins"])
assert shadowed == [("_PluginBase", "app.sdk.plugin._PluginBase")]
def test_shadow_detection_allows_reimported_canonical_symbol():
"""
物理模块重新导入同一个 canonical 对象不算遮蔽。
"""
canonical = importlib.import_module("app.sdk.plugin.base")
module = ModuleType("app.plugins")
module._PluginBase = canonical._PluginBase
module.PluginChian = canonical.PluginChain
assert detect_shadowed_exports(module, SYMBOL_ALIASES["app.plugins"]) == []
def test_installed_symbol_overlay_modules_have_no_shadowed_exports():
"""
已装载的兼容叠加模块都不得用第二份实现遮蔽登记符号。
"""
configure_legacy_import_diagnostics(enabled=False, emitter=lambda _: None)
try:
violations: dict[str, list] = {}
for module_name, exports in SYMBOL_ALIASES.items():
try:
module = importlib.import_module(module_name)
except ModuleNotFoundError:
continue
shadowed = detect_shadowed_exports(module, exports)
if shadowed:
violations[module_name] = shadowed
assert violations == {}
finally:
reset_legacy_import_diagnostics()
def test_sdk_plugins_module_alias_reuses_manager_module():
"""
旧 SDK 路径 app.sdk.plugins 只经精确别名复用插件管理器模块。
"""
alias = MODULE_ALIASES["app.sdk.plugins"]
configure_legacy_import_diagnostics(enabled=False, emitter=lambda _: None)
try:
assert alias.target == "app.sdk.plugin.manager"
assert alias.replacement == "app.sdk.plugin.manager"
assert importlib.import_module("app.sdk.plugins") is importlib.import_module(
"app.sdk.plugin.manager"
)
finally:
reset_legacy_import_diagnostics()
def test_plugins_package_root_source_matches_shipped_file():
"""
自愈写回的包根源码必须与后端源码提供的包根完全一致。
"""
shipped = Path(__file__).resolve().parents[1] / "app" / "plugins" / "__init__.py"
assert shipped.read_text(encoding="utf-8") == PLUGINS_PACKAGE_ROOT_SOURCE
def test_plugins_package_root_shadowing_detects_legacy_host_implementation(tmp_path):
"""
旧版本自带宿主实现的包根必须被判定为遮蔽兼容符号。
"""
init_file = tmp_path / "__init__.py"
init_file.write_text(
"from abc import ABCMeta\n"
"\n"
"\n"
"class PluginChian:\n"
" pass\n"
"\n"
"\n"
"class _PluginBase(metaclass=ABCMeta):\n"
" pass\n",
encoding="utf-8",
)
assert detect_plugins_package_root_shadowing(init_file) == [
"PluginChian",
"_PluginBase",
]
def test_plugins_package_root_shadowing_ignores_reimported_symbols(tmp_path):
"""
包根只重新导入 canonical 对象不算遮蔽,自愈必须放过它。
"""
init_file = tmp_path / "__init__.py"
init_file.write_text(
'"""说明。"""\n'
"\n"
"from app.sdk.plugin.base import PluginChain as PluginChian\n"
"from app.sdk.plugin.base import _PluginBase\n",
encoding="utf-8",
)
assert detect_plugins_package_root_shadowing(init_file) == []
def test_plugins_package_root_shadowing_tolerates_unreadable_source(tmp_path):
"""
包根缺失或语法错误时返回空列表,把故障留给标准导入流程。
"""
missing_file = tmp_path / "__init__.py"
assert detect_plugins_package_root_shadowing(missing_file) == []
missing_file.write_text("class _PluginBase(\n", encoding="utf-8")
assert detect_plugins_package_root_shadowing(missing_file) == []
def test_restore_plugins_package_root_backs_up_and_rewrites(tmp_path):
"""
自愈必须先备份原文件再写回只含说明的命名空间入口。
"""
init_file = tmp_path / "__init__.py"
legacy_source = "class _PluginBase:\n pass\n"
init_file.write_text(legacy_source, encoding="utf-8")
backup_file = restore_plugins_package_root(init_file)
assert backup_file.name == "__init__.py" + PLUGINS_PACKAGE_ROOT_BACKUP_SUFFIX
assert backup_file.read_text(encoding="utf-8") == legacy_source
assert init_file.read_text(encoding="utf-8") == PLUGINS_PACKAGE_ROOT_SOURCE
assert detect_plugins_package_root_shadowing(init_file) == []
def test_startup_repairs_plugin_package_root_before_loading_plugins(tmp_path, monkeypatch):
"""
启动装配必须在导入插件前把被遮蔽的包根恢复成命名空间入口。
"""
plugins_root = tmp_path / "app" / "plugins"
plugins_root.mkdir(parents=True)
init_file = plugins_root / "__init__.py"
init_file.write_text("class PluginChian:\n pass\n", encoding="utf-8")
monkeypatch.setattr(
plugins_initializer,
"get_runtime_setting",
lambda name: str(tmp_path) if name == "ROOT_PATH" else None,
)
assert plugins_initializer.repair_plugin_package_root() == ["PluginChian"]
assert init_file.read_text(encoding="utf-8") == PLUGINS_PACKAGE_ROOT_SOURCE
assert (
plugins_root / ("__init__.py" + PLUGINS_PACKAGE_ROOT_BACKUP_SUFFIX)
).read_text(encoding="utf-8") == "class PluginChian:\n pass\n"
assert plugins_initializer.repair_plugin_package_root() == []
def test_startup_keeps_hard_failure_when_plugin_root_is_read_only(tmp_path, monkeypatch):
"""
运行目录不可写时自愈降级为报错并返回遮蔽符号,不静默跳过。
"""
plugins_root = tmp_path / "app" / "plugins"
plugins_root.mkdir(parents=True)
(plugins_root / "__init__.py").write_text(
"class _PluginBase:\n pass\n", encoding="utf-8"
)
monkeypatch.setattr(
plugins_initializer,
"get_runtime_setting",
lambda name: str(tmp_path) if name == "ROOT_PATH" else None,
)
def _deny(_init_file):
raise OSError("Read-only file system")
monkeypatch.setattr(plugins_initializer, "restore_plugins_package_root", _deny)
assert plugins_initializer.repair_plugin_package_root() == ["_PluginBase"]