From 34984f0f35b9bdb988703e8239727d2e032b0c13 Mon Sep 17 00:00:00 2001 From: TristanKruse Date: Sun, 24 May 2026 00:26:31 +0200 Subject: [PATCH 1/7] ci: add release metadata checks --- .github/workflows/integrate.yaml | 6 ++- .releaserc.json | 4 +- BACKLOG.md | 8 ++-- pyproject.toml | 1 + scripts/bump_release_version.py | 50 +++++++++++++++++++++ scripts/check_release_metadata.py | 72 +++++++++++++++++++++++++++++++ 6 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 scripts/bump_release_version.py create mode 100644 scripts/check_release_metadata.py diff --git a/.github/workflows/integrate.yaml b/.github/workflows/integrate.yaml index 9421c30..f83f87d 100644 --- a/.github/workflows/integrate.yaml +++ b/.github/workflows/integrate.yaml @@ -26,12 +26,16 @@ jobs: pip-${{ runner.os }}-${{ matrix.python-version }}- - name: Install dependencies run: pip install -e ".[dev]" + - name: Check release metadata + run: python scripts/check_release_metadata.py - name: Lint - run: ruff check src/ + run: ruff check src/ scripts/ - name: Type check run: mypy src/archunitpython/ --ignore-missing-imports - name: Test run: pytest --tb=short -q + - name: Build package + run: python -m build publish: if: github.ref == 'refs/heads/main' && github.event_name == 'push' diff --git a/.releaserc.json b/.releaserc.json index 0229c94..e45500f 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -12,13 +12,13 @@ [ "@semantic-release/exec", { - "prepareCmd": "python -c \"import re, pathlib; p=pathlib.Path('pyproject.toml'); p.write_text(re.sub(r'version = \\\"[^\\\"]+\\\"', f'version = \\\"${nextRelease.version}\\\"', p.read_text()))\"" + "prepareCmd": "python scripts/bump_release_version.py ${nextRelease.version}" } ], [ "@semantic-release/git", { - "assets": ["pyproject.toml", "CHANGELOG.md"], + "assets": ["pyproject.toml", "src/archunitpython/__init__.py", "CHANGELOG.md"], "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" } ], diff --git a/BACKLOG.md b/BACKLOG.md index 424e9ab..824dd1f 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -4,10 +4,10 @@ This backlog merges the existing `TODO.md` items with research findings from `do ## P0 - Maintenance And Correctness -- Keep package metadata synchronized across `pyproject.toml`, `CHANGELOG.md`, and `src/archunitpython/__init__.py`. -- Keep tool configuration valid for the supported Python range, especially mypy and Ruff target versions. -- Add a release metadata check that fails when the exported `__version__` differs from the project version. -- Add CI jobs that run tests, Ruff, mypy, and a package build from a clean checkout. +- [x] Keep package metadata synchronized across `pyproject.toml`, `CHANGELOG.md`, and `src/archunitpython/__init__.py`. +- [x] Keep tool configuration valid for the supported Python range, especially mypy and Ruff target versions. +- [x] Add a release metadata check that fails when the exported `__version__` differs from the project version. +- [x] Add CI jobs that run tests, Ruff, mypy, and a package build from a clean checkout. ## P1 - Adoption Workflow diff --git a/pyproject.toml b/pyproject.toml index 17603ca..d7d4e18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ Changelog = "https://github.com/LukasNiessen/ArchUnitPython/blob/main/CHANGELOG. [project.optional-dependencies] dev = [ + "build>=1.0", "pytest>=7.0", "pytest-cov>=4.0", "mypy>=1.0", diff --git a/scripts/bump_release_version.py b/scripts/bump_release_version.py new file mode 100644 index 0000000..16e27b7 --- /dev/null +++ b/scripts/bump_release_version.py @@ -0,0 +1,50 @@ +"""Synchronize release version metadata for semantic-release.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PYPROJECT = ROOT / "pyproject.toml" +PACKAGE_INIT = ROOT / "src" / "archunitpython" / "__init__.py" + + +def replace_once(pattern: str, replacement: str, content: str, path: Path) -> str: + updated, count = re.subn(pattern, replacement, content, count=1, flags=re.MULTILINE) + if count != 1: + raise RuntimeError(f"Could not update version in {path}") + return updated + + +def bump_version(version: str) -> None: + pyproject = PYPROJECT.read_text(encoding="utf-8") + PYPROJECT.write_text( + replace_once(r'^version = "[^"]+"$', f'version = "{version}"', pyproject, PYPROJECT), + encoding="utf-8", + ) + + package_init = PACKAGE_INIT.read_text(encoding="utf-8") + PACKAGE_INIT.write_text( + replace_once( + r'^__version__ = "[^"]+"$', + f'__version__ = "{version}"', + package_init, + PACKAGE_INIT, + ), + encoding="utf-8", + ) + + +def main() -> int: + if len(sys.argv) != 2: + print("Usage: python scripts/bump_release_version.py ", file=sys.stderr) + return 2 + + bump_version(sys.argv[1]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_release_metadata.py b/scripts/check_release_metadata.py new file mode 100644 index 0000000..3c2f015 --- /dev/null +++ b/scripts/check_release_metadata.py @@ -0,0 +1,72 @@ +"""Check that release metadata stays synchronized.""" + +from __future__ import annotations + +import ast +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PYPROJECT = ROOT / "pyproject.toml" +PACKAGE_INIT = ROOT / "src" / "archunitpython" / "__init__.py" +CHANGELOG = ROOT / "CHANGELOG.md" + + +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") + return match.group(1) + + +def read_package_version() -> str: + module = ast.parse(PACKAGE_INIT.read_text(encoding="utf-8")) + for statement in module.body: + if ( + isinstance(statement, ast.Assign) + and len(statement.targets) == 1 + and isinstance(statement.targets[0], ast.Name) + and statement.targets[0].id == "__version__" + and isinstance(statement.value, ast.Constant) + and isinstance(statement.value.value, str) + ): + return statement.value.value + raise RuntimeError("Could not find __version__ in src/archunitpython/__init__.py") + + +def changelog_contains_version(version: str) -> bool: + content = CHANGELOG.read_text(encoding="utf-8") + heading_pattern = re.compile( + rf"^#+\s+(?:\[)?{re.escape(version)}(?:\])?(?:\s|\(|$)", + re.MULTILINE, + ) + return heading_pattern.search(content) is not None + + +def main() -> int: + project_version = read_project_version() + package_version = read_package_version() + + errors = [] + if package_version != project_version: + errors.append( + f"Package __version__ ({package_version}) does not match " + f"pyproject.toml version ({project_version})." + ) + if not changelog_contains_version(project_version): + errors.append(f"CHANGELOG.md does not contain a heading for version {project_version}.") + + if errors: + print("Release metadata check failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + print(f"Release metadata is synchronized for version {project_version}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From c51c40148425b08ce0257d75dd95c996d0505b00 Mon Sep 17 00:00:00 2001 From: TristanKruse Date: Tue, 9 Jun 2026 17:04:32 +0200 Subject: [PATCH 2/7] feat: support archignore exclusions --- BACKLOG.md | 2 +- README.md | 19 ++++ .../common/extraction/extract_graph.py | 89 ++++++++++++++++--- .../metrics/extraction/extract_class_info.py | 9 +- .../metrics/fluentapi/metrics.py | 4 +- tests/common/test_extract_graph.py | 55 ++++++++++++ tests/metrics/test_metrics_fluentapi.py | 26 ++++++ 7 files changed, 186 insertions(+), 18 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 824dd1f..1ca6437 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -11,7 +11,7 @@ This backlog merges the existing `TODO.md` items with research findings from `do ## P1 - Adoption Workflow -- Add an `.archignore` or similar file, modeled after `.gitignore`, for files that should never be analyzed. +- [x] 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. - Add a freeze/baseline mechanism for known violations so teams can adopt ArchUnitPython incrementally. - Add configuration-file support for common rules, while keeping the fluent Python API as the primary interface. diff --git a/README.md b/README.md index 7e203d9..4530515 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,25 @@ 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: + +```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 `/`. + ## 🐹 Use Cases Here is an overview of common use cases. diff --git a/src/archunitpython/common/extraction/extract_graph.py b/src/archunitpython/common/extraction/extract_graph.py index 4d05d72..fb72cf2 100644 --- a/src/archunitpython/common/extraction/extract_graph.py +++ b/src/archunitpython/common/extraction/extract_graph.py @@ -27,6 +27,8 @@ "*.egg-info", ] +_ARCHIGNORE_FILE = ".archignore" + def clear_graph_cache(options: CheckOptions | None = None) -> None: """Clear the cached dependency graphs.""" @@ -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 ) @@ -94,6 +96,34 @@ def _build_cache_key( ) +def _resolve_exclude_patterns( + project_path: str, + exclude_patterns: list[str] | None, +) -> list[str]: + """Resolve built-in, explicit, and .archignore exclude 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") as f: + lines = f.readlines() + except (OSError, IOError): + 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], @@ -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) @@ -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( @@ -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 diff --git a/src/archunitpython/metrics/extraction/extract_class_info.py b/src/archunitpython/metrics/extraction/extract_class_info.py index 24ed42e..ab29ee4 100644 --- a/src/archunitpython/metrics/extraction/extract_class_info.py +++ b/src/archunitpython/metrics/extraction/extract_class_info.py @@ -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, @@ -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] = [] @@ -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] = [] diff --git a/src/archunitpython/metrics/fluentapi/metrics.py b/src/archunitpython/metrics/fluentapi/metrics.py index 27107fd..7a60c54 100644 --- a/src/archunitpython/metrics/fluentapi/metrics.py +++ b/src/archunitpython/metrics/fluentapi/metrics.py @@ -267,13 +267,13 @@ def check(self, options: CheckOptions | None = None) -> list[Violation]: import os from archunitpython.common.extraction.extract_graph import ( - _DEFAULT_EXCLUDE, _find_python_files, + _resolve_exclude_patterns, ) project = self._project_path or os.getcwd() project = os.path.abspath(project) - files = _find_python_files(project, _DEFAULT_EXCLUDE) + files = _find_python_files(project, _resolve_exclude_patterns(project, None)) violations: list[Violation] = [] for file_path in files: diff --git a/tests/common/test_extract_graph.py b/tests/common/test_extract_graph.py index 5339658..ddb72c4 100644 --- a/tests/common/test_extract_graph.py +++ b/tests/common/test_extract_graph.py @@ -11,6 +11,7 @@ _extract_imports, _find_python_files, _normalize, + _resolve_exclude_patterns, clear_graph_cache, extract_graph, ) @@ -132,6 +133,60 @@ def test_edge_has_import_kinds(self): assert len(edges_with_kinds) > 0 +class TestArchignore: + def setup_method(self): + clear_graph_cache() + self._temp_dir = Path(__file__).resolve().parent / ".tmp" / f"project_{uuid4().hex}" + self._temp_dir.mkdir(parents=True) + + def teardown_method(self): + shutil.rmtree(self._temp_dir, ignore_errors=True) + + def _write(self, relative_path: str, content: str = "") -> None: + path = self._temp_dir / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + def test_archignore_excludes_files_and_directories(self): + self._write( + ".archignore", + "\n".join( + [ + "# Ignore generated architecture-test inputs", + "ignored.py", + "generated/", + "nested/*.py", + "/root_ignored.py", + ] + ), + ) + self._write("keep.py") + self._write("ignored.py") + self._write("root_ignored.py") + self._write("generated/generated.py") + self._write("nested/ignored_nested.py") + + excludes = _resolve_exclude_patterns(str(self._temp_dir), ["__pycache__"]) + files = _find_python_files(str(self._temp_dir), excludes) + relative_files = { + Path(file_path).relative_to(self._temp_dir).as_posix() + for file_path in files + } + + assert relative_files == {"keep.py"} + + def test_archignore_ignored_files_are_not_dependency_targets(self): + self._write(".archignore", "ignored.py\n") + self._write("keep.py", "import ignored\n") + self._write("ignored.py", "VALUE = 1\n") + + graph = extract_graph(str(self._temp_dir)) + targets = {edge.target for edge in graph} + + ignored_path = _normalize(str((self._temp_dir / "ignored.py").resolve())) + assert ignored_path not in targets + + class TestTypeCheckingImportHandling: def setup_method(self): clear_graph_cache() diff --git a/tests/metrics/test_metrics_fluentapi.py b/tests/metrics/test_metrics_fluentapi.py index fd812ec..e21d3d7 100644 --- a/tests/metrics/test_metrics_fluentapi.py +++ b/tests/metrics/test_metrics_fluentapi.py @@ -1,6 +1,9 @@ """Tests for the metrics fluent API.""" import os +import shutil +from pathlib import Path +from uuid import uuid4 from archunitpython.metrics.assertion.metric_thresholds import ( FileCountViolation, @@ -58,6 +61,29 @@ def test_lines_of_code_violation(self): assert len(file_violations) > 0 +class TestMetricsArchignore: + def setup_method(self): + self._temp_dir = Path(__file__).resolve().parent / ".tmp" / f"project_{uuid4().hex}" + self._temp_dir.mkdir(parents=True) + + def teardown_method(self): + shutil.rmtree(self._temp_dir, ignore_errors=True) + + def test_file_metrics_respect_archignore(self): + (self._temp_dir / ".archignore").write_text("ignored.py\n", encoding="utf-8") + (self._temp_dir / "keep.py").write_text("VALUE = 1\n", encoding="utf-8") + (self._temp_dir / "ignored.py").write_text( + "\n".join(f"VALUE_{i} = {i}" for i in range(20)), + encoding="utf-8", + ) + + violations = ( + metrics(str(self._temp_dir)).count().lines_of_code().should_be_below(5).check() + ) + + assert violations == [] + + class TestLCOMMetricsFluentAPI: def test_lcom96b_below(self): violations = ( From 58b11edb2587b55cc1df389d2c80d6f174cf97a6 Mon Sep 17 00:00:00 2001 From: TristanKruse Date: Tue, 9 Jun 2026 18:28:11 +0200 Subject: [PATCH 3/7] feat: add because rule rationales --- BACKLOG.md | 2 +- README.md | 19 ++++++++++++++ src/archunitpython/common/__init__.py | 7 +++++- .../common/fluentapi/__init__.py | 8 ++++-- .../common/fluentapi/checkable.py | 24 +++++++++++++++++- src/archunitpython/files/fluentapi/files.py | 12 ++++----- .../metrics/fluentapi/metrics.py | 14 +++++------ src/archunitpython/slices/fluentapi/slices.py | 6 ++--- src/archunitpython/testing/assertion.py | 14 ++++++++--- tests/files/test_files_fluentapi.py | 19 ++++++++++++++ tests/integration/test_e2e.py | 25 +++++++++++++++++++ 11 files changed, 126 insertions(+), 24 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 1ca6437..e019257 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -12,7 +12,7 @@ This backlog merges the existing `TODO.md` items with research findings from `do ## P1 - Adoption Workflow - [x] 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 a freeze/baseline mechanism for known violations so teams can adopt ArchUnitPython incrementally. - 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. diff --git a/README.md b/README.md index 4530515..aeadedf 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,25 @@ migrations/*.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. diff --git a/src/archunitpython/common/__init__.py b/src/archunitpython/common/__init__.py index e945f0a..0c1296d 100644 --- a/src/archunitpython/common/__init__.py +++ b/src/archunitpython/common/__init__.py @@ -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 @@ -11,6 +15,7 @@ "UserError", "Checkable", "CheckOptions", + "RuleRationaleMixin", "LoggingOptions", "Pattern", "Filter", diff --git a/src/archunitpython/common/fluentapi/__init__.py b/src/archunitpython/common/fluentapi/__init__.py index 273eebc..7e8414e 100644 --- a/src/archunitpython/common/fluentapi/__init__.py +++ b/src/archunitpython/common/fluentapi/__init__.py @@ -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"] diff --git a/src/archunitpython/common/fluentapi/checkable.py b/src/archunitpython/common/fluentapi/checkable.py index df3cc4b..10f216c 100644 --- a/src/archunitpython/common/fluentapi/checkable.py +++ b/src/archunitpython/common/fluentapi/checkable.py @@ -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 @@ -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. diff --git a/src/archunitpython/files/fluentapi/files.py b/src/archunitpython/files/fluentapi/files.py index c680681..5f9be15 100644 --- a/src/archunitpython/files/fluentapi/files.py +++ b/src/archunitpython/files/fluentapi/files.py @@ -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, @@ -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: @@ -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__( @@ -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__( @@ -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__( @@ -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__( diff --git a/src/archunitpython/metrics/fluentapi/metrics.py b/src/archunitpython/metrics/fluentapi/metrics.py index 7a60c54..45c0095 100644 --- a/src/archunitpython/metrics/fluentapi/metrics.py +++ b/src/archunitpython/metrics/fluentapi/metrics.py @@ -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 @@ -186,7 +186,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__( @@ -246,7 +246,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__( @@ -386,7 +386,7 @@ def should_be_above(self, threshold: float) -> "DistanceCondition": ) -class DistanceCondition: +class DistanceCondition(RuleRationaleMixin): """Checkable for distance metric thresholds.""" def __init__( @@ -426,7 +426,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: @@ -511,7 +511,7 @@ def should_satisfy( ) -class CustomMetricCondition: +class CustomMetricCondition(RuleRationaleMixin): """Checkable for custom metric thresholds.""" def __init__( @@ -551,7 +551,7 @@ def check(self, options: CheckOptions | None = None) -> list[Violation]: return violations -class CustomAssertionCondition: +class CustomAssertionCondition(RuleRationaleMixin): """Checkable for custom metric assertions.""" def __init__( diff --git a/src/archunitpython/slices/fluentapi/slices.py b/src/archunitpython/slices/fluentapi/slices.py index cf314fb..cd44e38 100644 --- a/src/archunitpython/slices/fluentapi/slices.py +++ b/src/archunitpython/slices/fluentapi/slices.py @@ -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 ( @@ -146,7 +146,7 @@ def contain_dependency( ) -class PositiveSliceCondition: +class PositiveSliceCondition(RuleRationaleMixin): """Checkable that verifies slices adhere to a diagram.""" def __init__( @@ -184,7 +184,7 @@ def _get_mapper(self) -> MapFunction: return identity() -class NegativeSliceCondition: +class NegativeSliceCondition(RuleRationaleMixin): """Checkable that verifies a specific dependency does NOT exist.""" def __init__( diff --git a/src/archunitpython/testing/assertion.py b/src/archunitpython/testing/assertion.py index 8bb5795..900c2b8 100644 --- a/src/archunitpython/testing/assertion.py +++ b/src/archunitpython/testing/assertion.py @@ -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: @@ -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}") @@ -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)) diff --git a/tests/files/test_files_fluentapi.py b/tests/files/test_files_fluentapi.py index c17993d..2278350 100644 --- a/tests/files/test_files_fluentapi.py +++ b/tests/files/test_files_fluentapi.py @@ -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 @@ -270,6 +272,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): diff --git a/tests/integration/test_e2e.py b/tests/integration/test_e2e.py index 8bd5ed3..fcd9a7c 100644 --- a/tests/integration/test_e2e.py +++ b/tests/integration/test_e2e.py @@ -75,6 +75,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): @@ -94,6 +107,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.""" From ae88c932ae2838cff0eabab687ac948f4920c3eb Mon Sep 17 00:00:00 2001 From: TristanKruse Date: Tue, 9 Jun 2026 18:42:43 +0200 Subject: [PATCH 4/7] fix: harden archignore loading --- .../common/extraction/extract_graph.py | 6 +++--- tests/common/test_extract_graph.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/archunitpython/common/extraction/extract_graph.py b/src/archunitpython/common/extraction/extract_graph.py index fb72cf2..509abd3 100644 --- a/src/archunitpython/common/extraction/extract_graph.py +++ b/src/archunitpython/common/extraction/extract_graph.py @@ -100,7 +100,7 @@ def _resolve_exclude_patterns( project_path: str, exclude_patterns: list[str] | None, ) -> list[str]: - """Resolve built-in, explicit, and .archignore exclude patterns.""" + """Resolve default/explicit exclude patterns and .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 @@ -110,9 +110,9 @@ 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") as f: + with open(archignore_path, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() - except (OSError, IOError): + except OSError: return [] patterns: list[str] = [] diff --git a/tests/common/test_extract_graph.py b/tests/common/test_extract_graph.py index ddb72c4..a01c080 100644 --- a/tests/common/test_extract_graph.py +++ b/tests/common/test_extract_graph.py @@ -186,6 +186,19 @@ def test_archignore_ignored_files_are_not_dependency_targets(self): ignored_path = _normalize(str((self._temp_dir / "ignored.py").resolve())) assert ignored_path not in targets + def test_archignore_with_invalid_utf8_bytes_does_not_abort_extraction(self): + (self._temp_dir / ".archignore").write_bytes(b"ignored.py\n\xff\n") + self._write("keep.py") + self._write("ignored.py") + + graph = extract_graph(str(self._temp_dir)) + sources = {edge.source for edge in graph} + + keep_path = _normalize(str((self._temp_dir / "keep.py").resolve())) + ignored_path = _normalize(str((self._temp_dir / "ignored.py").resolve())) + assert keep_path in sources + assert ignored_path not in sources + class TestTypeCheckingImportHandling: def setup_method(self): From 999bb689594ff613b5f4445191509bcdaa9a514f Mon Sep 17 00:00:00 2001 From: TristanKruse Date: Tue, 9 Jun 2026 18:55:14 +0200 Subject: [PATCH 5/7] fix: clarify metadata and exclude messages --- scripts/check_release_metadata.py | 2 +- src/archunitpython/common/extraction/extract_graph.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check_release_metadata.py b/scripts/check_release_metadata.py index 3c2f015..36e438e 100644 --- a/scripts/check_release_metadata.py +++ b/scripts/check_release_metadata.py @@ -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) diff --git a/src/archunitpython/common/extraction/extract_graph.py b/src/archunitpython/common/extraction/extract_graph.py index 509abd3..36681a4 100644 --- a/src/archunitpython/common/extraction/extract_graph.py +++ b/src/archunitpython/common/extraction/extract_graph.py @@ -100,7 +100,7 @@ def _resolve_exclude_patterns( project_path: str, exclude_patterns: list[str] | None, ) -> list[str]: - """Resolve default/explicit exclude patterns and .archignore patterns.""" + """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 From d2895c3dffc7967371ba6977d4ee572078555c47 Mon Sep 17 00:00:00 2001 From: TristanKruse Date: Tue, 9 Jun 2026 18:42:43 +0200 Subject: [PATCH 6/7] fix: harden archignore loading --- .../common/extraction/extract_graph.py | 6 +++--- tests/common/test_extract_graph.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/archunitpython/common/extraction/extract_graph.py b/src/archunitpython/common/extraction/extract_graph.py index fb72cf2..509abd3 100644 --- a/src/archunitpython/common/extraction/extract_graph.py +++ b/src/archunitpython/common/extraction/extract_graph.py @@ -100,7 +100,7 @@ def _resolve_exclude_patterns( project_path: str, exclude_patterns: list[str] | None, ) -> list[str]: - """Resolve built-in, explicit, and .archignore exclude patterns.""" + """Resolve default/explicit exclude patterns and .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 @@ -110,9 +110,9 @@ 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") as f: + with open(archignore_path, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() - except (OSError, IOError): + except OSError: return [] patterns: list[str] = [] diff --git a/tests/common/test_extract_graph.py b/tests/common/test_extract_graph.py index ddb72c4..a01c080 100644 --- a/tests/common/test_extract_graph.py +++ b/tests/common/test_extract_graph.py @@ -186,6 +186,19 @@ def test_archignore_ignored_files_are_not_dependency_targets(self): ignored_path = _normalize(str((self._temp_dir / "ignored.py").resolve())) assert ignored_path not in targets + def test_archignore_with_invalid_utf8_bytes_does_not_abort_extraction(self): + (self._temp_dir / ".archignore").write_bytes(b"ignored.py\n\xff\n") + self._write("keep.py") + self._write("ignored.py") + + graph = extract_graph(str(self._temp_dir)) + sources = {edge.source for edge in graph} + + keep_path = _normalize(str((self._temp_dir / "keep.py").resolve())) + ignored_path = _normalize(str((self._temp_dir / "ignored.py").resolve())) + assert keep_path in sources + assert ignored_path not in sources + class TestTypeCheckingImportHandling: def setup_method(self): From 2239c94e7310ab7cf8072a22aa8c55f1e9a11959 Mon Sep 17 00:00:00 2001 From: TristanKruse Date: Tue, 9 Jun 2026 18:55:14 +0200 Subject: [PATCH 7/7] fix: clarify metadata and exclude messages --- scripts/check_release_metadata.py | 2 +- src/archunitpython/common/extraction/extract_graph.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check_release_metadata.py b/scripts/check_release_metadata.py index 3c2f015..36e438e 100644 --- a/scripts/check_release_metadata.py +++ b/scripts/check_release_metadata.py @@ -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) diff --git a/src/archunitpython/common/extraction/extract_graph.py b/src/archunitpython/common/extraction/extract_graph.py index 509abd3..36681a4 100644 --- a/src/archunitpython/common/extraction/extract_graph.py +++ b/src/archunitpython/common/extraction/extract_graph.py @@ -100,7 +100,7 @@ def _resolve_exclude_patterns( project_path: str, exclude_patterns: list[str] | None, ) -> list[str]: - """Resolve default/explicit exclude patterns and .archignore patterns.""" + """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