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
2 changes: 1 addition & 1 deletion BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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 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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,25 @@ options = CheckOptions(
violations = rule.check(options)
```

### 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
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
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 @@ -322,7 +322,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 @@ -350,7 +350,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 @@ -377,7 +377,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 @@ -409,7 +409,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 @@ -434,7 +434,7 @@ def check(self, options: CheckOptions | None = None) -> list[Violation]:
return gather_regex_matching_violations(nodes, self._check_filters, self._is_negated)


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

def __init__(
Expand Down
14 changes: 7 additions & 7 deletions src/archunitpython/metrics/fluentapi/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from typing import Any, Callable

from archunitpython.common.assertion.violation import Violation
from archunitpython.common.fluentapi.checkable import CheckOptions
from archunitpython.common.fluentapi.checkable import CheckOptions, RuleRationaleMixin
from archunitpython.common.pattern_matching import matches_pattern_classname
from archunitpython.common.regex_factory import RegexFactory
from archunitpython.common.types import Filter, Pattern
Expand Down Expand Up @@ -170,7 +170,7 @@ def should_be_above_or_equal(self, threshold: float) -> "ClassMetricCondition":
)


class ClassMetricCondition:
class ClassMetricCondition(RuleRationaleMixin):
"""Checkable that verifies a class-level metric threshold."""

def __init__(
Expand Down Expand Up @@ -230,7 +230,7 @@ def should_be_below_or_equal(self, threshold: float) -> "FileMetricCondition":
)


class FileMetricCondition:
class FileMetricCondition(RuleRationaleMixin):
"""Checkable that verifies a file-level metric threshold."""

def __init__(
Expand Down Expand Up @@ -364,7 +364,7 @@ def should_be_above(self, threshold: float) -> "DistanceCondition":
)


class DistanceCondition:
class DistanceCondition(RuleRationaleMixin):
"""Checkable for distance metric thresholds."""

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


class ZoneCondition:
class ZoneCondition(RuleRationaleMixin):
"""Checkable for zone detection (pain/uselessness)."""

def __init__(self, project_path: str | None, filters: list[Filter], zone_type: str) -> None:
Expand Down Expand Up @@ -485,7 +485,7 @@ def should_satisfy(
)


class CustomMetricCondition:
class CustomMetricCondition(RuleRationaleMixin):
"""Checkable for custom metric thresholds."""

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


class CustomAssertionCondition:
class CustomAssertionCondition(RuleRationaleMixin):
"""Checkable for custom metric assertions."""

def __init__(
Expand Down
6 changes: 3 additions & 3 deletions src/archunitpython/slices/fluentapi/slices.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from archunitpython.common.assertion.violation import 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.projection.project_edges import project_edges
from archunitpython.common.projection.types import MapFunction
from archunitpython.slices.assertion.admissible_edges import (
Expand Down Expand Up @@ -140,7 +140,7 @@ def contain_dependency(self, source: str, target: str) -> "NegativeSliceConditio
)


class PositiveSliceCondition:
class PositiveSliceCondition(RuleRationaleMixin):
"""Checkable that verifies slices adhere to a diagram."""

def __init__(
Expand Down Expand Up @@ -176,7 +176,7 @@ def _get_mapper(self) -> MapFunction:
return identity()


class NegativeSliceCondition:
class NegativeSliceCondition(RuleRationaleMixin):
"""Checkable that verifies a specific dependency does NOT exist."""

def __init__(
Expand Down
14 changes: 11 additions & 3 deletions src/archunitpython/testing/assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
from archunitpython.testing.common.violation_factory import ViolationFactory


def format_violations(violations: list[Violation]) -> str:
def format_violations(
violations: list[Violation],
*,
because: str | None = None,
) -> str:
"""Format violations into a human-readable string.

Args:
Expand All @@ -19,7 +23,10 @@ def format_violations(violations: list[Violation]) -> str:
if not violations:
return "No violations found."

lines = [f"Found {len(violations)} architecture violation(s):", ""]
lines = [f"Found {len(violations)} architecture violation(s):"]
if because:
lines.extend(["", f"Because: {because}"])
lines.append("")
for i, violation in enumerate(violations, 1):
tv = ViolationFactory.from_violation(violation)
lines.append(f" {i}. {tv.message}")
Expand All @@ -44,4 +51,5 @@ def assert_passes(
"""
violations = checkable.check(options)
if violations:
raise AssertionError(format_violations(violations))
because = getattr(checkable, "because_reason", None)
raise AssertionError(format_violations(violations, because=because))
19 changes: 19 additions & 0 deletions tests/files/test_files_fluentapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from pathlib import Path
from uuid import uuid4

import pytest

from archunitpython.common.assertion.violation import EmptyTestViolation
from archunitpython.common.extraction.extract_graph import clear_graph_cache
from archunitpython.files.assertion.custom_file_logic import CustomFileViolation
Expand Down Expand Up @@ -223,6 +225,23 @@ def test_builder_with_multiple_filters(self):
cycle_violations = [v for v in violations if isinstance(v, ViolatingCycle)]
assert len(cycle_violations) == 0

def test_because_adds_rule_rationale(self):
rule = (
project_files(FIXTURES_DIR)
.in_folder("**/services*")
.should()
.have_no_cycles()
.because("service cycles are hard to refactor")
)

assert rule.because_reason == "service cycles are hard to refactor"

def test_because_rejects_empty_rationale(self):
rule = project_files(FIXTURES_DIR).should().have_no_cycles()

with pytest.raises(ValueError, match="must not be empty"):
rule.because(" ")


class TestTypeCheckingImports:
def setup_method(self):
Expand Down
25 changes: 25 additions & 0 deletions tests/integration/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,19 @@ def test_failing_rule_raises(self):
with pytest.raises(AssertionError, match="architecture violation"):
assert_passes(rule)

def test_failing_rule_includes_because_rationale(self):
rule = (
project_files(FIXTURES_DIR)
.in_folder("**/controllers*")
.should_not()
.depend_on_files()
.in_folder("**/services*")
.because("controllers should stay thin")
)

with pytest.raises(AssertionError, match="Because: controllers should stay thin"):
assert_passes(rule)


class TestFormatViolations:
def test_no_violations(self):
Expand All @@ -102,6 +115,18 @@ def test_with_violations(self):
assert "1 architecture violation" in result
assert "Circular dependency" in result

def test_with_because_rationale(self):
from archunitpython.common.projection.types import ProjectedEdge

violation = ViolatingCycle(
cycle=[
ProjectedEdge(source_label="a.py", target_label="b.py"),
ProjectedEdge(source_label="b.py", target_label="a.py"),
]
)
result = format_violations([violation], because="cycles make changes risky")
assert "Because: cycles make changes risky" in result


class TestSelfTesting:
"""ArchUnitPython tests its own architecture."""
Expand Down