Skip to content

Commit d3dc126

Browse files
committed
Merge remote-tracking branch 'origin/main' into codex/conditional-import-kind
2 parents 5f0f600 + 839ac8e commit d3dc126

28 files changed

Lines changed: 659 additions & 65 deletions

‎BACKLOG.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ 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.
15-
- Add a `.because(...)` API so rules can carry user-facing rationale into failure messages and generated architecture documentation.
16-
- Add configuration-file support for common rules, while keeping the fluent Python API as the primary interface.
14+
- [x] Add an `.archignore` or similar file, modeled after `.gitignore`, for files that should never be analyzed.
15+
- [x] Add a `.because(...)` API so rules can carry user-facing rationale into failure messages and generated architecture documentation.
16+
- [x] 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.
1818

1919
## P1 - Python Import Semantics

‎CHANGELOG.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,30 @@
1+
# [1.5.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.4.0...v1.5.0) (2026-07-18)
2+
3+
4+
### Features
5+
6+
* load common rules from config ([c7c968a](https://github.com/LukasNiessen/ArchUnitPython/commit/c7c968af20e929f7e7a2372275eaa7e26cb38e5f))
7+
8+
# [1.4.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.3.0...v1.4.0) (2026-07-18)
9+
10+
11+
### Bug Fixes
12+
13+
* clarify metadata and exclude messages ([999bb68](https://github.com/LukasNiessen/ArchUnitPython/commit/999bb689594ff613b5f4445191509bcdaa9a514f))
14+
* harden archignore loading ([ae88c93](https://github.com/LukasNiessen/ArchUnitPython/commit/ae88c932ae2838cff0eabab687ac948f4920c3eb))
15+
16+
17+
### Features
18+
19+
* support archignore exclusions ([c51c401](https://github.com/LukasNiessen/ArchUnitPython/commit/c51c40148425b08ce0257d75dd95c996d0505b00))
20+
21+
# [1.3.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.2.1...v1.3.0) (2026-07-05)
22+
23+
24+
### Features
25+
26+
* add because rule rationales ([491c666](https://github.com/LukasNiessen/ArchUnitPython/commit/491c666f1f8a6ef2f5ccd782a4e4c79ed39321f6))
27+
128
## [1.2.1](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.2.0...v1.2.1) (2026-06-28)
229

330

‎README.md‎

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,79 @@ 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 `/`.
176+
177+
### Loading Common Rules From Config
178+
179+
For straightforward shared rules, you can load a JSON config file and still run
180+
the resulting rules in your normal test suite:
181+
182+
```json
183+
{
184+
"project_path": "src",
185+
"rules": [
186+
{
187+
"name": "controllers must not use services directly",
188+
"type": "forbidden_dependency",
189+
"source": "**/controllers/**",
190+
"target": "**/services/**"
191+
},
192+
{
193+
"name": "source files have no cycles",
194+
"type": "no_cycles"
195+
}
196+
]
197+
}
198+
```
199+
200+
```python
201+
from archunitpython import assert_passes, rules_from_config
202+
203+
def test_configured_architecture_rules():
204+
for rule in rules_from_config("archunitpython.json"):
205+
assert_passes(rule)
206+
```
207+
208+
Supported rule types are `no_cycles`, `forbidden_dependency`, and
209+
`forbidden_external_dependency`. The fluent Python API remains the primary and
210+
most flexible interface.
211+
212+
### Explaining Rules With `.because(...)`
213+
214+
Attach a rationale to a rule so failing assertions explain why the rule exists:
215+
216+
```python
217+
rule = (
218+
project_files("src/")
219+
.in_folder("**/controllers/**")
220+
.should_not()
221+
.depend_on_files()
222+
.in_folder("**/database/**")
223+
.because("controllers should stay thin and delegate persistence")
224+
)
225+
226+
assert_passes(rule)
227+
```
228+
229+
When the rule fails, the rationale is included in the assertion message.
230+
158231
## 🐹 Use Cases
159232

160233
Here is an overview of common use cases.
@@ -405,7 +478,7 @@ def test_no_forbidden_dependency():
405478

406479
Generate dependency graph reports in multiple formats and narrow them to the part of the codebase you want to inspect.
407480

408-
**Using `requests` library repo for example**
481+
**Using [`requests`](https://github.com/psf/requests) library repo for example**
409482

410483
```python
411484
from archunitpython import project_graph
@@ -418,7 +491,7 @@ def test_export_dependency_graph_reports():
418491
if __name__ == "__main__":
419492
test_export_dependency_graph_reports()
420493
```
421-
**Rendered mermain diagram**
494+
**Exported mermaid diagram**
422495
``` mermaid
423496
flowchart LR
424497
n0["__init__.py"]

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "archunitpython"
7-
version = "1.2.1"
7+
version = "1.5.0"
88
description = "Architecture testing library for Python projects. Enforce dependency rules, detect cycles, validate metrics."
99
readme = "README.md"
1010
license = "MIT"

‎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/__init__.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""ArchUnitPython - Architecture testing library for Python projects."""
22

3-
__version__ = "1.2.1"
3+
__version__ = "1.5.0"
44

55
# Files API
66
# Common
@@ -12,6 +12,7 @@
1212
Violation,
1313
)
1414
from archunitpython.common.extraction import clear_graph_cache, extract_graph
15+
from archunitpython.config import ConfiguredRule, rules_from_config
1516
from archunitpython.files import files, project_files
1617
from archunitpython.graph import dependency_graph, project_graph
1718
from archunitpython.layers import layers, project_layers
@@ -35,6 +36,9 @@
3536
# Layers
3637
"project_layers",
3738
"layers",
39+
# Config
40+
"rules_from_config",
41+
"ConfiguredRule",
3842
# Slices
3943
"project_slices",
4044
# Metrics

‎src/archunitpython/common/__init__.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
from archunitpython.common.assertion.violation import EmptyTestViolation, Violation
22
from archunitpython.common.error.errors import TechnicalError, UserError
3-
from archunitpython.common.fluentapi.checkable import Checkable, CheckOptions
3+
from archunitpython.common.fluentapi.checkable import (
4+
Checkable,
5+
CheckOptions,
6+
RuleRationaleMixin,
7+
)
48
from archunitpython.common.logging.types import LoggingOptions
59
from archunitpython.common.types import Filter, Pattern, PatternMatchingOptions
610

@@ -11,6 +15,7 @@
1115
"UserError",
1216
"Checkable",
1317
"CheckOptions",
18+
"RuleRationaleMixin",
1419
"LoggingOptions",
1520
"Pattern",
1621
"Filter",

‎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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1-
from archunitpython.common.fluentapi.checkable import Checkable, CheckOptions
1+
from archunitpython.common.fluentapi.checkable import (
2+
Checkable,
3+
CheckOptions,
4+
RuleRationaleMixin,
5+
)
26

3-
__all__ = ["Checkable", "CheckOptions"]
7+
__all__ = ["Checkable", "CheckOptions", "RuleRationaleMixin"]

‎src/archunitpython/common/fluentapi/checkable.py‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass
6-
from typing import Protocol
6+
from typing import Protocol, TypeVar
77

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

2121

22+
T = TypeVar("T", bound="RuleRationaleMixin")
23+
24+
25+
class RuleRationaleMixin:
26+
"""Mixin for checkable rules that can carry a human-readable rationale."""
27+
28+
_because_reason: str | None = None
29+
30+
def because(self: T, reason: str) -> T:
31+
"""Attach a rationale explaining why the rule exists."""
32+
reason = reason.strip()
33+
if not reason:
34+
raise ValueError("Rule rationale must not be empty.")
35+
self._because_reason = reason
36+
return self
37+
38+
@property
39+
def because_reason(self) -> str | None:
40+
"""Return the rationale attached with because(), if any."""
41+
return self._because_reason
42+
43+
2244
class Checkable(Protocol):
2345
"""Protocol for any architecture rule that can be checked.
2446

0 commit comments

Comments
 (0)