Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ This backlog collects product and maintenance ideas from project research.

## P1 - Adoption Workflow

- Add an `.archignore` or similar file, modeled after `.gitignore`, for files that should never be analyzed.
- Add a `.because(...)` API so rules can carry user-facing rationale into failure messages and generated architecture documentation.
- [x] Add an `.archignore` or similar file, modeled after `.gitignore`, for files that should never be analyzed.
- [x] Add a `.because(...)` API so rules can carry user-facing rationale into failure messages and generated architecture documentation.
- Add configuration-file support for common rules, while keeping the fluent Python API as the primary interface.
- Add support for monorepo and multi-package Python projects.

Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,44 @@ options = CheckOptions(
violations = rule.check(options)
```

### Excluding Files With `.archignore`

Add a `.archignore` file to your project root to permanently exclude generated or
irrelevant files from architecture checks and file-based metrics:

Comment on lines +140 to +144
```gitignore
# Generated code
generated/

# Migration scripts
migrations/*.py

# A single root-level file
/legacy_adapter.py
```

Patterns support comments, blank lines, glob syntax, root-relative paths, path
patterns, and directory patterns with a trailing `/`.

### Explaining Rules With `.because(...)`

Attach a rationale to a rule so failing assertions explain why the rule exists:

```python
rule = (
project_files("src/")
.in_folder("**/controllers/**")
.should_not()
.depend_on_files()
.in_folder("**/database/**")
.because("controllers should stay thin and delegate persistence")
)

assert_passes(rule)
```

When the rule fails, the rationale is included in the assertion message.

## 🐹 Use Cases

Here is an overview of common use cases.
Expand Down
2 changes: 1 addition & 1 deletion scripts/check_release_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def read_project_version() -> str:
content = PYPROJECT.read_text(encoding="utf-8")
match = re.search(r'^version = "([^"]+)"$', content, re.MULTILINE)
if match is None:
raise RuntimeError("Could not find project.version in pyproject.toml")
raise RuntimeError("Could not find [project].version in pyproject.toml")
return match.group(1)


Expand Down
7 changes: 6 additions & 1 deletion src/archunitpython/common/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from archunitpython.common.assertion.violation import EmptyTestViolation, Violation
from archunitpython.common.error.errors import TechnicalError, UserError
from archunitpython.common.fluentapi.checkable import Checkable, CheckOptions
from archunitpython.common.fluentapi.checkable import (
Checkable,
CheckOptions,
RuleRationaleMixin,
)
from archunitpython.common.logging.types import LoggingOptions
from archunitpython.common.types import Filter, Pattern, PatternMatchingOptions

Expand All @@ -11,6 +15,7 @@
"UserError",
"Checkable",
"CheckOptions",
"RuleRationaleMixin",
"LoggingOptions",
"Pattern",
"Filter",
Expand Down
89 changes: 77 additions & 12 deletions src/archunitpython/common/extraction/extract_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
"*.egg-info",
]

_ARCHIGNORE_FILE = ".archignore"


def clear_graph_cache(options: CheckOptions | None = None) -> None:
"""Clear the cached dependency graphs."""
Expand Down Expand Up @@ -58,7 +60,7 @@ def extract_graph(
project_path = os.getcwd()

project_path = os.path.abspath(project_path)
excludes = list(exclude_patterns) if exclude_patterns is not None else list(_DEFAULT_EXCLUDE)
excludes = _resolve_exclude_patterns(project_path, exclude_patterns)
ignore_type_checking_imports = bool(
options and options.ignore_type_checking_imports
)
Expand Down Expand Up @@ -94,6 +96,34 @@ def _build_cache_key(
)


def _resolve_exclude_patterns(
project_path: str,
exclude_patterns: list[str] | None,
) -> list[str]:
"""Resolve exclude patterns (explicit or defaults) plus any .archignore patterns."""
excludes = list(exclude_patterns) if exclude_patterns is not None else list(_DEFAULT_EXCLUDE)
excludes.extend(_load_archignore_patterns(project_path))
return excludes


def _load_archignore_patterns(project_path: str) -> list[str]:
"""Load .archignore patterns from a project root, if present."""
archignore_path = os.path.join(project_path, _ARCHIGNORE_FILE)
try:
with open(archignore_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
except OSError:
return []

patterns: list[str] = []
for line in lines:
pattern = line.strip()
if not pattern or pattern.startswith("#"):
continue
patterns.append(pattern)
return patterns


def _extract_graph_uncached(
project_path: str,
exclude_patterns: list[str],
Expand All @@ -105,6 +135,7 @@ def _extract_graph_uncached(

edges: list[Edge] = []
py_files_set = set(py_files)
normalized_py_files = {_normalize(f) for f in py_files_set}

for file_path in py_files:
# Add self-referencing edge (ensures the file appears as a node)
Expand All @@ -129,10 +160,8 @@ def _extract_graph_uncached(
)
if resolved and resolved != _normalize(file_path):
# Check if the resolved path is in our project
if not is_external and resolved not in {
_normalize(f) for f in py_files_set
}:
is_external = True
if not is_external and resolved not in normalized_py_files:
continue

edges.append(
Edge(
Expand All @@ -154,29 +183,65 @@ def _normalize(path: str) -> str:
def _find_python_files(root: str, exclude: list[str]) -> list[str]:
"""Recursively find all .py files, excluding specified patterns."""
py_files: list[str] = []
root = os.path.abspath(root)
for dirpath, dirnames, filenames in os.walk(root):
# Filter out excluded directories in-place
dirnames[:] = [
d
for d in dirnames
if not _should_exclude(d, exclude)
if not _should_exclude_path(os.path.join(dirpath, d), root, exclude, is_dir=True)
]

for filename in filenames:
if filename.endswith(".py") and not _should_exclude(filename, exclude):
full_path = os.path.join(dirpath, filename)
full_path = os.path.join(dirpath, filename)
if filename.endswith(".py") and not _should_exclude_path(
full_path, root, exclude, is_dir=False
):
py_files.append(os.path.abspath(full_path))

return py_files


def _should_exclude(name: str, patterns: list[str]) -> bool:
"""Check if a name matches any exclude pattern."""
def _should_exclude_path(
path: str,
root: str,
patterns: list[str],
*,
is_dir: bool,
) -> bool:
"""Check if a path matches any exclude pattern."""
import fnmatch

for pattern in patterns:
if fnmatch.fnmatch(name, pattern):
rel_path = _normalize(os.path.relpath(path, root))
name = os.path.basename(path)

for raw_pattern in patterns:
pattern = raw_pattern.strip().replace("\\", "/")
if not pattern or pattern.startswith("#"):
continue

pattern = pattern.removeprefix("./")
anchored = pattern.startswith("/")
if anchored:
pattern = pattern[1:]

dir_only = pattern.endswith("/")
if dir_only:
pattern = pattern.rstrip("/")
if not is_dir:
continue

if not pattern:
continue

if "/" in pattern or anchored:
if fnmatch.fnmatch(rel_path, pattern):
return True
if is_dir and rel_path == pattern:
return True
elif fnmatch.fnmatch(name, pattern):
return True

return False


Expand Down
8 changes: 6 additions & 2 deletions src/archunitpython/common/fluentapi/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
from archunitpython.common.fluentapi.checkable import Checkable, CheckOptions
from archunitpython.common.fluentapi.checkable import (
Checkable,
CheckOptions,
RuleRationaleMixin,
)

__all__ = ["Checkable", "CheckOptions"]
__all__ = ["Checkable", "CheckOptions", "RuleRationaleMixin"]
24 changes: 23 additions & 1 deletion src/archunitpython/common/fluentapi/checkable.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Protocol
from typing import Protocol, TypeVar

from archunitpython.common.assertion.violation import Violation
from archunitpython.common.logging.types import LoggingOptions
Expand All @@ -19,6 +19,28 @@ class CheckOptions:
ignore_type_checking_imports: bool = False


T = TypeVar("T", bound="RuleRationaleMixin")


class RuleRationaleMixin:
"""Mixin for checkable rules that can carry a human-readable rationale."""

_because_reason: str | None = None

def because(self: T, reason: str) -> T:
"""Attach a rationale explaining why the rule exists."""
reason = reason.strip()
if not reason:
raise ValueError("Rule rationale must not be empty.")
self._because_reason = reason
return self

@property
def because_reason(self) -> str | None:
"""Return the rationale attached with because(), if any."""
return self._because_reason


class Checkable(Protocol):
"""Protocol for any architecture rule that can be checked.

Expand Down
12 changes: 6 additions & 6 deletions src/archunitpython/files/fluentapi/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from archunitpython.common.assertion.violation import EmptyTestViolation, Violation
from archunitpython.common.extraction.extract_graph import extract_graph
from archunitpython.common.fluentapi.checkable import CheckOptions
from archunitpython.common.fluentapi.checkable import CheckOptions, RuleRationaleMixin
from archunitpython.common.pattern_matching import matches_all_patterns
from archunitpython.common.projection.edge_projections import (
per_external_edge,
Expand Down Expand Up @@ -338,7 +338,7 @@ def _check_empty_test(
return None


class CycleFreeFileCondition:
class CycleFreeFileCondition(RuleRationaleMixin):
"""Checkable that verifies no cycles exist among filtered files."""

def __init__(self, project_path: str | None, filters: list[Filter]) -> None:
Expand Down Expand Up @@ -366,7 +366,7 @@ def check(self, options: CheckOptions | None = None) -> list[Violation]:
return gather_cycle_violations(cycles)


class DependOnFileCondition:
class DependOnFileCondition(RuleRationaleMixin):
"""Checkable that verifies file dependency rules."""

def __init__(
Expand All @@ -393,7 +393,7 @@ def check(self, options: CheckOptions | None = None) -> list[Violation]:
)


class DependOnExternalModuleCondition:
class DependOnExternalModuleCondition(RuleRationaleMixin):
"""Checkable that verifies external module dependency rules."""

def __init__(
Expand Down Expand Up @@ -425,7 +425,7 @@ def check(self, options: CheckOptions | None = None) -> list[Violation]:
)


class MatchPatternFileCondition:
class MatchPatternFileCondition(RuleRationaleMixin):
"""Checkable that verifies files match/don't match patterns."""

def __init__(
Expand All @@ -452,7 +452,7 @@ def check(self, options: CheckOptions | None = None) -> list[Violation]:
)


class CustomFileCheckableCondition:
class CustomFileCheckableCondition(RuleRationaleMixin):
"""Checkable that evaluates a custom condition on files."""

def __init__(
Expand Down
9 changes: 6 additions & 3 deletions src/archunitpython/metrics/extraction/extract_class_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
import ast
import os

from archunitpython.common.extraction.extract_graph import _DEFAULT_EXCLUDE, _find_python_files
from archunitpython.common.extraction.extract_graph import (
_find_python_files,
_resolve_exclude_patterns,
)
from archunitpython.metrics.common.types import (
ClassInfo,
EnhancedClassInfo,
Expand Down Expand Up @@ -33,7 +36,7 @@ def extract_class_info(
project_path = os.getcwd()

project_path = os.path.abspath(project_path)
excludes = exclude_patterns if exclude_patterns is not None else _DEFAULT_EXCLUDE
excludes = _resolve_exclude_patterns(project_path, exclude_patterns)
py_files = _find_python_files(project_path, excludes)

classes: list[ClassInfo] = []
Expand All @@ -53,7 +56,7 @@ def extract_enhanced_class_info(
project_path = os.getcwd()

project_path = os.path.abspath(project_path)
excludes = exclude_patterns if exclude_patterns is not None else _DEFAULT_EXCLUDE
excludes = _resolve_exclude_patterns(project_path, exclude_patterns)
py_files = _find_python_files(project_path, excludes)

results: list[FileAnalysisResult] = []
Expand Down
Loading