mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 12:24:39 +08:00
442 lines
16 KiB
Python
442 lines
16 KiB
Python
"""
|
|
验证插件兼容导入清单与受控能力边界。
|
|
"""
|
|
|
|
import ast
|
|
import importlib
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.runtime.compat.manifest import MODULE_ALIASES, PACKAGE_ALIASES
|
|
|
|
PROJECT_ROOT = Path(__file__).parents[1]
|
|
APP_ROOT = PROJECT_ROOT / "app"
|
|
OFFICIAL_PLUGIN_BASELINE = (
|
|
PROJECT_ROOT / "tests" / "fixtures" / "architecture" / "official-plugin-baseline.json"
|
|
)
|
|
FILENAME_POLICY = PROJECT_ROOT / "tests" / "fixtures" / "architecture" / "filename-policy.json"
|
|
PLUGIN_COMPONENT_PACKAGES = (
|
|
APP_ROOT / "adapters" / "external" / "plugin",
|
|
APP_ROOT / "adapters" / "system" / "plugin",
|
|
APP_ROOT / "application" / "plugin",
|
|
APP_ROOT / "runtime" / "extensions" / "plugin",
|
|
)
|
|
AUDITED_PLUGIN_MANAGER_MEMBERS = {
|
|
"get_plugin_config",
|
|
"get_plugin_ids",
|
|
"plugins",
|
|
"running_plugins",
|
|
}
|
|
DELEGATED_PLUGIN_CATALOG_METHODS = {
|
|
"async_get_online_plugin_candidates",
|
|
"async_get_online_plugins",
|
|
"async_get_plugins_from_market",
|
|
"get_installed_plugins",
|
|
"get_local_plugin_version",
|
|
"get_local_plugins",
|
|
"get_local_repo_plugins",
|
|
"get_online_plugins",
|
|
"get_plugins_from_market",
|
|
"is_plugin_exists",
|
|
"process_plugins_list",
|
|
}
|
|
|
|
|
|
def _parse(path: Path) -> ast.Module:
|
|
"""
|
|
解析 UTF-8 Python 源码,兼容历史插件文件可能携带的 BOM。
|
|
"""
|
|
return ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
|
|
|
|
|
def _module_imports(tree: ast.AST) -> set[str]:
|
|
"""
|
|
返回语法树中的绝对导入模块,不把导入符号误当成子模块。
|
|
"""
|
|
imports: set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
imports.update(alias.name for alias in node.names)
|
|
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
|
imports.add(node.module)
|
|
return imports
|
|
|
|
|
|
def _public_names(tree: ast.AST) -> set[str]:
|
|
"""
|
|
返回模块定义或非私有导入到顶层的符号名。
|
|
"""
|
|
names: set[str] = set()
|
|
for node in tree.body:
|
|
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
names.add(node.name)
|
|
elif isinstance(node, ast.ImportFrom):
|
|
names.update(alias.asname or alias.name for alias in node.names)
|
|
elif isinstance(node, ast.Import):
|
|
names.update(alias.asname or alias.name.split(".", 1)[0] for alias in node.names)
|
|
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
names.update(target.id for target in targets if isinstance(target, ast.Name))
|
|
return {name for name in names if not name.startswith("_")}
|
|
|
|
|
|
def _camel_to_snake(value: str) -> str:
|
|
"""
|
|
把合同类名转换为对应的稳定模块标识。
|
|
"""
|
|
first_pass = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", value)
|
|
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", first_pass).lower()
|
|
|
|
|
|
def _matches_filename_contract(path: Path, rule: dict[str, str]) -> bool:
|
|
"""
|
|
按真实发现合同判断多词文件是否属于有理由的语义命名。
|
|
"""
|
|
relative = path.relative_to(PROJECT_ROOT).as_posix()
|
|
root = rule["root"].rstrip("/")
|
|
if not relative.startswith(f"{root}/"):
|
|
return False
|
|
kind = rule["kind"]
|
|
if kind == "agent_tool_private_helper":
|
|
return path.stem.startswith("_")
|
|
|
|
tree = _parse(path)
|
|
class_names = {node.name for node in tree.body if isinstance(node, ast.ClassDef)}
|
|
if kind == "agent_tool_contract":
|
|
expected_name = path.stem
|
|
for node in tree.body:
|
|
if not isinstance(node, ast.ClassDef):
|
|
continue
|
|
for statement in node.body:
|
|
if not isinstance(statement, ast.AnnAssign):
|
|
continue
|
|
if not isinstance(statement.target, ast.Name) or statement.target.id != "name":
|
|
continue
|
|
value = statement.value
|
|
if isinstance(value, ast.Call) and isinstance(value.func, ast.Name) and value.func.id == "Field":
|
|
value = next(
|
|
(keyword.value for keyword in value.keywords if keyword.arg == "default"),
|
|
value.args[0] if value.args else None,
|
|
)
|
|
if isinstance(value, ast.Constant) and value.value == expected_name:
|
|
return True
|
|
return False
|
|
if kind == "workflow_action_contract":
|
|
return any(
|
|
name.endswith("Action")
|
|
and _camel_to_snake(name.removesuffix("Action")) == path.stem
|
|
for name in class_names
|
|
)
|
|
if kind == "indexer_parser_contract":
|
|
if any(
|
|
name.endswith("SiteUserInfo")
|
|
and _camel_to_snake(name.removesuffix("SiteUserInfo")) == path.stem
|
|
for name in class_names
|
|
):
|
|
return True
|
|
return any(
|
|
isinstance(statement, ast.Assign)
|
|
and any(isinstance(target, ast.Name) and target.id == "schema" for target in statement.targets)
|
|
and isinstance(statement.value, ast.Attribute)
|
|
and _camel_to_snake(statement.value.attr) == path.stem
|
|
for node in tree.body
|
|
if isinstance(node, ast.ClassDef)
|
|
for statement in node.body
|
|
)
|
|
raise AssertionError(f"未知文件命名合同:{kind}")
|
|
|
|
|
|
def _is_documentation_only_root(tree: ast.Module) -> bool:
|
|
"""
|
|
判断包根是否只保留包说明。
|
|
"""
|
|
return not tree.body or (ast.get_docstring(tree) is not None and len(tree.body) == 1)
|
|
|
|
|
|
def _is_curated_lazy_facade(tree: ast.Module) -> bool:
|
|
"""
|
|
判断包根是否为显式白名单驱动且不含公开实现的懒门面。
|
|
"""
|
|
assignments = {
|
|
target.id: node.value
|
|
for node in tree.body
|
|
if isinstance(node, ast.Assign)
|
|
for target in node.targets
|
|
if isinstance(target, ast.Name) and target.id in {"_EXPORTS", "__all__"}
|
|
}
|
|
if set(assignments) != {"_EXPORTS", "__all__"}:
|
|
return False
|
|
if any(
|
|
isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef))
|
|
and not node.name.startswith("_")
|
|
for node in tree.body
|
|
):
|
|
return False
|
|
exports = ast.literal_eval(assignments["_EXPORTS"])
|
|
public = ast.literal_eval(assignments["__all__"])
|
|
export_names = set(exports) if isinstance(exports, dict) else set(exports)
|
|
return export_names == set(public)
|
|
|
|
|
|
def test_official_plugin_legacy_imports_are_exactly_routed_or_retired() -> None:
|
|
"""
|
|
官方插件旧导入必须有精确 Compat 路由,或保持一条已审查的可选退役路径。
|
|
"""
|
|
baseline = json.loads(OFFICIAL_PLUGIN_BASELINE.read_text(encoding="utf-8"))
|
|
legacy_imports = {
|
|
module
|
|
for module in baseline["imports"]
|
|
if module == "app.log"
|
|
or module.startswith(("app.core.", "app.helper.", "app.utils."))
|
|
}
|
|
routed = set(MODULE_ALIASES) | set(PACKAGE_ALIASES)
|
|
|
|
# app.helper.subscribe 已由宿主删除,不应恢复源码或猜测映射。
|
|
assert legacy_imports - routed == set()
|
|
assert "app.helper.subscribe" not in routed
|
|
|
|
|
|
def test_plugin_manager_legacy_and_sdk_paths_share_canonical_identity() -> None:
|
|
"""
|
|
V2 旧路径与 V3 SDK 必须复用同一个 PluginManager 身份及已验证成员。
|
|
"""
|
|
canonical = importlib.import_module("app.runtime.extensions.plugin.manager")
|
|
legacy = importlib.import_module("app.core.plugin")
|
|
sdk = importlib.import_module("app.sdk.plugin.manager")
|
|
|
|
assert MODULE_ALIASES["app.core.plugin"].target == canonical.__name__
|
|
assert MODULE_ALIASES["app.core.plugin"].replacement == "app.sdk.plugin.manager"
|
|
assert legacy is canonical
|
|
assert legacy.PluginManager is canonical.PluginManager
|
|
assert sdk.PluginManager is canonical.PluginManager
|
|
assert AUDITED_PLUGIN_MANAGER_MEMBERS <= set(dir(canonical.PluginManager))
|
|
assert sdk.__all__ == ["ModuleManager", "PluginManager"]
|
|
assert not hasattr(sdk, "PluginHelper")
|
|
|
|
|
|
def test_plugin_namespace_root_only_documents_the_install_directory() -> None:
|
|
"""
|
|
插件安装命名空间包根只保留说明,契约基类由 SDK 拥有。
|
|
"""
|
|
tree = _parse(APP_ROOT / "plugins" / "__init__.py")
|
|
|
|
assert _is_documentation_only_root(tree)
|
|
assert _public_names(tree) == set()
|
|
|
|
|
|
def test_plugin_contract_base_depends_on_canonical_modules_only() -> None:
|
|
"""
|
|
插件契约基类直接依赖 canonical 模块,宿主源码不消费幻影旧路径。
|
|
"""
|
|
tree = _parse(APP_ROOT / "sdk" / "plugin" / "base.py")
|
|
legacy = {
|
|
module
|
|
for module in _module_imports(tree)
|
|
if module == "app.log" or module.startswith(("app.core.", "app.helper.", "app.utils."))
|
|
}
|
|
|
|
assert legacy == set()
|
|
|
|
|
|
def test_plugin_helper_stays_on_exact_legacy_adapter_route() -> None:
|
|
"""
|
|
PluginHelper 只保留精确旧 Helper 路由,不进入推荐插件 SDK。
|
|
"""
|
|
canonical = importlib.import_module("app.adapters.external.market")
|
|
legacy = importlib.import_module("app.helper.plugin")
|
|
alias = MODULE_ALIASES["app.helper.plugin"]
|
|
|
|
assert alias.target == canonical.__name__
|
|
assert alias.replacement == canonical.__name__
|
|
assert legacy is canonical
|
|
assert legacy.PluginHelper is canonical.PluginHelper
|
|
|
|
|
|
def test_plugin_entry_and_application_do_not_reimport_concrete_legacy_owners() -> None:
|
|
"""
|
|
Plugin 入口与应用用例不得重新依赖 PluginHelper、ServerHelper 或具体 PluginManager。
|
|
"""
|
|
forbidden_modules = {
|
|
"app.adapters.external.market",
|
|
"app.adapters.external.server",
|
|
"app.runtime.extensions.plugin.manager",
|
|
}
|
|
forbidden_names = {"MoviePilotServerHelper", "PluginHelper", "PluginManager"}
|
|
paths = [APP_ROOT / "api" / "endpoints" / "plugin.py"]
|
|
paths.extend((APP_ROOT / "application" / "plugin").rglob("*.py"))
|
|
violations: list[str] = []
|
|
|
|
for path in paths:
|
|
tree = _parse(path)
|
|
relative = path.relative_to(PROJECT_ROOT).as_posix()
|
|
violations.extend(
|
|
f"{relative}:import:{module}"
|
|
for module in sorted(_module_imports(tree) & forbidden_modules)
|
|
)
|
|
referenced = {
|
|
node.id
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, ast.Name) and node.id in forbidden_names
|
|
}
|
|
violations.extend(f"{relative}:name:{name}" for name in sorted(referenced))
|
|
|
|
assert violations == []
|
|
|
|
|
|
def test_plugin_manager_catalog_compat_methods_remain_thin_delegates() -> None:
|
|
"""
|
|
PluginManager 的旧市场目录方法只能委托,不得重新吸收应用目录算法。
|
|
"""
|
|
path = APP_ROOT / "runtime" / "extensions" / "plugin" / "manager.py"
|
|
tree = _parse(path)
|
|
manager = next(
|
|
node
|
|
for node in tree.body
|
|
if isinstance(node, ast.ClassDef) and node.name == "PluginManager"
|
|
)
|
|
methods = {
|
|
node.name: node
|
|
for node in manager.body
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
and node.name in DELEGATED_PLUGIN_CATALOG_METHODS
|
|
}
|
|
|
|
assert set(methods) == DELEGATED_PLUGIN_CATALOG_METHODS
|
|
for method_name, method in methods.items():
|
|
statements = [
|
|
statement
|
|
for statement in method.body
|
|
if not (
|
|
isinstance(statement, ast.Expr)
|
|
and isinstance(statement.value, ast.Constant)
|
|
and isinstance(statement.value.value, str)
|
|
)
|
|
]
|
|
assert len(statements) == 1, method_name
|
|
assert isinstance(statements[0], ast.Return), method_name
|
|
value = statements[0].value
|
|
if isinstance(value, ast.Await):
|
|
value = value.value
|
|
assert isinstance(value, ast.Call), method_name
|
|
assert ast.unparse(value.func).startswith("self._plugin_catalog_view."), method_name
|
|
|
|
|
|
def test_plugin_capability_package_roots_do_not_duplicate_exports() -> None:
|
|
"""
|
|
多文件插件能力包根只保留说明,不得复制子模块实现或宿主导出。
|
|
"""
|
|
violations: list[str] = []
|
|
for package in PLUGIN_COMPONENT_PACKAGES:
|
|
path = package / "__init__.py"
|
|
tree = _parse(path)
|
|
if not ast.get_docstring(tree) or len(tree.body) != 1 or _public_names(tree):
|
|
violations.append(path.relative_to(PROJECT_ROOT).as_posix())
|
|
|
|
assert violations == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("declaration", "expected"),
|
|
[
|
|
('"tool_name"', True),
|
|
('"other_name"', False),
|
|
('Field("tool_name", description="工具名称")', True),
|
|
('Field(default="tool_name", description="工具名称")', True),
|
|
('Field(default="other_name", description="工具名称")', False),
|
|
('Field(description="工具名称")', False),
|
|
('unrelated("tool_name")', False),
|
|
],
|
|
)
|
|
def test_agent_tool_filename_contract_reads_declared_default(tmp_path, monkeypatch, declaration, expected):
|
|
"""
|
|
文件名契约读取常量或 Field 的默认值,不把描述或其他调用误当成工具名称。
|
|
"""
|
|
monkeypatch.setattr(__name__ + ".PROJECT_ROOT", tmp_path)
|
|
path = tmp_path / "app" / "agent" / "tools" / "impl" / "tool_name.py"
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(f"class Tool:\n name: str = {declaration}\n", encoding="utf-8")
|
|
|
|
assert _matches_filename_contract(
|
|
path,
|
|
{"root": "app/agent/tools/impl", "kind": "agent_tool_contract"},
|
|
) is expected
|
|
|
|
|
|
def test_new_host_production_modules_use_single_word_filenames() -> None:
|
|
"""
|
|
宿主多词文件必须满足稳定发现合同或带有可审查的逐项理由。
|
|
"""
|
|
policy = json.loads(FILENAME_POLICY.read_text(encoding="utf-8"))
|
|
assert policy["schema_version"] == 2
|
|
assert policy["scope"] == {
|
|
"root": "app",
|
|
"excluded": ["app/plugins"],
|
|
"special": ["__init__.py"],
|
|
}
|
|
single_word = re.compile(r"[a-z][a-z0-9]*")
|
|
actual = sorted(
|
|
path
|
|
for path in APP_ROOT.rglob("*.py")
|
|
if not path.is_relative_to(APP_ROOT / "plugins")
|
|
and path.name != "__init__.py"
|
|
and not single_word.fullmatch(path.stem)
|
|
)
|
|
|
|
contracts = policy["semantic_filename_contracts"]
|
|
reviewed = {item["path"]: item for item in policy["reviewed_multiword_files"]}
|
|
assert all(item["reason"].strip() for item in contracts)
|
|
assert all(item["reason"].strip() and item["evidence"].strip() for item in reviewed.values())
|
|
|
|
unmatched = []
|
|
contract_paths = set()
|
|
for path in actual:
|
|
relative = path.relative_to(PROJECT_ROOT).as_posix()
|
|
if any(_matches_filename_contract(path, rule) for rule in contracts):
|
|
contract_paths.add(relative)
|
|
elif relative not in reviewed:
|
|
unmatched.append(relative)
|
|
|
|
assert unmatched == []
|
|
assert set(reviewed) == {
|
|
path.relative_to(PROJECT_ROOT).as_posix() for path in actual
|
|
} - contract_paths
|
|
|
|
|
|
def test_package_roots_are_documentation_facades_or_reasoned_owners() -> None:
|
|
"""
|
|
全局包根只能为空、说明、精确懒门面、模块入口或有理由的既有 owner。
|
|
"""
|
|
policy = json.loads(FILENAME_POLICY.read_text(encoding="utf-8"))
|
|
exceptions = {item["path"]: item for item in policy["package_root_exceptions"]}
|
|
assert all(
|
|
item["mode"] in {"bootstrap", "implementation", "public_facade", "contained_vendor"}
|
|
and item["reason"].strip()
|
|
for item in exceptions.values()
|
|
)
|
|
|
|
used_exceptions: set[str] = set()
|
|
violations: list[str] = []
|
|
modules_root = APP_ROOT / "modules"
|
|
for path in APP_ROOT.rglob("__init__.py"):
|
|
if path.is_relative_to(APP_ROOT / "plugins"):
|
|
continue
|
|
relative = path.relative_to(PROJECT_ROOT).as_posix()
|
|
if relative in exceptions:
|
|
used_exceptions.add(relative)
|
|
continue
|
|
tree = _parse(path)
|
|
if _is_documentation_only_root(tree) or _is_curated_lazy_facade(tree):
|
|
continue
|
|
if path.parent.parent == modules_root and any(
|
|
isinstance(node, ast.ClassDef) and node.name.endswith("Module")
|
|
for node in tree.body
|
|
):
|
|
continue
|
|
violations.append(relative)
|
|
|
|
assert violations == []
|
|
assert used_exceptions == set(exceptions)
|