Files

105 lines
3.2 KiB
Python

"""Tests for the documented modular-monolith dependency boundaries."""
from __future__ import annotations
import ast
from collections.abc import Iterator
from pathlib import Path
SOURCE_ROOT = Path(__file__).resolve().parents[2] / "src" / "ragcore"
FRAMEWORK_IMPORTS = (
"alembic",
"docling",
"fastapi",
"httpx",
"pydantic",
"pydantic_settings",
"qdrant_client",
"sqlalchemy",
"starlette",
"uvicorn",
)
FORBIDDEN_RAGCORE_IMPORTS = {
"domain": {
"ragcore.api",
"ragcore.application",
"ragcore.evaluation",
"ragcore.generation",
"ragcore.ingestion",
"ragcore.infrastructure",
"ragcore.platform",
"ragcore.retrieval",
"ragcore.web",
},
"application": {
"ragcore.api",
"ragcore.infrastructure",
"ragcore.platform",
"ragcore.web",
},
}
def _module_name(path: Path) -> str:
relative = path.relative_to(SOURCE_ROOT).with_suffix("")
parts = relative.parts
if parts[-1] == "__init__":
parts = parts[:-1]
return ".".join(("ragcore", *parts))
def _resolve_relative_import(module_name: str, node: ast.ImportFrom) -> str:
if node.level == 0:
return node.module or ""
package_parts = module_name.split(".")[:-1]
base_parts = package_parts[: len(package_parts) - node.level + 1]
if node.module:
base_parts.extend(node.module.split("."))
return ".".join(base_parts)
def _imports(path: Path) -> Iterator[tuple[int, str]]:
module_name = _module_name(path)
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
yield node.lineno, alias.name
elif isinstance(node, ast.ImportFrom):
yield node.lineno, _resolve_relative_import(module_name, node)
def _matches(import_name: str, prefix: str) -> bool:
return import_name == prefix or import_name.startswith(f"{prefix}.")
def test_domain_and_application_respect_dependency_boundaries() -> None:
violations: list[str] = []
for layer, forbidden_modules in FORBIDDEN_RAGCORE_IMPORTS.items():
layer_root = SOURCE_ROOT / layer
for path in sorted(layer_root.rglob("*.py")):
for line, imported in _imports(path):
framework = next(
(prefix for prefix in FRAMEWORK_IMPORTS if _matches(imported, prefix)),
None,
)
forbidden = next(
(prefix for prefix in forbidden_modules if _matches(imported, prefix)),
None,
)
if framework:
violations.append(
f"{path}:{line}: {layer} imports framework {imported}; "
"keep framework adapters at the API/infrastructure boundary"
)
elif forbidden:
violations.append(
f"{path}:{line}: {layer} imports {imported}; "
"depend on domain rules and application ports instead"
)
assert not violations, "Architecture boundary violations:\n" + "\n".join(violations)