archetype-py 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- archetype/__init__.py +3 -0
- archetype/analysis/__init__.py +3 -0
- archetype/analysis/ast_utils.py +19 -0
- archetype/analysis/imports.py +97 -0
- archetype/analysis/init.py +1 -0
- archetype/analysis/models.py +26 -0
- archetype/check.py +70 -0
- archetype/dsl/__init__.py +3 -0
- archetype/dsl/init.py +1 -0
- archetype/dsl/query.py +114 -0
- archetype/init.py +8 -0
- archetype/plugin/__init__.py +3 -0
- archetype/plugin/init.py +1 -0
- archetype/plugin/pytest_plugin.py +75 -0
- archetype/reporter.py +65 -0
- archetype/rule.py +61 -0
- archetype/rules/__init__.py +8 -0
- archetype/rules/boundaries.py +57 -0
- archetype/rules/cycles.py +69 -0
- archetype/rules/layers.py +69 -0
- archetype/rules/naming.py +116 -0
- archetype_py-0.1.0.dist-info/METADATA +195 -0
- archetype_py-0.1.0.dist-info/RECORD +26 -0
- archetype_py-0.1.0.dist-info/WHEEL +4 -0
- archetype_py-0.1.0.dist-info/entry_points.txt +5 -0
- archetype_py-0.1.0.dist-info/licenses/LICENSE +21 -0
archetype/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""AST traversal helpers used by Archetype static analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_class_names(tree: ast.AST) -> list[str]:
|
|
9
|
+
"""Return all class names defined anywhere in the AST."""
|
|
10
|
+
return [node.name for node in ast.walk(tree) if isinstance(node, ast.ClassDef)]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_top_level_function_names(tree: ast.AST) -> list[str]:
|
|
14
|
+
"""Return names of module-level functions only (excluding class methods)."""
|
|
15
|
+
return [
|
|
16
|
+
node.name
|
|
17
|
+
for node in getattr(tree, "body", [])
|
|
18
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
19
|
+
]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Import parsing and dependency graph construction from Python source files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import networkx as nx
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def path_to_module(file_path: Path, project_root: Path) -> str:
|
|
12
|
+
"""Convert a Python file path to its fully-qualified module path."""
|
|
13
|
+
relative = file_path.relative_to(project_root).with_suffix("")
|
|
14
|
+
parts = list(relative.parts)
|
|
15
|
+
|
|
16
|
+
if parts and parts[-1] in {"__init__", "init"}:
|
|
17
|
+
parts = parts[:-1]
|
|
18
|
+
|
|
19
|
+
return ".".join(parts)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_relative_import(
|
|
23
|
+
current_module: str, imported_module: str | None, level: int
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Resolve a relative import into an absolute module path."""
|
|
26
|
+
if level <= 0:
|
|
27
|
+
return imported_module or current_module
|
|
28
|
+
|
|
29
|
+
current_parts = [part for part in current_module.split(".") if part]
|
|
30
|
+
drop_count = min(level, len(current_parts))
|
|
31
|
+
base_parts = current_parts[:-drop_count]
|
|
32
|
+
|
|
33
|
+
if imported_module:
|
|
34
|
+
return ".".join(base_parts + imported_module.split("."))
|
|
35
|
+
return ".".join(base_parts)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_import_graph(project_root: Path) -> nx.DiGraph:
|
|
39
|
+
"""Build a directed import graph for local Python modules under project_root."""
|
|
40
|
+
graph = nx.DiGraph()
|
|
41
|
+
root = project_root.resolve()
|
|
42
|
+
|
|
43
|
+
def is_local_module(module_name: str) -> bool:
|
|
44
|
+
top_level = module_name.split(".", maxsplit=1)[0]
|
|
45
|
+
return (root / top_level).is_dir()
|
|
46
|
+
|
|
47
|
+
def add_import_edge(current_module: str, imported_module: str) -> None:
|
|
48
|
+
if imported_module and is_local_module(imported_module):
|
|
49
|
+
graph.add_node(imported_module)
|
|
50
|
+
graph.add_edge(current_module, imported_module)
|
|
51
|
+
|
|
52
|
+
for file_path in sorted(root.rglob("*.py")):
|
|
53
|
+
current_module = path_to_module(file_path, root)
|
|
54
|
+
if not current_module:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
graph.add_node(current_module)
|
|
58
|
+
|
|
59
|
+
source = file_path.read_text(encoding="utf-8")
|
|
60
|
+
tree = ast.parse(source, filename=str(file_path))
|
|
61
|
+
|
|
62
|
+
for node in ast.walk(tree):
|
|
63
|
+
if isinstance(node, ast.Import):
|
|
64
|
+
for alias in node.names:
|
|
65
|
+
add_import_edge(current_module, alias.name)
|
|
66
|
+
|
|
67
|
+
if isinstance(node, ast.ImportFrom):
|
|
68
|
+
if node.level and node.level > 0:
|
|
69
|
+
resolution_context = current_module
|
|
70
|
+
if file_path.stem in {"__init__", "init"}:
|
|
71
|
+
resolution_context = f"{current_module}.__init__"
|
|
72
|
+
base_module = resolve_relative_import(
|
|
73
|
+
resolution_context,
|
|
74
|
+
node.module,
|
|
75
|
+
node.level,
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
base_module = node.module or ""
|
|
79
|
+
|
|
80
|
+
# Resolve imported names as potential submodules first.
|
|
81
|
+
# Example: `from simple_project import db` -> `simple_project.db`
|
|
82
|
+
# Example: `from . import utils` -> `<current_pkg>.utils`
|
|
83
|
+
resolved_submodule = False
|
|
84
|
+
for alias in node.names:
|
|
85
|
+
if alias.name == "*":
|
|
86
|
+
continue
|
|
87
|
+
candidate = f"{base_module}.{alias.name}" if base_module else alias.name
|
|
88
|
+
if candidate and is_local_module(candidate):
|
|
89
|
+
resolved_submodule = True
|
|
90
|
+
add_import_edge(current_module, candidate)
|
|
91
|
+
|
|
92
|
+
# Fall back to base module dependency when no concrete local
|
|
93
|
+
# submodule candidates were found (or for star imports).
|
|
94
|
+
if not resolved_submodule or any(alias.name == "*" for alias in node.names):
|
|
95
|
+
add_import_edge(current_module, base_module)
|
|
96
|
+
|
|
97
|
+
return graph
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Static analysis utilities for building and inspecting module dependency graphs."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Data models representing modules, imports, and analysis results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Violation:
|
|
11
|
+
"""Represents a single architectural rule violation."""
|
|
12
|
+
|
|
13
|
+
module: str
|
|
14
|
+
file: Path
|
|
15
|
+
line: int
|
|
16
|
+
message: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RuleResult:
|
|
21
|
+
"""Represents the execution outcome for a single architecture rule."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
passed: bool
|
|
25
|
+
violations: list[Violation] = field(default_factory=list)
|
|
26
|
+
error: Exception | None = None
|
archetype/check.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Command-line entry points and orchestration for running architecture checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import sys
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from archetype.dsl.query import load_project
|
|
13
|
+
from archetype.reporter import print_results
|
|
14
|
+
from archetype.rule import registry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group()
|
|
18
|
+
def cli() -> None:
|
|
19
|
+
"""Archetype CLI."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@cli.command("check")
|
|
23
|
+
@click.argument(
|
|
24
|
+
"path",
|
|
25
|
+
required=False,
|
|
26
|
+
default=".",
|
|
27
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
28
|
+
)
|
|
29
|
+
def check(path: Path) -> None:
|
|
30
|
+
"""Run architecture rules against a Python project."""
|
|
31
|
+
project_path = path.resolve()
|
|
32
|
+
architecture_file = project_path / "architecture.py"
|
|
33
|
+
|
|
34
|
+
if not architecture_file.is_file():
|
|
35
|
+
click.echo(
|
|
36
|
+
f"Error: architecture.py not found. Looked for: {architecture_file}",
|
|
37
|
+
err=True,
|
|
38
|
+
)
|
|
39
|
+
raise SystemExit(1)
|
|
40
|
+
|
|
41
|
+
registry.clear()
|
|
42
|
+
load_project(project_path)
|
|
43
|
+
|
|
44
|
+
module_name = f"_archetype_user_architecture_{uuid.uuid4().hex}"
|
|
45
|
+
spec = importlib.util.spec_from_file_location(module_name, architecture_file)
|
|
46
|
+
if spec is None or spec.loader is None:
|
|
47
|
+
click.echo(
|
|
48
|
+
f"Error: could not load architecture module from: {architecture_file}",
|
|
49
|
+
err=True,
|
|
50
|
+
)
|
|
51
|
+
raise SystemExit(1)
|
|
52
|
+
|
|
53
|
+
module = importlib.util.module_from_spec(spec)
|
|
54
|
+
original_sys_path = list(sys.path)
|
|
55
|
+
try:
|
|
56
|
+
sys.path.insert(0, str(project_path))
|
|
57
|
+
spec.loader.exec_module(module)
|
|
58
|
+
except Exception as exc: # noqa: BLE001
|
|
59
|
+
click.echo(
|
|
60
|
+
f"Error: failed to import architecture.py from {architecture_file}: {exc}",
|
|
61
|
+
err=True,
|
|
62
|
+
)
|
|
63
|
+
raise SystemExit(1) from exc
|
|
64
|
+
finally:
|
|
65
|
+
sys.path = original_sys_path
|
|
66
|
+
|
|
67
|
+
results = registry.run_all()
|
|
68
|
+
passed = sum(1 for result in results if result.passed)
|
|
69
|
+
print_results(results)
|
|
70
|
+
raise SystemExit(0 if passed == len(results) else 1)
|
archetype/dsl/init.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Domain-specific language components for expressing architecture rules."""
|
archetype/dsl/query.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Query API for selecting modules and asserting dependency constraints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import networkx as nx
|
|
8
|
+
|
|
9
|
+
from archetype.analysis.imports import build_import_graph
|
|
10
|
+
from archetype.analysis.models import Violation
|
|
11
|
+
|
|
12
|
+
_current_graph: nx.DiGraph | None = None
|
|
13
|
+
_current_root: Path | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _matches_pattern(module_name: str, pattern: str) -> bool:
|
|
17
|
+
return module_name == pattern or module_name.startswith(f"{pattern}.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_project(project_root: Path) -> None:
|
|
21
|
+
"""Load a project's import graph into DSL runtime state."""
|
|
22
|
+
global _current_graph, _current_root
|
|
23
|
+
resolved_root = project_root.resolve()
|
|
24
|
+
_current_graph = build_import_graph(resolved_root)
|
|
25
|
+
_current_root = resolved_root
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ImportQuery:
|
|
29
|
+
"""Query object for evaluating import constraints on matched modules."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, pattern: str) -> None:
|
|
32
|
+
self.pattern = pattern
|
|
33
|
+
if _current_graph is None:
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
"No project graph is loaded. Call load_project(path) before using imports()."
|
|
36
|
+
)
|
|
37
|
+
self.graph = _current_graph
|
|
38
|
+
self.matched_nodes = [
|
|
39
|
+
node for node in self.graph.nodes if _matches_pattern(node, pattern)
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
def must_not_import(self, target_pattern: str) -> None:
|
|
43
|
+
"""Assert that matched source modules do not import modules matching target."""
|
|
44
|
+
violations: list[Violation] = []
|
|
45
|
+
|
|
46
|
+
for source in self.matched_nodes:
|
|
47
|
+
for target in self.graph.successors(source):
|
|
48
|
+
if _matches_pattern(target, target_pattern):
|
|
49
|
+
violations.append(
|
|
50
|
+
Violation(
|
|
51
|
+
module=source,
|
|
52
|
+
file=Path("<unknown>"),
|
|
53
|
+
line=0,
|
|
54
|
+
message=(
|
|
55
|
+
f"Module '{source}' must not import '{target_pattern}' "
|
|
56
|
+
f"(found import to '{target}')."
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if violations:
|
|
62
|
+
exc = AssertionError(
|
|
63
|
+
f"Forbidden imports found: {len(violations)} edge(s) from "
|
|
64
|
+
f"'{self.pattern}' to '{target_pattern}'."
|
|
65
|
+
)
|
|
66
|
+
setattr(exc, "violations", violations)
|
|
67
|
+
raise exc
|
|
68
|
+
|
|
69
|
+
def has_no_cycles(self) -> None:
|
|
70
|
+
"""Assert that the matched module set has no directed import cycles."""
|
|
71
|
+
subgraph = self.graph.subgraph(self.matched_nodes)
|
|
72
|
+
try:
|
|
73
|
+
cycle_edges = nx.find_cycle(subgraph, orientation="original")
|
|
74
|
+
except nx.NetworkXNoCycle:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
cycle_nodes = [edge[0] for edge in cycle_edges]
|
|
78
|
+
cycle_nodes.append(cycle_edges[0][0])
|
|
79
|
+
chain = " -> ".join(cycle_nodes)
|
|
80
|
+
raise AssertionError(f"Import cycle detected: {chain}")
|
|
81
|
+
|
|
82
|
+
def must_only_import_from(self, *allowed_patterns: str) -> None:
|
|
83
|
+
"""Assert that matched source modules import only from allowed targets."""
|
|
84
|
+
violations: list[Violation] = []
|
|
85
|
+
|
|
86
|
+
for source in self.matched_nodes:
|
|
87
|
+
for target in self.graph.successors(source):
|
|
88
|
+
if not any(
|
|
89
|
+
_matches_pattern(target, allowed_pattern)
|
|
90
|
+
for allowed_pattern in allowed_patterns
|
|
91
|
+
):
|
|
92
|
+
violations.append(
|
|
93
|
+
Violation(
|
|
94
|
+
module=source,
|
|
95
|
+
file=Path("<unknown>"),
|
|
96
|
+
line=0,
|
|
97
|
+
message=(
|
|
98
|
+
f"Module '{source}' imports '{target}', which is outside "
|
|
99
|
+
f"the allowed set: {allowed_patterns}."
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if violations:
|
|
105
|
+
exc = AssertionError(
|
|
106
|
+
f"Disallowed imports found for '{self.pattern}': {len(violations)} edge(s)."
|
|
107
|
+
)
|
|
108
|
+
setattr(exc, "violations", violations)
|
|
109
|
+
raise exc
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def imports(pattern: str) -> ImportQuery:
|
|
113
|
+
"""Create an import query rooted at the provided module/package pattern."""
|
|
114
|
+
return ImportQuery(pattern)
|
archetype/init.py
ADDED
archetype/plugin/init.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pytest plugin package for integrating Archetype rules into test runs."""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Pytest plugin hooks for auto-discovering and executing architecture rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from archetype.dsl.query import load_project
|
|
12
|
+
from archetype.reporter import format_violation
|
|
13
|
+
from archetype.rule import registry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def pytest_collect_file(file_path: Path, parent: pytest.Collector): # type: ignore[override]
|
|
17
|
+
"""Collect only architecture.py files as Archetype rule containers."""
|
|
18
|
+
if file_path.name == "architecture.py":
|
|
19
|
+
return ArchetypeFile.from_parent(parent, path=file_path)
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ArchetypeFile(pytest.File):
|
|
24
|
+
"""Custom collector for architecture.py files."""
|
|
25
|
+
|
|
26
|
+
def collect(self):
|
|
27
|
+
registry.clear()
|
|
28
|
+
project_root = self.path.parent
|
|
29
|
+
load_project(project_root)
|
|
30
|
+
|
|
31
|
+
module_name = f"_archetype_pytest_architecture_{uuid.uuid4().hex}"
|
|
32
|
+
spec = importlib.util.spec_from_file_location(module_name, self.path)
|
|
33
|
+
if spec is None or spec.loader is None:
|
|
34
|
+
raise RuntimeError(f"Could not load architecture module at {self.path}")
|
|
35
|
+
|
|
36
|
+
module = importlib.util.module_from_spec(spec)
|
|
37
|
+
spec.loader.exec_module(module)
|
|
38
|
+
|
|
39
|
+
for rule_func in registry._rules:
|
|
40
|
+
rule_name = getattr(rule_func, "_rule_name", rule_func.__name__)
|
|
41
|
+
yield ArchetypeItem.from_parent(
|
|
42
|
+
self, name=rule_name, rule_func=rule_func, file_path=self.path
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ArchetypeItem(pytest.Item):
|
|
47
|
+
"""Custom pytest test item wrapping a single architecture rule callable."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
name: str,
|
|
53
|
+
parent: pytest.Collector,
|
|
54
|
+
rule_func,
|
|
55
|
+
file_path: Path,
|
|
56
|
+
) -> None:
|
|
57
|
+
super().__init__(name=name, parent=parent)
|
|
58
|
+
self.rule_func = rule_func
|
|
59
|
+
self.file_path = file_path
|
|
60
|
+
|
|
61
|
+
def runtest(self) -> None:
|
|
62
|
+
self.rule_func()
|
|
63
|
+
|
|
64
|
+
def repr_failure(self, excinfo, style=None): # type: ignore[override]
|
|
65
|
+
err = excinfo.value
|
|
66
|
+
if isinstance(err, AssertionError):
|
|
67
|
+
violations = getattr(err, "violations", None)
|
|
68
|
+
if violations:
|
|
69
|
+
lines = [f"Rule '{self.name}' violations:"]
|
|
70
|
+
lines.extend(f" - {format_violation(violation)}" for violation in violations)
|
|
71
|
+
return "\n".join(lines)
|
|
72
|
+
return super().repr_failure(excinfo, style=style)
|
|
73
|
+
|
|
74
|
+
def reportinfo(self):
|
|
75
|
+
return self.file_path, None, self.name
|
archetype/reporter.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Shared formatting and output utilities for Archetype rule results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from archetype.analysis.models import RuleResult, Violation
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _extract_target(violation: Violation) -> str:
|
|
13
|
+
quoted = re.findall(r"'([^']+)'", violation.message)
|
|
14
|
+
if quoted:
|
|
15
|
+
return quoted[-1]
|
|
16
|
+
return "<unknown>"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def format_violation(violation: Violation) -> str:
|
|
20
|
+
"""Format a violation into a concise, actionable message."""
|
|
21
|
+
target = _extract_target(violation)
|
|
22
|
+
return f"{violation.module} -> {target}: {violation.message}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_results(results: list[RuleResult]) -> str:
|
|
26
|
+
"""Build a complete plain-text report for rule execution results."""
|
|
27
|
+
lines: list[str] = []
|
|
28
|
+
passed = sum(1 for result in results if result.passed)
|
|
29
|
+
failed = len(results) - passed
|
|
30
|
+
|
|
31
|
+
for result in results:
|
|
32
|
+
symbol = "✓" if result.passed else "✗"
|
|
33
|
+
lines.append(f"{symbol} {result.name}")
|
|
34
|
+
if not result.passed:
|
|
35
|
+
for violation in result.violations:
|
|
36
|
+
lines.append(f" - {format_violation(violation)}")
|
|
37
|
+
if result.error is not None:
|
|
38
|
+
lines.append(f" - Rule error: {result.error}")
|
|
39
|
+
|
|
40
|
+
lines.append(
|
|
41
|
+
f"Summary: {passed} passed, {failed} failed, {len(results)} total rules."
|
|
42
|
+
)
|
|
43
|
+
return "\n".join(lines)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def print_results(results: list[RuleResult]) -> None:
|
|
47
|
+
"""Print rule results using rich colors for pass/fail states."""
|
|
48
|
+
console = Console()
|
|
49
|
+
passed = sum(1 for result in results if result.passed)
|
|
50
|
+
failed = len(results) - passed
|
|
51
|
+
|
|
52
|
+
for result in results:
|
|
53
|
+
if result.passed:
|
|
54
|
+
console.print(f"[green]✓ {result.name}[/green]")
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
console.print(f"[red]✗ {result.name}[/red]")
|
|
58
|
+
for violation in result.violations:
|
|
59
|
+
console.print(f"[red] - {format_violation(violation)}[/red]")
|
|
60
|
+
if result.error is not None:
|
|
61
|
+
console.print(f"[red] - Rule error: {result.error}[/red]")
|
|
62
|
+
|
|
63
|
+
summary = f"Summary: {passed} passed, {failed} failed, {len(results)} total rules."
|
|
64
|
+
summary_color = "green" if failed == 0 else "red"
|
|
65
|
+
console.print(f"[{summary_color}]{summary}[/{summary_color}]")
|
archetype/rule.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Rule decorator and rule registration primitives for architecture checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from archetype.analysis.models import RuleResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
RuleFn = Callable[[], None]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RuleRegistry:
|
|
15
|
+
"""In-memory registry for architecture rule callables."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._rules: list[RuleFn] = []
|
|
19
|
+
|
|
20
|
+
def register(self, func: RuleFn) -> None:
|
|
21
|
+
"""Register a rule function."""
|
|
22
|
+
self._rules.append(func)
|
|
23
|
+
|
|
24
|
+
def clear(self) -> None:
|
|
25
|
+
"""Remove all registered rule functions."""
|
|
26
|
+
self._rules.clear()
|
|
27
|
+
|
|
28
|
+
def run_all(self) -> list[RuleResult]:
|
|
29
|
+
"""Execute all registered rules and collect results."""
|
|
30
|
+
results: list[RuleResult] = []
|
|
31
|
+
for func in self._rules:
|
|
32
|
+
rule_name = getattr(func, "_rule_name", func.__name__)
|
|
33
|
+
try:
|
|
34
|
+
func()
|
|
35
|
+
results.append(RuleResult(name=rule_name, passed=True))
|
|
36
|
+
except AssertionError as exc:
|
|
37
|
+
violations = getattr(exc, "violations", [])
|
|
38
|
+
results.append(
|
|
39
|
+
RuleResult(name=rule_name, passed=False, violations=violations)
|
|
40
|
+
)
|
|
41
|
+
except Exception as exc: # noqa: BLE001
|
|
42
|
+
results.append(RuleResult(name=rule_name, passed=False, error=exc))
|
|
43
|
+
return results
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
registry = RuleRegistry()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def rule(name: str) -> Callable[[RuleFn], RuleFn]:
|
|
50
|
+
"""Decorator for registering architecture rules with a display name."""
|
|
51
|
+
|
|
52
|
+
def decorator(func: RuleFn) -> RuleFn:
|
|
53
|
+
@wraps(func)
|
|
54
|
+
def wrapped() -> None:
|
|
55
|
+
return func()
|
|
56
|
+
|
|
57
|
+
setattr(wrapped, "_rule_name", name)
|
|
58
|
+
registry.register(wrapped)
|
|
59
|
+
return wrapped
|
|
60
|
+
|
|
61
|
+
return decorator
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Built-in architecture rules shipped with Archetype."""
|
|
2
|
+
|
|
3
|
+
from archetype.rules.layers import layers
|
|
4
|
+
from archetype.rules.boundaries import module
|
|
5
|
+
from archetype.rules.naming import classes_in, functions_in
|
|
6
|
+
from archetype.rules.cycles import no_cycles
|
|
7
|
+
|
|
8
|
+
__all__ = ["layers", "module", "classes_in", "functions_in", "no_cycles"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Built-in module boundary rule for protecting internal module access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import archetype.dsl.query as query_module
|
|
8
|
+
from archetype.analysis.models import Violation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _matches_pattern(module_name: str, pattern: str) -> bool:
|
|
12
|
+
return module_name == pattern or module_name.startswith(f"{pattern}.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ModuleBoundaryRule:
|
|
16
|
+
"""Rule object for enforcing module import boundaries."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, protected_pattern: str) -> None:
|
|
19
|
+
graph = query_module._current_graph
|
|
20
|
+
if graph is None:
|
|
21
|
+
raise RuntimeError(
|
|
22
|
+
"No project graph is loaded. Call load_project(path) before evaluating module() boundaries."
|
|
23
|
+
)
|
|
24
|
+
self.graph = graph
|
|
25
|
+
self.protected_pattern = protected_pattern
|
|
26
|
+
|
|
27
|
+
def only_imported_within(self, parent_pattern: str) -> None:
|
|
28
|
+
"""Assert protected modules are imported only from inside parent_pattern."""
|
|
29
|
+
violations: list[Violation] = []
|
|
30
|
+
|
|
31
|
+
for source, target in self.graph.edges:
|
|
32
|
+
source_in_parent = _matches_pattern(source, parent_pattern)
|
|
33
|
+
target_is_protected = _matches_pattern(target, self.protected_pattern)
|
|
34
|
+
if not source_in_parent and target_is_protected:
|
|
35
|
+
violations.append(
|
|
36
|
+
Violation(
|
|
37
|
+
module=source,
|
|
38
|
+
file=Path("<unknown>"),
|
|
39
|
+
line=0,
|
|
40
|
+
message=(
|
|
41
|
+
f"Boundary violation: outside module '{source}' imports protected "
|
|
42
|
+
f"module '{target}' (allowed only within '{parent_pattern}')."
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if violations:
|
|
48
|
+
exc = AssertionError(
|
|
49
|
+
f"Module boundary violated by {len(violations)} import(s) into '{self.protected_pattern}'."
|
|
50
|
+
)
|
|
51
|
+
setattr(exc, "violations", violations)
|
|
52
|
+
raise exc
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def module(protected_pattern: str) -> ModuleBoundaryRule:
|
|
56
|
+
"""Create a module boundary rule for a protected module pattern."""
|
|
57
|
+
return ModuleBoundaryRule(protected_pattern)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Built-in rule for detecting circular imports in the project graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import networkx as nx
|
|
8
|
+
|
|
9
|
+
import archetype.dsl.query as query_module
|
|
10
|
+
from archetype.analysis.models import Violation
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _matches_pattern(module_name: str, pattern: str) -> bool:
|
|
14
|
+
return module_name == pattern or module_name.startswith(f"{pattern}.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _normalize_cycle(cycle: list[str]) -> tuple[str, ...]:
|
|
18
|
+
"""Normalize a cycle by rotating to its alphabetically first module."""
|
|
19
|
+
if not cycle:
|
|
20
|
+
return ()
|
|
21
|
+
min_module = min(cycle)
|
|
22
|
+
min_index = cycle.index(min_module)
|
|
23
|
+
rotated = cycle[min_index:] + cycle[:min_index]
|
|
24
|
+
return tuple(rotated)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def no_cycles(module_pattern: str | None = None) -> None:
|
|
28
|
+
"""Assert that no import cycles exist globally or inside a module pattern."""
|
|
29
|
+
graph = query_module._current_graph
|
|
30
|
+
if graph is None:
|
|
31
|
+
raise RuntimeError(
|
|
32
|
+
"No project graph is loaded. Call load_project(path) before evaluating no_cycles()."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if module_pattern is None:
|
|
36
|
+
target_graph = graph
|
|
37
|
+
else:
|
|
38
|
+
matched_nodes = [
|
|
39
|
+
node for node in graph.nodes if _matches_pattern(node, module_pattern)
|
|
40
|
+
]
|
|
41
|
+
target_graph = graph.subgraph(matched_nodes).copy()
|
|
42
|
+
|
|
43
|
+
raw_cycles = list(nx.simple_cycles(target_graph))
|
|
44
|
+
if not raw_cycles:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
seen: set[tuple[str, ...]] = set()
|
|
48
|
+
violations: list[Violation] = []
|
|
49
|
+
|
|
50
|
+
for cycle in raw_cycles:
|
|
51
|
+
normalized = _normalize_cycle(cycle)
|
|
52
|
+
if normalized in seen:
|
|
53
|
+
continue
|
|
54
|
+
seen.add(normalized)
|
|
55
|
+
|
|
56
|
+
chain_nodes = list(normalized) + [normalized[0]]
|
|
57
|
+
chain = " imports ".join(chain_nodes)
|
|
58
|
+
violations.append(
|
|
59
|
+
Violation(
|
|
60
|
+
module=normalized[0],
|
|
61
|
+
file=Path("<unknown>"),
|
|
62
|
+
line=0,
|
|
63
|
+
message=chain,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
exc = AssertionError(f"Detected {len(violations)} circular import cycle(s).")
|
|
68
|
+
setattr(exc, "violations", violations)
|
|
69
|
+
raise exc
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Built-in layering rule for enforcing top-down architectural dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import archetype.dsl.query as query_module
|
|
8
|
+
from archetype.analysis.models import Violation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _matches_pattern(module_name: str, pattern: str) -> bool:
|
|
12
|
+
return module_name == pattern or module_name.startswith(f"{pattern}.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LayerOrderRule:
|
|
16
|
+
"""Rule object that validates import directions across declared layers."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, layer_patterns: list[str]) -> None:
|
|
19
|
+
self.layer_patterns = layer_patterns
|
|
20
|
+
|
|
21
|
+
def are_ordered(self) -> None:
|
|
22
|
+
"""Assert that lower layers do not import upper layers."""
|
|
23
|
+
graph = query_module._current_graph
|
|
24
|
+
if graph is None:
|
|
25
|
+
raise RuntimeError(
|
|
26
|
+
"No project graph is loaded. Call load_project(path) before evaluating layers()."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
violations: list[Violation] = []
|
|
30
|
+
for upper_index, upper_pattern in enumerate(self.layer_patterns):
|
|
31
|
+
for lower_pattern in self.layer_patterns[upper_index + 1 :]:
|
|
32
|
+
lower_nodes = [
|
|
33
|
+
node
|
|
34
|
+
for node in graph.nodes
|
|
35
|
+
if _matches_pattern(node, lower_pattern)
|
|
36
|
+
]
|
|
37
|
+
upper_nodes = {
|
|
38
|
+
node
|
|
39
|
+
for node in graph.nodes
|
|
40
|
+
if _matches_pattern(node, upper_pattern)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for source in lower_nodes:
|
|
44
|
+
for target in graph.successors(source):
|
|
45
|
+
if target in upper_nodes:
|
|
46
|
+
violations.append(
|
|
47
|
+
Violation(
|
|
48
|
+
module=source,
|
|
49
|
+
file=Path("<unknown>"),
|
|
50
|
+
line=0,
|
|
51
|
+
message=(
|
|
52
|
+
f"Layering violation (upward dependency): lower layer "
|
|
53
|
+
f"'{lower_pattern}' module '{source}' imports upper layer "
|
|
54
|
+
f"'{upper_pattern}' module '{target}'."
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if violations:
|
|
60
|
+
exc = AssertionError(
|
|
61
|
+
f"Layer ordering violated by {len(violations)} upward import(s)."
|
|
62
|
+
)
|
|
63
|
+
setattr(exc, "violations", violations)
|
|
64
|
+
raise exc
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def layers(layer_patterns: list[str]) -> LayerOrderRule:
|
|
68
|
+
"""Create a layer-order rule for modules listed top-to-bottom."""
|
|
69
|
+
return LayerOrderRule(layer_patterns)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Built-in naming convention rules based on static AST inspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import archetype.dsl.query as query_module
|
|
10
|
+
from archetype.analysis.ast_utils import (
|
|
11
|
+
get_class_names,
|
|
12
|
+
get_top_level_function_names,
|
|
13
|
+
)
|
|
14
|
+
from archetype.analysis.imports import path_to_module
|
|
15
|
+
from archetype.analysis.models import Violation
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _matches_pattern(module_name: str, pattern: str) -> bool:
|
|
19
|
+
return module_name == pattern or module_name.startswith(f"{pattern}.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _matched_python_files(module_pattern: str) -> list[Path]:
|
|
23
|
+
root = query_module._current_root
|
|
24
|
+
if root is None:
|
|
25
|
+
raise RuntimeError(
|
|
26
|
+
"No project root is loaded. Call load_project(path) before evaluating naming rules."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
matched: list[Path] = []
|
|
30
|
+
for file_path in sorted(root.rglob("*.py")):
|
|
31
|
+
module_name = path_to_module(file_path, root)
|
|
32
|
+
if module_name and _matches_pattern(module_name, module_pattern):
|
|
33
|
+
matched.append(file_path)
|
|
34
|
+
return matched
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ClassesInQuery:
|
|
38
|
+
"""Naming query for class definitions in matched modules."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, module_pattern: str) -> None:
|
|
41
|
+
self.module_pattern = module_pattern
|
|
42
|
+
self.files = _matched_python_files(module_pattern)
|
|
43
|
+
|
|
44
|
+
def all_match(self, name_pattern: str) -> None:
|
|
45
|
+
"""Assert all class names in matched files satisfy a regex."""
|
|
46
|
+
violations: list[Violation] = []
|
|
47
|
+
regex = re.compile(name_pattern)
|
|
48
|
+
|
|
49
|
+
for file_path in self.files:
|
|
50
|
+
tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
|
|
51
|
+
for class_name in get_class_names(tree):
|
|
52
|
+
if not regex.fullmatch(class_name):
|
|
53
|
+
violations.append(
|
|
54
|
+
Violation(
|
|
55
|
+
module=self.module_pattern,
|
|
56
|
+
file=file_path,
|
|
57
|
+
line=0,
|
|
58
|
+
message=(
|
|
59
|
+
f"Class '{class_name}' in '{file_path}' does not match "
|
|
60
|
+
f"required pattern '{name_pattern}'."
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if violations:
|
|
66
|
+
exc = AssertionError(
|
|
67
|
+
f"Naming rule failed: {len(violations)} class name violation(s)."
|
|
68
|
+
)
|
|
69
|
+
setattr(exc, "violations", violations)
|
|
70
|
+
raise exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class FunctionsInQuery:
|
|
74
|
+
"""Naming query for required module-level function presence."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, module_pattern: str) -> None:
|
|
77
|
+
self.module_pattern = module_pattern
|
|
78
|
+
self.files = _matched_python_files(module_pattern)
|
|
79
|
+
|
|
80
|
+
def must_include(self, function_name: str) -> None:
|
|
81
|
+
"""Assert each matched file defines the required top-level function."""
|
|
82
|
+
violations: list[Violation] = []
|
|
83
|
+
|
|
84
|
+
for file_path in self.files:
|
|
85
|
+
tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
|
|
86
|
+
top_level_functions = get_top_level_function_names(tree)
|
|
87
|
+
if function_name not in top_level_functions:
|
|
88
|
+
violations.append(
|
|
89
|
+
Violation(
|
|
90
|
+
module=self.module_pattern,
|
|
91
|
+
file=file_path,
|
|
92
|
+
line=0,
|
|
93
|
+
message=(
|
|
94
|
+
f"File '{file_path}' is missing required top-level function "
|
|
95
|
+
f"'{function_name}'."
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if violations:
|
|
101
|
+
exc = AssertionError(
|
|
102
|
+
f"Naming rule failed: required function '{function_name}' missing in "
|
|
103
|
+
f"{len(violations)} file(s)."
|
|
104
|
+
)
|
|
105
|
+
setattr(exc, "violations", violations)
|
|
106
|
+
raise exc
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def classes_in(module_pattern: str) -> ClassesInQuery:
|
|
110
|
+
"""Create a class-naming query for modules matching module_pattern."""
|
|
111
|
+
return ClassesInQuery(module_pattern)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def functions_in(module_pattern: str) -> FunctionsInQuery:
|
|
115
|
+
"""Create a function-presence query for modules matching module_pattern."""
|
|
116
|
+
return FunctionsInQuery(module_pattern)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: archetype-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Archetype statically analyzes Python projects to enforce architectural rules as code.
|
|
5
|
+
Project-URL: Homepage, https://github.com/your-org/your-repo
|
|
6
|
+
Project-URL: Documentation, https://github.com/your-org/your-repo
|
|
7
|
+
Author-email: Mossab Arektout <mossabarektout2000@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: click
|
|
17
|
+
Requires-Dist: networkx
|
|
18
|
+
Requires-Dist: rich
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: hatch; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
   
|
|
25
|
+
|
|
26
|
+
## Architectural rules should not live in people’s heads
|
|
27
|
+
Architectural rules usually exist in engineers’ heads but nowhere in the codebase.
|
|
28
|
+
Archetype turns those rules into executable Python checks that run in `archetype check` and `pytest`.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
# architecture.py
|
|
32
|
+
from archetype import imports, rule
|
|
33
|
+
|
|
34
|
+
@rule("api does not import db")
|
|
35
|
+
def api_not_db() -> None:
|
|
36
|
+
imports("myapp.api").must_not_import("myapp.db")
|
|
37
|
+
|
|
38
|
+
@rule("services only import db")
|
|
39
|
+
def services_only_db() -> None:
|
|
40
|
+
imports("myapp.services").must_only_import_from("myapp.db")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
$ archetype check .
|
|
45
|
+
✓ api does not import db
|
|
46
|
+
✗ services only import db
|
|
47
|
+
- myapp.services.user -> myapp.cache: Module 'myapp.services.user' imports 'myapp.cache', which is outside the allowed set: ('myapp.db',).
|
|
48
|
+
Summary: 1 passed, 1 failed, 2 total rules.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
```bash
|
|
53
|
+
pip install archetype-py
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quickstart
|
|
57
|
+
1. Install Archetype.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install archetype-py
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
2. Create `architecture.py` in your project root.
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
touch architecture.py
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
3. Add your first rule with the imports DSL.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
# architecture.py
|
|
73
|
+
from archetype import imports, rule
|
|
74
|
+
|
|
75
|
+
@rule("api does not import db")
|
|
76
|
+
def api_not_db() -> None:
|
|
77
|
+
imports("myapp.api").must_not_import("myapp.db")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
4. Run the checker.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
archetype check .
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
5. Read the output and fix violations.
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
✓ api does not import db
|
|
90
|
+
Summary: 1 passed, 0 failed, 1 total rules.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
If your project already runs `pytest`, running `pytest` is sufficient because Archetype rules are collected and executed by the pytest plugin.
|
|
94
|
+
|
|
95
|
+
## Why Archetype exists
|
|
96
|
+
Style tools enforce how code looks. Type tools enforce what values can flow through code. Architectural tools enforce which parts of the system are allowed to depend on which other parts.
|
|
97
|
+
|
|
98
|
+
Pylint and similar linters are strong at local code quality checks, and Mypy is strong at static type correctness. Neither is designed to express team-level dependency contracts like “API cannot import DB” or “internal modules are private outside their package boundary.”
|
|
99
|
+
|
|
100
|
+
Archetype keeps rules in `architecture.py` as normal Python functions, not static YAML declarations. That makes rules executable, reviewable, testable, and easy to evolve with the codebase using the same language and tooling your team already uses.
|
|
101
|
+
|
|
102
|
+
## Built-in rules reference
|
|
103
|
+
### `layers`
|
|
104
|
+
Enforces that lower layers do not import upper layers.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from archetype import rule
|
|
108
|
+
from archetype.rules import layers
|
|
109
|
+
|
|
110
|
+
@rule("layers are ordered")
|
|
111
|
+
def layer_order() -> None:
|
|
112
|
+
layers(["myapp.api", "myapp.services", "myapp.db"]).are_ordered()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### `module` (module boundaries)
|
|
116
|
+
Enforces that a protected internal module is only imported from an allowed parent scope.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from archetype import rule
|
|
120
|
+
from archetype.rules import module
|
|
121
|
+
|
|
122
|
+
@rule("internal auth is private")
|
|
123
|
+
def auth_boundary() -> None:
|
|
124
|
+
module("myapp.auth.internal").only_imported_within("myapp.auth")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### `classes_in` and `functions_in` (naming conventions)
|
|
128
|
+
Enforces class naming patterns and required top-level functions in matched modules.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from archetype import rule
|
|
132
|
+
from archetype.rules import classes_in, functions_in
|
|
133
|
+
|
|
134
|
+
@rule("service classes end with Service")
|
|
135
|
+
def class_names() -> None:
|
|
136
|
+
classes_in("myapp.services").all_match(r".*Service$")
|
|
137
|
+
|
|
138
|
+
@rule("api modules expose handle")
|
|
139
|
+
def api_handle_exists() -> None:
|
|
140
|
+
functions_in("myapp.api").must_include("handle")
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### `no_cycles`
|
|
144
|
+
Enforces that there are no import cycles in the whole project or in a selected module scope.
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from archetype import rule
|
|
148
|
+
from archetype.rules import no_cycles
|
|
149
|
+
|
|
150
|
+
@rule("no cycles in services")
|
|
151
|
+
def services_no_cycles() -> None:
|
|
152
|
+
no_cycles("myapp.services")
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Writing custom rules
|
|
156
|
+
```python
|
|
157
|
+
from archetype import imports, rule
|
|
158
|
+
|
|
159
|
+
@rule("custom architecture policy")
|
|
160
|
+
def custom_policy() -> None:
|
|
161
|
+
imports("myapp.api").must_not_import("myapp.db")
|
|
162
|
+
imports("myapp.services").has_no_cycles()
|
|
163
|
+
imports("myapp.services").must_only_import_from("myapp.db", "myapp.shared")
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Any Python function decorated with `@rule` that returns without raising is a passing rule. Rules can use the full Python language, so you can encode architecture constraints that do not fit generic linters or static config.
|
|
167
|
+
|
|
168
|
+
## CI integration
|
|
169
|
+
```yaml
|
|
170
|
+
name: Archetype Check
|
|
171
|
+
|
|
172
|
+
on:
|
|
173
|
+
push:
|
|
174
|
+
branches: [main]
|
|
175
|
+
pull_request:
|
|
176
|
+
|
|
177
|
+
jobs:
|
|
178
|
+
archetype:
|
|
179
|
+
runs-on: ubuntu-latest
|
|
180
|
+
steps:
|
|
181
|
+
- uses: actions/checkout@v4
|
|
182
|
+
- uses: actions/setup-python@v5
|
|
183
|
+
with:
|
|
184
|
+
python-version: "3.11"
|
|
185
|
+
- run: |
|
|
186
|
+
python -m pip install --upgrade pip
|
|
187
|
+
pip install archetype-py
|
|
188
|
+
- run: archetype check .
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
If your CI already runs `pytest`, no additional CI configuration is required.
|
|
192
|
+
|
|
193
|
+
## Contributing
|
|
194
|
+
Source code and issue tracking are in the GitHub repository: `https://github.com/your-org/your-repo`.
|
|
195
|
+
Contributions are welcome; open an issue first to discuss scope before submitting a pull request.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
archetype/__init__.py,sha256=hdeNvlViCB5xKoNfvr_LYy1xhRFwWbhIfwe3Ib3CLi8,89
|
|
2
|
+
archetype/check.py,sha256=aqTYaYdLkQ5XJvwfxTB-ZsxyzDx7Tp3FPYVNK6vkCpc,1983
|
|
3
|
+
archetype/init.py,sha256=0Wu_DzyHEcIzL_oHVshHI_OwX60TYTBBHja5t_vrq9U,248
|
|
4
|
+
archetype/reporter.py,sha256=OsAiAuPWrkHm_b0cxYsnUpMHOMyc6nvntA6Mw25BS3w,2248
|
|
5
|
+
archetype/rule.py,sha256=tC8FiT44LotPykcx45FnBm4cySZjsrvesFWI8MLhRnQ,1798
|
|
6
|
+
archetype/analysis/__init__.py,sha256=r45MIo4x7I73HH34o4lR_mqYHXhz_RvQc1rGAoUNKBQ,95
|
|
7
|
+
archetype/analysis/ast_utils.py,sha256=PAwj7NbcgV3VImY33MyPWmSuOGUppvop7yPHttzAw-8,605
|
|
8
|
+
archetype/analysis/imports.py,sha256=OhzzaCFgx-ZHN4kAZx2LrvJWO_QYvV0XjyITpQxiBD4,3711
|
|
9
|
+
archetype/analysis/init.py,sha256=vecxj2nNQfqgrcxup1ULS_Hk7-qbjONd0HBq19p-ld8,86
|
|
10
|
+
archetype/analysis/models.py,sha256=tHQnZ5ioIkO_3ZpChnGuQfhLA03bAqDM3fe4Yd1O7Hc,562
|
|
11
|
+
archetype/dsl/__init__.py,sha256=4Q5io9-AHo0BZKPCsObe__Dnjo3F2000-sZNgnwqwl8,85
|
|
12
|
+
archetype/dsl/init.py,sha256=sKS0sR0cGWG-fk4DCqucwPJw_h6bGcQnWlfrQajDzbA,77
|
|
13
|
+
archetype/dsl/query.py,sha256=vi6speYWSML_n8hinU3r0lM3mq2H3bOItt0sM1mgrAA,4252
|
|
14
|
+
archetype/plugin/__init__.py,sha256=CYj1c8Kpg73hdNXLSziC0PvuqakbAi4VplMUeI8KNV4,91
|
|
15
|
+
archetype/plugin/init.py,sha256=Dv0sc34LZCGTLfucTesFjJklNMFTGrFYrw93voj92Xg,76
|
|
16
|
+
archetype/plugin/pytest_plugin.py,sha256=fKwNBZ2U9AHACMho4ax5odAiE06slQ5xzdTmwi0Ax50,2483
|
|
17
|
+
archetype/rules/__init__.py,sha256=UC3mqs6cWqXt7bWGrQWqC9-6S6AByj9y5XQOolezZOk,327
|
|
18
|
+
archetype/rules/boundaries.py,sha256=fxXy34bCKpkz4k_JVjFwjsLxK2t2_MU0yziO87Yo2JU,2182
|
|
19
|
+
archetype/rules/cycles.py,sha256=RWvBlQtYcTZhz2it0vwr-BUtodnyLKAkc3anyMDJ6EI,2074
|
|
20
|
+
archetype/rules/layers.py,sha256=fktxE3VWyrP8ZrJIKchV7K8ATNCP3-oerwVnQmtF3nE,2669
|
|
21
|
+
archetype/rules/naming.py,sha256=dqusL4mUN58we8c4vGJ4Qp6DdXepO7h6fNTunVEG1KY,4218
|
|
22
|
+
archetype_py-0.1.0.dist-info/METADATA,sha256=JmlJKAbtRNRXuyiY5UnFtr51ePADvr_a_eX5NBFP_p8,6109
|
|
23
|
+
archetype_py-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
24
|
+
archetype_py-0.1.0.dist-info/entry_points.txt,sha256=HQNYKeWAHr8jm8GOJSMx5vyEJCKsSqZ8CT-c7VShHp4,105
|
|
25
|
+
archetype_py-0.1.0.dist-info/licenses/LICENSE,sha256=OG7RJA70iDz95BX5vw32rRMwm3ms3Jy3V6wxoC2zAbM,1072
|
|
26
|
+
archetype_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [YEAR] [AUTHOR NAME]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|