From 34984f0f35b9bdb988703e8239727d2e032b0c13 Mon Sep 17 00:00:00 2001 From: TristanKruse Date: Sun, 24 May 2026 00:26:31 +0200 Subject: [PATCH 1/4] 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/4] 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 ae88c932ae2838cff0eabab687ac948f4920c3eb Mon Sep 17 00:00:00 2001 From: TristanKruse Date: Tue, 9 Jun 2026 18:42:43 +0200 Subject: [PATCH 3/4] 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 4/4] 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