Skip to content

Commit 9f97e04

Browse files
authored
Merge pull request #14 from LukasNiessen/codex/archignore-support
feat: support .archignore exclusions
2 parents 97a838e + 1c336d2 commit 9f97e04

8 files changed

Lines changed: 201 additions & 18 deletions

File tree

‎BACKLOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ This backlog collects product and maintenance ideas from project research.
1111

1212
## P1 - Adoption Workflow
1313

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

‎README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,24 @@ options = CheckOptions(
155155
violations = rule.check(options)
156156
```
157157

158+
### Excluding Files With `.archignore`
159+
160+
Add a `.archignore` file to your project root to permanently exclude generated or
161+
irrelevant files from architecture checks and file-based metrics:
162+
163+
```gitignore
164+
# Generated code
165+
generated/
166+
167+
# Migration scripts
168+
migrations/*.py
169+
170+
# A single root-level file
171+
/legacy_adapter.py
172+
```
173+
174+
Patterns support comments, blank lines, glob syntax, root-relative paths, path
175+
patterns, and directory patterns with a trailing `/`.
158176
### Explaining Rules With `.because(...)`
159177

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

‎scripts/check_release_metadata.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def read_project_version() -> str:
1717
content = PYPROJECT.read_text(encoding="utf-8")
1818
match = re.search(r'^version = "([^"]+)"$', content, re.MULTILINE)
1919
if match is None:
20-
raise RuntimeError("Could not find project.version in pyproject.toml")
20+
raise RuntimeError("Could not find [project].version in pyproject.toml")
2121
return match.group(1)
2222

2323

‎src/archunitpython/common/extraction/extract_graph.py‎

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
"*.egg-info",
3030
]
3131

32+
_ARCHIGNORE_FILE = ".archignore"
33+
3234
_IGNORE_DIRECTIVE_REGEX = re.compile(
3335
r"#\s*archunit(?::|-)\s*ignore"
3436
r"(?:\([^)]*\))?"
@@ -88,9 +90,7 @@ def extract_graph(
8890
project_path = os.getcwd()
8991

9092
project_path = os.path.abspath(project_path)
91-
excludes = (
92-
list(set(exclude_patterns)) if exclude_patterns is not None else list(_DEFAULT_EXCLUDE)
93-
)
93+
excludes = _resolve_exclude_patterns(project_path, exclude_patterns)
9494
ignore_type_checking_imports = bool(options and options.ignore_type_checking_imports)
9595
cache_key = _build_cache_key(project_path, excludes, ignore_type_checking_imports)
9696

@@ -122,6 +122,34 @@ def _build_cache_key(
122122
)
123123

124124

125+
def _resolve_exclude_patterns(
126+
project_path: str,
127+
exclude_patterns: list[str] | None,
128+
) -> list[str]:
129+
"""Resolve exclude patterns (explicit or defaults) plus any .archignore patterns."""
130+
excludes = list(exclude_patterns) if exclude_patterns is not None else list(_DEFAULT_EXCLUDE)
131+
excludes.extend(_load_archignore_patterns(project_path))
132+
return excludes
133+
134+
135+
def _load_archignore_patterns(project_path: str) -> list[str]:
136+
"""Load .archignore patterns from a project root, if present."""
137+
archignore_path = os.path.join(project_path, _ARCHIGNORE_FILE)
138+
try:
139+
with open(archignore_path, "r", encoding="utf-8", errors="replace") as f:
140+
lines = f.readlines()
141+
except OSError:
142+
return []
143+
144+
patterns: list[str] = []
145+
for line in lines:
146+
pattern = line.strip()
147+
if not pattern or pattern.startswith("#"):
148+
continue
149+
patterns.append(pattern)
150+
return patterns
151+
152+
125153
def _extract_graph_uncached(
126154
project_path: str,
127155
exclude_patterns: list[str],
@@ -160,7 +188,7 @@ def _extract_graph_uncached(
160188
if resolved and resolved != _normalize(file_path):
161189
# Check if the resolved path is in our project
162190
if not is_external and resolved not in normalized_py_file_set:
163-
is_external = True
191+
continue
164192

165193
edges.append(
166194
Edge(
@@ -182,25 +210,65 @@ def _normalize(path: str) -> str:
182210
def _find_python_files(root: str, exclude: list[str]) -> list[str]:
183211
"""Recursively find all .py files, excluding specified patterns."""
184212
py_files: list[str] = []
213+
root = os.path.abspath(root)
185214
for dirpath, dirnames, filenames in os.walk(root):
186215
# Filter out excluded directories in-place
187-
dirnames[:] = [d for d in dirnames if not _should_exclude(d, exclude)]
216+
dirnames[:] = [
217+
d
218+
for d in dirnames
219+
if not _should_exclude_path(os.path.join(dirpath, d), root, exclude, is_dir=True)
220+
]
188221

189222
for filename in filenames:
190-
if filename.endswith(".py") and not _should_exclude(filename, exclude):
191-
full_path = os.path.join(dirpath, filename)
223+
full_path = os.path.join(dirpath, filename)
224+
if filename.endswith(".py") and not _should_exclude_path(
225+
full_path, root, exclude, is_dir=False
226+
):
192227
py_files.append(os.path.abspath(full_path))
193228

194229
return py_files
195230

196231

197-
def _should_exclude(name: str, patterns: list[str]) -> bool:
198-
"""Check if a name matches any exclude pattern."""
232+
def _should_exclude_path(
233+
path: str,
234+
root: str,
235+
patterns: list[str],
236+
*,
237+
is_dir: bool,
238+
) -> bool:
239+
"""Check if a path matches any exclude pattern."""
199240
import fnmatch
200241

201-
for pattern in patterns:
202-
if fnmatch.fnmatch(name, pattern):
242+
rel_path = _normalize(os.path.relpath(path, root))
243+
name = os.path.basename(path)
244+
245+
for raw_pattern in patterns:
246+
pattern = raw_pattern.strip().replace("\\", "/")
247+
if not pattern or pattern.startswith("#"):
248+
continue
249+
250+
pattern = pattern.removeprefix("./")
251+
anchored = pattern.startswith("/")
252+
if anchored:
253+
pattern = pattern[1:]
254+
255+
dir_only = pattern.endswith("/")
256+
if dir_only:
257+
pattern = pattern.rstrip("/")
258+
if not is_dir:
259+
continue
260+
261+
if not pattern:
262+
continue
263+
264+
if "/" in pattern or anchored:
265+
if fnmatch.fnmatch(rel_path, pattern):
266+
return True
267+
if is_dir and rel_path == pattern:
268+
return True
269+
elif fnmatch.fnmatch(name, pattern):
203270
return True
271+
204272
return False
205273

206274

‎src/archunitpython/metrics/extraction/extract_class_info.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
import ast
66
import os
77

8-
from archunitpython.common.extraction.extract_graph import _DEFAULT_EXCLUDE, _find_python_files
8+
from archunitpython.common.extraction.extract_graph import (
9+
_find_python_files,
10+
_resolve_exclude_patterns,
11+
)
912
from archunitpython.metrics.common.types import (
1013
ClassInfo,
1114
EnhancedClassInfo,
@@ -33,7 +36,7 @@ def extract_class_info(
3336
project_path = os.getcwd()
3437

3538
project_path = os.path.abspath(project_path)
36-
excludes = exclude_patterns if exclude_patterns is not None else _DEFAULT_EXCLUDE
39+
excludes = _resolve_exclude_patterns(project_path, exclude_patterns)
3740
py_files = _find_python_files(project_path, excludes)
3841

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

5558
project_path = os.path.abspath(project_path)
56-
excludes = exclude_patterns if exclude_patterns is not None else _DEFAULT_EXCLUDE
59+
excludes = _resolve_exclude_patterns(project_path, exclude_patterns)
5760
py_files = _find_python_files(project_path, excludes)
5861

5962
results: list[FileAnalysisResult] = []

‎src/archunitpython/metrics/fluentapi/metrics.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,13 +251,13 @@ def check(self, options: CheckOptions | None = None) -> list[Violation]:
251251
import os
252252

253253
from archunitpython.common.extraction.extract_graph import (
254-
_DEFAULT_EXCLUDE,
255254
_find_python_files,
255+
_resolve_exclude_patterns,
256256
)
257257

258258
project = self._project_path or os.getcwd()
259259
project = os.path.abspath(project)
260-
files = _find_python_files(project, _DEFAULT_EXCLUDE)
260+
files = _find_python_files(project, _resolve_exclude_patterns(project, None))
261261
violations: list[Violation] = []
262262

263263
for file_path in files:

‎tests/common/test_extract_graph.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
_extract_imports,
1212
_find_python_files,
1313
_normalize,
14+
_resolve_exclude_patterns,
1415
clear_graph_cache,
1516
extract_graph,
1617
)
@@ -164,6 +165,73 @@ def test_edge_has_import_kinds(self):
164165
assert len(edges_with_kinds) > 0
165166

166167

168+
class TestArchignore:
169+
def setup_method(self):
170+
clear_graph_cache()
171+
self._temp_dir = Path(__file__).resolve().parent / ".tmp" / f"project_{uuid4().hex}"
172+
self._temp_dir.mkdir(parents=True)
173+
174+
def teardown_method(self):
175+
shutil.rmtree(self._temp_dir, ignore_errors=True)
176+
177+
def _write(self, relative_path: str, content: str = "") -> None:
178+
path = self._temp_dir / relative_path
179+
path.parent.mkdir(parents=True, exist_ok=True)
180+
path.write_text(content, encoding="utf-8")
181+
182+
def test_archignore_excludes_files_and_directories(self):
183+
self._write(
184+
".archignore",
185+
"\n".join(
186+
[
187+
"# Ignore generated architecture-test inputs",
188+
"ignored.py",
189+
"generated/",
190+
"nested/*.py",
191+
"/root_ignored.py",
192+
]
193+
),
194+
)
195+
self._write("keep.py")
196+
self._write("ignored.py")
197+
self._write("root_ignored.py")
198+
self._write("generated/generated.py")
199+
self._write("nested/ignored_nested.py")
200+
201+
excludes = _resolve_exclude_patterns(str(self._temp_dir), ["__pycache__"])
202+
files = _find_python_files(str(self._temp_dir), excludes)
203+
relative_files = {
204+
Path(file_path).relative_to(self._temp_dir).as_posix()
205+
for file_path in files
206+
}
207+
208+
assert relative_files == {"keep.py"}
209+
210+
def test_archignore_ignored_files_are_not_dependency_targets(self):
211+
self._write(".archignore", "ignored.py\n")
212+
self._write("keep.py", "import ignored\n")
213+
self._write("ignored.py", "VALUE = 1\n")
214+
215+
graph = extract_graph(str(self._temp_dir))
216+
targets = {edge.target for edge in graph}
217+
218+
ignored_path = _normalize(str((self._temp_dir / "ignored.py").resolve()))
219+
assert ignored_path not in targets
220+
221+
def test_archignore_with_invalid_utf8_bytes_does_not_abort_extraction(self):
222+
(self._temp_dir / ".archignore").write_bytes(b"ignored.py\n\xff\n")
223+
self._write("keep.py")
224+
self._write("ignored.py")
225+
226+
graph = extract_graph(str(self._temp_dir))
227+
sources = {edge.source for edge in graph}
228+
229+
keep_path = _normalize(str((self._temp_dir / "keep.py").resolve()))
230+
ignored_path = _normalize(str((self._temp_dir / "ignored.py").resolve()))
231+
assert keep_path in sources
232+
assert ignored_path not in sources
233+
234+
167235
class TestTypeCheckingImportHandling:
168236
def setup_method(self):
169237
clear_graph_cache()

‎tests/metrics/test_metrics_fluentapi.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"""Tests for the metrics fluent API."""
22

33
import os
4+
import shutil
5+
from pathlib import Path
6+
from uuid import uuid4
47

58
from archunitpython.metrics.assertion.metric_thresholds import (
69
FileCountViolation,
@@ -40,6 +43,29 @@ def test_lines_of_code_violation(self):
4043
assert len(file_violations) > 0
4144

4245

46+
class TestMetricsArchignore:
47+
def setup_method(self):
48+
self._temp_dir = Path(__file__).resolve().parent / ".tmp" / f"project_{uuid4().hex}"
49+
self._temp_dir.mkdir(parents=True)
50+
51+
def teardown_method(self):
52+
shutil.rmtree(self._temp_dir, ignore_errors=True)
53+
54+
def test_file_metrics_respect_archignore(self):
55+
(self._temp_dir / ".archignore").write_text("ignored.py\n", encoding="utf-8")
56+
(self._temp_dir / "keep.py").write_text("VALUE = 1\n", encoding="utf-8")
57+
(self._temp_dir / "ignored.py").write_text(
58+
"\n".join(f"VALUE_{i} = {i}" for i in range(20)),
59+
encoding="utf-8",
60+
)
61+
62+
violations = (
63+
metrics(str(self._temp_dir)).count().lines_of_code().should_be_below(5).check()
64+
)
65+
66+
assert violations == []
67+
68+
4369
class TestLCOMMetricsFluentAPI:
4470
def test_lcom96b_below(self):
4571
violations = metrics(FIXTURES_DIR).lcom().lcom96b().should_be_below(1.0).check()

0 commit comments

Comments
 (0)