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 ADDED
@@ -0,0 +1,3 @@
1
+ """Archetype public package exports."""
2
+
3
+ from archetype.init import * # noqa: F401,F403
@@ -0,0 +1,3 @@
1
+ """Archetype analysis subpackage."""
2
+
3
+ from archetype.analysis.init import * # noqa: F401,F403
@@ -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)
@@ -0,0 +1,3 @@
1
+ """Archetype DSL subpackage."""
2
+
3
+ from archetype.dsl.init import * # noqa: F401,F403
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
@@ -0,0 +1,8 @@
1
+ """Top-level package metadata and public exports for Archetype."""
2
+
3
+ from archetype.dsl.query import imports, load_project
4
+ from archetype.rule import registry, rule
5
+
6
+ module = None
7
+
8
+ __all__ = ["rule", "registry", "imports", "load_project", "module"]
@@ -0,0 +1,3 @@
1
+ """Archetype plugin subpackage."""
2
+
3
+ from archetype.plugin.init import * # noqa: F401,F403
@@ -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
+ ![PyPI](https://img.shields.io/pypi/v/archetype) ![Python](https://img.shields.io/pypi/pyversions/archetype) ![License](https://img.shields.io/badge/license-MIT-green) ![CI](https://img.shields.io/github/actions/workflow/status/your-org/your-repo/ci.yml?branch=main)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ archetype = archetype.check:cli
3
+
4
+ [pytest11]
5
+ archetype = archetype.plugin.pytest_plugin
@@ -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.