mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
349 lines
13 KiB
Python
349 lines
13 KiB
Python
"""整理历史类型化边界与适配器布局门禁。"""
|
||
|
||
import ast
|
||
import importlib
|
||
from pathlib import Path
|
||
|
||
PROJECT_ROOT = Path(__file__).parents[1]
|
||
APP_ROOT = PROJECT_ROOT / "app"
|
||
HISTORY_APPLICATION_PATH = APP_ROOT / "application" / "history" / "__init__.py"
|
||
HISTORY_CONTRACTS_APPLICATION_PATH = APP_ROOT / "application" / "history" / "contracts.py"
|
||
HISTORY_RETRY_APPLICATION_PATH = APP_ROOT / "application" / "history" / "retry.py"
|
||
QUERY_APPLICATION_PATH = APP_ROOT / "application" / "query.py"
|
||
HISTORY_ADAPTER_PACKAGE = APP_ROOT / "db" / "adapters" / "history"
|
||
CANONICAL_CONSUMER_PATHS = (
|
||
APP_ROOT / "agent",
|
||
APP_ROOT / "api",
|
||
APP_ROOT / "application",
|
||
APP_ROOT / "chain",
|
||
APP_ROOT / "modules",
|
||
APP_ROOT / "monitor",
|
||
APP_ROOT / "startup",
|
||
APP_ROOT / "workflow",
|
||
APP_ROOT / "scheduler.py",
|
||
)
|
||
|
||
|
||
def _python_paths(root: Path) -> list[Path]:
|
||
"""返回 canonical 消费边界内的 Python 源文件。"""
|
||
if root.is_file():
|
||
return [root]
|
||
return sorted(root.rglob("*.py"))
|
||
|
||
|
||
def _annotation_contains_any(annotation: ast.expr | None) -> bool:
|
||
"""判断类型注解是否仍以 Any 隐藏跨层数据合同。"""
|
||
return annotation is not None and any(
|
||
isinstance(node, ast.Name) and node.id == "Any"
|
||
for node in ast.walk(annotation)
|
||
)
|
||
|
||
|
||
def _is_frozen_slotted_dataclass(node: ast.ClassDef) -> bool:
|
||
"""判断类是否声明为不可变且使用 slots 的数据类。"""
|
||
for decorator in node.decorator_list:
|
||
if not isinstance(decorator, ast.Call):
|
||
continue
|
||
if not isinstance(decorator.func, ast.Name) or decorator.func.id != "dataclass":
|
||
continue
|
||
keywords = {
|
||
keyword.arg: keyword.value
|
||
for keyword in decorator.keywords
|
||
if keyword.arg is not None
|
||
}
|
||
return all(
|
||
isinstance(keywords.get(name), ast.Constant)
|
||
and keywords[name].value is True
|
||
for name in ("frozen", "slots")
|
||
)
|
||
return False
|
||
|
||
|
||
def test_transfer_history_contract_is_typed_and_frozen() -> None:
|
||
"""整理历史必须使用冻结 DTO 和明确 Query/Write/Repository 端口。"""
|
||
tree = ast.parse(
|
||
HISTORY_CONTRACTS_APPLICATION_PATH.read_text(encoding="utf-8-sig"),
|
||
filename=str(HISTORY_CONTRACTS_APPLICATION_PATH),
|
||
)
|
||
classes = {
|
||
node.name: node
|
||
for node in tree.body
|
||
if isinstance(node, ast.ClassDef)
|
||
}
|
||
expected = {
|
||
"TransferHistorySnapshot",
|
||
"TransferHistoryWrite",
|
||
"TransferHistoryQueryPort",
|
||
"TransferHistoryWritePort",
|
||
"TransferHistoryRepository",
|
||
}
|
||
|
||
assert expected <= classes.keys()
|
||
assert _is_frozen_slotted_dataclass(classes["TransferHistorySnapshot"])
|
||
assert _is_frozen_slotted_dataclass(classes["TransferHistoryWrite"])
|
||
|
||
violations: list[str] = []
|
||
for class_name in expected:
|
||
class_node = classes[class_name]
|
||
for node in ast.walk(class_node):
|
||
if isinstance(node, ast.AnnAssign) and _annotation_contains_any(node.annotation):
|
||
violations.append(f"{class_name}:{node.lineno}:field")
|
||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||
annotations = [
|
||
argument.annotation
|
||
for argument in (
|
||
*node.args.posonlyargs,
|
||
*node.args.args,
|
||
*node.args.kwonlyargs,
|
||
)
|
||
if argument.arg not in {"self", "cls"}
|
||
]
|
||
if node.args.vararg is not None:
|
||
annotations.append(node.args.vararg.annotation)
|
||
if node.args.kwarg is not None:
|
||
annotations.append(node.args.kwarg.annotation)
|
||
annotations.append(node.returns)
|
||
if any(_annotation_contains_any(annotation) for annotation in annotations):
|
||
violations.append(f"{class_name}:{node.lineno}:{node.name}")
|
||
|
||
assert violations == []
|
||
|
||
|
||
def test_transfer_history_contract_has_one_canonical_owner() -> None:
|
||
"""整理历史契约只能在独立模块定义,旧入口必须保留对象级兼容导出。"""
|
||
contract_names = {
|
||
"TransferHistorySnapshot",
|
||
"TransferHistoryWrite",
|
||
"TransferHistoryStatisticSnapshot",
|
||
"TransferHistoryMonthlyStatistics",
|
||
"TransferHistoryQueryPort",
|
||
"TransferHistoryWritePort",
|
||
"TransferHistoryReplacePort",
|
||
"TransferHistoryStagingPort",
|
||
"TransferHistoryRepository",
|
||
}
|
||
history_tree = ast.parse(
|
||
HISTORY_APPLICATION_PATH.read_text(encoding="utf-8-sig"),
|
||
filename=str(HISTORY_APPLICATION_PATH),
|
||
)
|
||
facade = importlib.import_module("app.application.history")
|
||
contracts = importlib.import_module("app.application.history.contracts")
|
||
|
||
assert {
|
||
node.name
|
||
for node in history_tree.body
|
||
if isinstance(node, ast.ClassDef) and node.name in contract_names
|
||
} == set()
|
||
for name in contract_names:
|
||
assert getattr(facade, name) is getattr(contracts, name)
|
||
|
||
|
||
def test_history_query_port_returns_explicit_snapshots() -> None:
|
||
"""插件查询端口也必须声明冻结历史快照,不能以 object 隐藏合同。"""
|
||
tree = ast.parse(
|
||
QUERY_APPLICATION_PATH.read_text(encoding="utf-8-sig"),
|
||
filename=str(QUERY_APPLICATION_PATH),
|
||
)
|
||
history_port = next(
|
||
node
|
||
for node in tree.body
|
||
if isinstance(node, ast.ClassDef) and node.name == "HistoryQueryPort"
|
||
)
|
||
expected = {
|
||
"list_download_history": "QueryRows[DownloadHistorySnapshot]",
|
||
"get_download_history": "DownloadHistorySnapshot | None",
|
||
"list_transfer_history": "QueryRows[TransferHistorySnapshot]",
|
||
"get_transfer_history": "TransferHistorySnapshot | None",
|
||
}
|
||
|
||
assert {
|
||
node.name: ast.unparse(node.returns)
|
||
for node in history_port.body
|
||
if isinstance(node, ast.FunctionDef)
|
||
} == expected
|
||
|
||
|
||
def test_transfer_history_retires_dynamic_writer_facade() -> None:
|
||
"""类型化仓储启用后不得保留动态 getter、Facade 或 add_force 合同。"""
|
||
tree = ast.parse(
|
||
HISTORY_APPLICATION_PATH.read_text(encoding="utf-8-sig"),
|
||
filename=str(HISTORY_APPLICATION_PATH),
|
||
)
|
||
retired_classes = {
|
||
"TransferHistoryRecord",
|
||
"TransferHistoryWriter",
|
||
"TransferHistoryPort",
|
||
"AsyncTransferHistoryQueryRepository",
|
||
}
|
||
retired_functions = {
|
||
"_get_transfer_history_writer",
|
||
"get_transfer_history_port",
|
||
}
|
||
violations: list[str] = []
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.ClassDef) and node.name in retired_classes:
|
||
violations.append(f"class:{node.name}:{node.lineno}")
|
||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||
if node.name in retired_functions or node.name == "add_force":
|
||
violations.append(f"function:{node.name}:{node.lineno}")
|
||
if isinstance(node, ast.Attribute) and node.attr == "add_force":
|
||
violations.append(f"attribute:add_force:{node.lineno}")
|
||
|
||
assert violations == []
|
||
|
||
|
||
def test_history_retry_policy_has_one_canonical_owner() -> None:
|
||
"""失败重试与查重闸逻辑只能由独立模块实现,包入口不再转发。"""
|
||
retry_tree = ast.parse(
|
||
HISTORY_RETRY_APPLICATION_PATH.read_text(encoding="utf-8-sig"),
|
||
filename=str(HISTORY_RETRY_APPLICATION_PATH),
|
||
)
|
||
history_tree = ast.parse(
|
||
HISTORY_APPLICATION_PATH.read_text(encoding="utf-8-sig"),
|
||
filename=str(HISTORY_APPLICATION_PATH),
|
||
)
|
||
expected_class = "HistoryGateAction"
|
||
expected_functions = {
|
||
"is_skip_action",
|
||
"max_failed_retries",
|
||
"failed_retry_key",
|
||
"file_fingerprint",
|
||
"failed_retry_count",
|
||
"record_transfer_failure",
|
||
"clear_transfer_failures",
|
||
"resolve_history",
|
||
"evaluate_history_gate",
|
||
"describe_history_gate",
|
||
"next_failed_retry_count",
|
||
}
|
||
retry_classes = {
|
||
node.name
|
||
for node in retry_tree.body
|
||
if isinstance(node, ast.ClassDef)
|
||
}
|
||
retry_functions = {
|
||
node.name
|
||
for node in retry_tree.body
|
||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||
}
|
||
history_classes = {
|
||
node.name
|
||
for node in history_tree.body
|
||
if isinstance(node, ast.ClassDef)
|
||
}
|
||
history_functions = {
|
||
node.name
|
||
for node in history_tree.body
|
||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||
}
|
||
|
||
assert expected_class in retry_classes
|
||
assert expected_functions <= retry_functions
|
||
assert expected_class not in history_classes
|
||
assert expected_functions.isdisjoint(history_functions)
|
||
|
||
|
||
def test_canonical_consumers_do_not_import_raw_transfer_history_persistence() -> None:
|
||
"""宿主消费者不得导入整理历史 ORM、Oper 或旧动态 getter。"""
|
||
forbidden_from_imports = {
|
||
("app.db.models", "TransferHistory"),
|
||
("app.db.models.transferhistory", "TransferHistory"),
|
||
("app.db.oper", "TransferHistoryOper"),
|
||
("app.db.oper.transferhistory", "TransferHistoryOper"),
|
||
("app.application.history", "TransferHistoryPort"),
|
||
("app.application.history", "get_transfer_history_port"),
|
||
}
|
||
forbidden_modules = {
|
||
"app.db.models.transferhistory",
|
||
"app.db.oper.transferhistory",
|
||
}
|
||
violations: list[str] = []
|
||
|
||
for root in CANONICAL_CONSUMER_PATHS:
|
||
for path in _python_paths(root):
|
||
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||
relative = path.relative_to(PROJECT_ROOT).as_posix()
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.ImportFrom):
|
||
for alias in node.names:
|
||
if (node.module, alias.name) in forbidden_from_imports:
|
||
violations.append(
|
||
f"{relative}:{node.lineno}:{node.module}.{alias.name}"
|
||
)
|
||
elif isinstance(node, ast.Import):
|
||
for alias in node.names:
|
||
if alias.name in forbidden_modules:
|
||
violations.append(f"{relative}:{node.lineno}:{alias.name}")
|
||
elif isinstance(node, ast.Attribute) and node.attr == "add_force":
|
||
violations.append(f"{relative}:{node.lineno}:add_force")
|
||
elif isinstance(node, ast.Name) and node.id == "get_transfer_history_port":
|
||
violations.append(
|
||
f"{relative}:{node.lineno}:get_transfer_history_port"
|
||
)
|
||
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||
targets = (
|
||
node.targets
|
||
if isinstance(node, ast.Assign)
|
||
else [node.target]
|
||
)
|
||
value = node.value
|
||
if (
|
||
any(
|
||
isinstance(target, ast.Name)
|
||
and target.id.startswith("TransferHistory")
|
||
for target in targets
|
||
)
|
||
and value is not None
|
||
and any(
|
||
isinstance(child, ast.Name) and child.id == "Any"
|
||
for child in ast.walk(value)
|
||
)
|
||
):
|
||
violations.append(f"{relative}:{node.lineno}:Any alias")
|
||
|
||
assert violations == []
|
||
|
||
|
||
def test_add_force_is_owned_only_by_legacy_sdk_facade() -> None:
|
||
"""旧无类型替换入口只能存在于私有 Legacy facade。"""
|
||
allowed = "app/sdk/_legacy/history.py"
|
||
violations: list[str] = []
|
||
for path in APP_ROOT.rglob("*.py"):
|
||
relative = path.relative_to(PROJECT_ROOT).as_posix()
|
||
if relative.startswith("app/plugins/") or relative == allowed:
|
||
continue
|
||
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||
if node.name == "add_force":
|
||
violations.append(f"{relative}:{node.lineno}:definition")
|
||
elif isinstance(node, ast.Attribute) and node.attr == "add_force":
|
||
violations.append(f"{relative}:{node.lineno}:call")
|
||
|
||
assert violations == []
|
||
|
||
|
||
def test_transfer_history_adapter_uses_topic_package_without_root_exports() -> None:
|
||
"""整理历史适配器必须位于 history/transfer.py,包根不得复制宿主导出面。"""
|
||
adapter_path = HISTORY_ADAPTER_PACKAGE / "transfer.py"
|
||
package_path = HISTORY_ADAPTER_PACKAGE / "__init__.py"
|
||
|
||
assert adapter_path.is_file()
|
||
assert not (APP_ROOT / "db" / "adapters" / "transferhistory.py").exists()
|
||
assert not (APP_ROOT / "db" / "adapters" / "transfer_history.py").exists()
|
||
|
||
tree = ast.parse(
|
||
package_path.read_text(encoding="utf-8-sig"),
|
||
filename=str(package_path),
|
||
)
|
||
statements = [
|
||
node
|
||
for node in tree.body
|
||
if not (
|
||
isinstance(node, ast.Expr)
|
||
and isinstance(node.value, ast.Constant)
|
||
and isinstance(node.value.value, str)
|
||
)
|
||
]
|
||
assert statements == []
|