105 lines
3.2 KiB
Python
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) |