archunitpython 1.2.1__tar.gz → 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. {archunitpython-1.2.1 → archunitpython-1.4.0}/BACKLOG.md +2 -2
  2. {archunitpython-1.2.1 → archunitpython-1.4.0}/CHANGELOG.md +20 -0
  3. {archunitpython-1.2.1 → archunitpython-1.4.0}/PKG-INFO +141 -12
  4. {archunitpython-1.2.1 → archunitpython-1.4.0}/README.md +140 -11
  5. {archunitpython-1.2.1 → archunitpython-1.4.0}/pyproject.toml +1 -1
  6. {archunitpython-1.2.1 → archunitpython-1.4.0}/scripts/check_release_metadata.py +1 -1
  7. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/__init__.py +1 -1
  8. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/__init__.py +6 -1
  9. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/extraction/extract_graph.py +79 -11
  10. archunitpython-1.4.0/src/archunitpython/common/fluentapi/__init__.py +7 -0
  11. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/fluentapi/checkable.py +23 -1
  12. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/files/fluentapi/files.py +6 -6
  13. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/metrics/extraction/extract_class_info.py +6 -3
  14. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/metrics/fluentapi/metrics.py +9 -9
  15. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/slices/fluentapi/slices.py +3 -3
  16. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/testing/assertion.py +11 -3
  17. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/common/test_declaration_detector.py +2 -2
  18. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/common/test_extract_graph.py +68 -0
  19. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/common/test_logger.py +0 -4
  20. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/files/test_file_assertions.py +0 -2
  21. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/files/test_files_fluentapi.py +19 -0
  22. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/fixtures/sample_project/utils/helpers.py +5 -1
  23. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/integration/test_e2e.py +25 -0
  24. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/metrics/test_metrics.py +8 -9
  25. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/metrics/test_metrics_fluentapi.py +26 -0
  26. archunitpython-1.2.1/src/archunitpython/common/fluentapi/__init__.py +0 -3
  27. {archunitpython-1.2.1 → archunitpython-1.4.0}/.editorconfig +0 -0
  28. {archunitpython-1.2.1 → archunitpython-1.4.0}/.gitattributes +0 -0
  29. {archunitpython-1.2.1 → archunitpython-1.4.0}/.github/CODEOWNERS +0 -0
  30. {archunitpython-1.2.1 → archunitpython-1.4.0}/.github/FUNDING.yml +0 -0
  31. {archunitpython-1.2.1 → archunitpython-1.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  32. {archunitpython-1.2.1 → archunitpython-1.4.0}/.github/ISSUE_TEMPLATE/documentation.md +0 -0
  33. {archunitpython-1.2.1 → archunitpython-1.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  34. {archunitpython-1.2.1 → archunitpython-1.4.0}/.github/ISSUE_TEMPLATE/question.md +0 -0
  35. {archunitpython-1.2.1 → archunitpython-1.4.0}/.github/PAGES.md +0 -0
  36. {archunitpython-1.2.1 → archunitpython-1.4.0}/.github/dependabot.yml +0 -0
  37. {archunitpython-1.2.1 → archunitpython-1.4.0}/.github/pull_request_template.md +0 -0
  38. {archunitpython-1.2.1 → archunitpython-1.4.0}/.github/workflows/docs.yaml +0 -0
  39. {archunitpython-1.2.1 → archunitpython-1.4.0}/.github/workflows/integrate.yaml +0 -0
  40. {archunitpython-1.2.1 → archunitpython-1.4.0}/.github/workflows/stale.yaml +0 -0
  41. {archunitpython-1.2.1 → archunitpython-1.4.0}/.gitignore +0 -0
  42. {archunitpython-1.2.1 → archunitpython-1.4.0}/.releaserc.json +0 -0
  43. {archunitpython-1.2.1 → archunitpython-1.4.0}/CONTRIBUTING.md +0 -0
  44. {archunitpython-1.2.1 → archunitpython-1.4.0}/LICENSE +0 -0
  45. {archunitpython-1.2.1 → archunitpython-1.4.0}/assets/logo-rounded.png +0 -0
  46. {archunitpython-1.2.1 → archunitpython-1.4.0}/research/product-direction/architecture-testing-landscape.md +0 -0
  47. {archunitpython-1.2.1 → archunitpython-1.4.0}/scripts/bump_release_version.py +0 -0
  48. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/assertion/__init__.py +0 -0
  49. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/assertion/violation.py +0 -0
  50. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/error/__init__.py +0 -0
  51. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/error/errors.py +0 -0
  52. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/extraction/__init__.py +0 -0
  53. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/extraction/graph.py +0 -0
  54. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/logging/__init__.py +0 -0
  55. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/logging/types.py +0 -0
  56. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/pattern_matching.py +0 -0
  57. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/projection/__init__.py +0 -0
  58. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/projection/cycles/__init__.py +0 -0
  59. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/projection/cycles/cycle_utils.py +0 -0
  60. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/projection/cycles/cycles.py +0 -0
  61. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/projection/cycles/johnsons_apsp.py +0 -0
  62. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/projection/cycles/model.py +0 -0
  63. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/projection/cycles/tarjan_scc.py +0 -0
  64. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/projection/edge_projections.py +0 -0
  65. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/projection/project_cycles.py +0 -0
  66. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/projection/project_edges.py +0 -0
  67. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/projection/project_nodes.py +0 -0
  68. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/projection/types.py +0 -0
  69. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/regex_factory.py +0 -0
  70. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/types.py +0 -0
  71. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/util/__init__.py +0 -0
  72. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/util/declaration_detector.py +0 -0
  73. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/common/util/logger.py +0 -0
  74. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/files/__init__.py +0 -0
  75. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/files/assertion/__init__.py +0 -0
  76. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/files/assertion/custom_file_logic.py +0 -0
  77. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/files/assertion/cycle_free.py +0 -0
  78. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/files/assertion/depend_on_external_modules.py +0 -0
  79. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/files/assertion/depend_on_files.py +0 -0
  80. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/files/assertion/matching_files.py +0 -0
  81. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/files/fluentapi/__init__.py +0 -0
  82. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/graph/__init__.py +0 -0
  83. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/graph/graph_reporter.py +0 -0
  84. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/layers/__init__.py +0 -0
  85. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/layers/assertion/__init__.py +0 -0
  86. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/layers/assertion/layer_dependencies.py +0 -0
  87. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/layers/fluentapi/__init__.py +0 -0
  88. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/layers/fluentapi/layers.py +0 -0
  89. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/metrics/__init__.py +0 -0
  90. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/metrics/assertion/__init__.py +0 -0
  91. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/metrics/assertion/metric_thresholds.py +0 -0
  92. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/metrics/calculation/__init__.py +0 -0
  93. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/metrics/calculation/count.py +0 -0
  94. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/metrics/calculation/distance.py +0 -0
  95. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/metrics/calculation/lcom.py +0 -0
  96. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/metrics/common/__init__.py +0 -0
  97. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/metrics/common/types.py +0 -0
  98. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/metrics/extraction/__init__.py +0 -0
  99. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/metrics/fluentapi/__init__.py +0 -0
  100. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/metrics/fluentapi/export_utils.py +0 -0
  101. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/metrics/projection/__init__.py +0 -0
  102. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/py.typed +0 -0
  103. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/slices/__init__.py +0 -0
  104. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/slices/assertion/__init__.py +0 -0
  105. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/slices/assertion/admissible_edges.py +0 -0
  106. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/slices/fluentapi/__init__.py +0 -0
  107. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/slices/projection/__init__.py +0 -0
  108. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/slices/projection/slicing_projections.py +0 -0
  109. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/slices/uml/__init__.py +0 -0
  110. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/slices/uml/export_diagram.py +0 -0
  111. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/slices/uml/generate_rules.py +0 -0
  112. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/testing/__init__.py +0 -0
  113. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/testing/common/__init__.py +0 -0
  114. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/testing/common/color_utils.py +0 -0
  115. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/testing/common/violation_factory.py +0 -0
  116. {archunitpython-1.2.1 → archunitpython-1.4.0}/src/archunitpython/testing/pytest_plugin/__init__.py +0 -0
  117. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/__init__.py +0 -0
  118. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/common/__init__.py +0 -0
  119. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/common/test_core_types.py +0 -0
  120. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/common/test_cycles.py +0 -0
  121. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/common/test_pattern_matching.py +0 -0
  122. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/common/test_projection.py +0 -0
  123. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/files/__init__.py +0 -0
  124. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/fixtures/metrics_project/service.py +0 -0
  125. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/fixtures/sample_project/__init__.py +0 -0
  126. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/fixtures/sample_project/architecture.puml +0 -0
  127. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/fixtures/sample_project/controllers/__init__.py +0 -0
  128. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/fixtures/sample_project/controllers/controller.py +0 -0
  129. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/fixtures/sample_project/models/__init__.py +0 -0
  130. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/fixtures/sample_project/models/model.py +0 -0
  131. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/fixtures/sample_project/services/__init__.py +0 -0
  132. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/fixtures/sample_project/services/service.py +0 -0
  133. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/fixtures/sample_project/services/service_a.py +0 -0
  134. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/fixtures/sample_project/services/service_b.py +0 -0
  135. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/fixtures/sample_project/utils/__init__.py +0 -0
  136. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/graph/test_graph_reporter.py +0 -0
  137. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/integration/__init__.py +0 -0
  138. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/layers/test_layers.py +0 -0
  139. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/metrics/__init__.py +0 -0
  140. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/metrics/test_export.py +0 -0
  141. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/slices/__init__.py +0 -0
  142. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/slices/test_slices.py +0 -0
  143. {archunitpython-1.2.1 → archunitpython-1.4.0}/tests/test_setup.py +0 -0
@@ -11,8 +11,8 @@ This backlog collects product and maintenance ideas from project research.
11
11
 
12
12
  ## P1 - Adoption Workflow
13
13
 
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.
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
16
  - Add configuration-file support for common rules, while keeping the fluent Python API as the primary interface.
17
17
  - Add support for monorepo and multi-package Python projects.
18
18
 
@@ -1,3 +1,23 @@
1
+ # [1.4.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.3.0...v1.4.0) (2026-07-18)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * clarify metadata and exclude messages ([999bb68](https://github.com/LukasNiessen/ArchUnitPython/commit/999bb689594ff613b5f4445191509bcdaa9a514f))
7
+ * harden archignore loading ([ae88c93](https://github.com/LukasNiessen/ArchUnitPython/commit/ae88c932ae2838cff0eabab687ac948f4920c3eb))
8
+
9
+
10
+ ### Features
11
+
12
+ * support archignore exclusions ([c51c401](https://github.com/LukasNiessen/ArchUnitPython/commit/c51c40148425b08ce0257d75dd95c996d0505b00))
13
+
14
+ # [1.3.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.2.1...v1.3.0) (2026-07-05)
15
+
16
+
17
+ ### Features
18
+
19
+ * add because rule rationales ([491c666](https://github.com/LukasNiessen/ArchUnitPython/commit/491c666f1f8a6ef2f5ccd782a4e4c79ed39321f6))
20
+
1
21
  ## [1.2.1](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.2.0...v1.2.1) (2026-06-28)
2
22
 
3
23
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: archunitpython
3
- Version: 1.2.1
3
+ Version: 1.4.0
4
4
  Summary: Architecture testing library for Python projects. Enforce dependency rules, detect cycles, validate metrics.
5
5
  Project-URL: Homepage, https://github.com/LukasNiessen/ArchUnitPython
6
6
  Project-URL: Repository, https://github.com/LukasNiessen/ArchUnitPython.git
@@ -188,6 +188,43 @@ options = CheckOptions(
188
188
  violations = rule.check(options)
189
189
  ```
190
190
 
191
+ ### Excluding Files With `.archignore`
192
+
193
+ Add a `.archignore` file to your project root to permanently exclude generated or
194
+ irrelevant files from architecture checks and file-based metrics:
195
+
196
+ ```gitignore
197
+ # Generated code
198
+ generated/
199
+
200
+ # Migration scripts
201
+ migrations/*.py
202
+
203
+ # A single root-level file
204
+ /legacy_adapter.py
205
+ ```
206
+
207
+ Patterns support comments, blank lines, glob syntax, root-relative paths, path
208
+ patterns, and directory patterns with a trailing `/`.
209
+ ### Explaining Rules With `.because(...)`
210
+
211
+ Attach a rationale to a rule so failing assertions explain why the rule exists:
212
+
213
+ ```python
214
+ rule = (
215
+ project_files("src/")
216
+ .in_folder("**/controllers/**")
217
+ .should_not()
218
+ .depend_on_files()
219
+ .in_folder("**/database/**")
220
+ .because("controllers should stay thin and delegate persistence")
221
+ )
222
+
223
+ assert_passes(rule)
224
+ ```
225
+
226
+ When the rule fails, the rationale is included in the assertion message.
227
+
191
228
  ## 🐹 Use Cases
192
229
 
193
230
  Here is an overview of common use cases.
@@ -431,21 +468,113 @@ def test_no_forbidden_dependency():
431
468
 
432
469
  Generate dependency graph reports in multiple formats and narrow them to the part of the codebase you want to inspect.
433
470
 
471
+ **Using [`requests`](https://github.com/psf/requests) library repo for example**
472
+
434
473
  ```python
435
474
  from archunitpython import project_graph
436
475
 
437
476
  def test_export_dependency_graph_reports():
438
- graph = project_graph("src/").titled("Application Architecture")
439
-
440
- graph.collapse_to_folder_depth(2).export_as_mermaid(
441
- "reports/dependencies.mmd"
442
- )
443
-
444
- graph.focus_on("**/domain/**", 1).export_as_html(
445
- "reports/domain-dependencies.html"
446
- )
447
-
448
- assert graph.snapshot().summary.node_count >= 0
477
+ graph = project_graph("src/requests").titled("Application Architecture")
478
+
479
+ graph.collapse_to_folder_depth(2).export_as_mermaid("reports/dependencies.md")
480
+
481
+ if __name__ == "__main__":
482
+ test_export_dependency_graph_reports()
483
+ ```
484
+ **Exported mermaid diagram**
485
+ ``` mermaid
486
+ flowchart LR
487
+ n0["__init__.py"]
488
+ n1["__version__.py"]
489
+ n2["_internal_utils.py"]
490
+ n3["_types.py"]
491
+ n4["adapters.py"]
492
+ n5["api.py"]
493
+ n6["auth.py"]
494
+ n7["certs.py"]
495
+ n8["compat.py"]
496
+ n9["cookies.py"]
497
+ n10["exceptions.py"]
498
+ n11["help.py"]
499
+ n12["hooks.py"]
500
+ n13["models.py"]
501
+ n14["packages.py"]
502
+ n15["sessions.py"]
503
+ n16["status_codes.py"]
504
+ n17["structures.py"]
505
+ n18["utils.py"]
506
+ n0 --> n1
507
+ n0 --> n5
508
+ n0 --> n10
509
+ n0 --> n13
510
+ n0 --> n15
511
+ n0 --> n16
512
+ n2 --> n8
513
+ n3 --> n6
514
+ n3 --> n9
515
+ n3 --> n13
516
+ n3 --> n17
517
+ n4 --> n0
518
+ n4 --> n3
519
+ n4 --> n6
520
+ n4 --> n8
521
+ n4 --> n9
522
+ n4 --> n10
523
+ n4 --> n13
524
+ n4 --> n17
525
+ n4 --> n18
526
+ n5 --> n0
527
+ n5 --> n13
528
+ n6 --> n2
529
+ n6 --> n8
530
+ n6 --> n9
531
+ n6 --> n13
532
+ n6 --> n18
533
+ n9 --> n2
534
+ n9 --> n3
535
+ n9 --> n8
536
+ n9 --> n13
537
+ n10 --> n8
538
+ n10 --> n13
539
+ n11 --> n0
540
+ n12 --> n0
541
+ n12 --> n13
542
+ n13 --> n0
543
+ n13 --> n2
544
+ n13 --> n4
545
+ n13 --> n6
546
+ n13 --> n8
547
+ n13 --> n9
548
+ n13 --> n10
549
+ n13 --> n12
550
+ n13 --> n16
551
+ n13 --> n17
552
+ n13 --> n18
553
+ n14 --> n8
554
+ n15 --> n0
555
+ n15 --> n2
556
+ n15 --> n3
557
+ n15 --> n4
558
+ n15 --> n6
559
+ n15 --> n8
560
+ n15 --> n9
561
+ n15 --> n10
562
+ n15 --> n12
563
+ n15 --> n13
564
+ n15 --> n16
565
+ n15 --> n17
566
+ n15 --> n18
567
+ n16 --> n17
568
+ n17 --> n8
569
+ n18 --> n0
570
+ n18 --> n1
571
+ n18 --> n2
572
+ n18 --> n3
573
+ n18 --> n8
574
+ n18 --> n9
575
+ n18 --> n10
576
+ n18 --> n13
577
+ n18 --> n17
449
578
  ```
450
579
 
451
580
  Supported formats:
@@ -155,6 +155,43 @@ options = CheckOptions(
155
155
  violations = rule.check(options)
156
156
  ```
157
157
 
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
+ ### Explaining Rules With `.because(...)`
177
+
178
+ Attach a rationale to a rule so failing assertions explain why the rule exists:
179
+
180
+ ```python
181
+ rule = (
182
+ project_files("src/")
183
+ .in_folder("**/controllers/**")
184
+ .should_not()
185
+ .depend_on_files()
186
+ .in_folder("**/database/**")
187
+ .because("controllers should stay thin and delegate persistence")
188
+ )
189
+
190
+ assert_passes(rule)
191
+ ```
192
+
193
+ When the rule fails, the rationale is included in the assertion message.
194
+
158
195
  ## 🐹 Use Cases
159
196
 
160
197
  Here is an overview of common use cases.
@@ -398,21 +435,113 @@ def test_no_forbidden_dependency():
398
435
 
399
436
  Generate dependency graph reports in multiple formats and narrow them to the part of the codebase you want to inspect.
400
437
 
438
+ **Using [`requests`](https://github.com/psf/requests) library repo for example**
439
+
401
440
  ```python
402
441
  from archunitpython import project_graph
403
442
 
404
443
  def test_export_dependency_graph_reports():
405
- graph = project_graph("src/").titled("Application Architecture")
406
-
407
- graph.collapse_to_folder_depth(2).export_as_mermaid(
408
- "reports/dependencies.mmd"
409
- )
410
-
411
- graph.focus_on("**/domain/**", 1).export_as_html(
412
- "reports/domain-dependencies.html"
413
- )
414
-
415
- assert graph.snapshot().summary.node_count >= 0
444
+ graph = project_graph("src/requests").titled("Application Architecture")
445
+
446
+ graph.collapse_to_folder_depth(2).export_as_mermaid("reports/dependencies.md")
447
+
448
+ if __name__ == "__main__":
449
+ test_export_dependency_graph_reports()
450
+ ```
451
+ **Exported mermaid diagram**
452
+ ``` mermaid
453
+ flowchart LR
454
+ n0["__init__.py"]
455
+ n1["__version__.py"]
456
+ n2["_internal_utils.py"]
457
+ n3["_types.py"]
458
+ n4["adapters.py"]
459
+ n5["api.py"]
460
+ n6["auth.py"]
461
+ n7["certs.py"]
462
+ n8["compat.py"]
463
+ n9["cookies.py"]
464
+ n10["exceptions.py"]
465
+ n11["help.py"]
466
+ n12["hooks.py"]
467
+ n13["models.py"]
468
+ n14["packages.py"]
469
+ n15["sessions.py"]
470
+ n16["status_codes.py"]
471
+ n17["structures.py"]
472
+ n18["utils.py"]
473
+ n0 --> n1
474
+ n0 --> n5
475
+ n0 --> n10
476
+ n0 --> n13
477
+ n0 --> n15
478
+ n0 --> n16
479
+ n2 --> n8
480
+ n3 --> n6
481
+ n3 --> n9
482
+ n3 --> n13
483
+ n3 --> n17
484
+ n4 --> n0
485
+ n4 --> n3
486
+ n4 --> n6
487
+ n4 --> n8
488
+ n4 --> n9
489
+ n4 --> n10
490
+ n4 --> n13
491
+ n4 --> n17
492
+ n4 --> n18
493
+ n5 --> n0
494
+ n5 --> n13
495
+ n6 --> n2
496
+ n6 --> n8
497
+ n6 --> n9
498
+ n6 --> n13
499
+ n6 --> n18
500
+ n9 --> n2
501
+ n9 --> n3
502
+ n9 --> n8
503
+ n9 --> n13
504
+ n10 --> n8
505
+ n10 --> n13
506
+ n11 --> n0
507
+ n12 --> n0
508
+ n12 --> n13
509
+ n13 --> n0
510
+ n13 --> n2
511
+ n13 --> n4
512
+ n13 --> n6
513
+ n13 --> n8
514
+ n13 --> n9
515
+ n13 --> n10
516
+ n13 --> n12
517
+ n13 --> n16
518
+ n13 --> n17
519
+ n13 --> n18
520
+ n14 --> n8
521
+ n15 --> n0
522
+ n15 --> n2
523
+ n15 --> n3
524
+ n15 --> n4
525
+ n15 --> n6
526
+ n15 --> n8
527
+ n15 --> n9
528
+ n15 --> n10
529
+ n15 --> n12
530
+ n15 --> n13
531
+ n15 --> n16
532
+ n15 --> n17
533
+ n15 --> n18
534
+ n16 --> n17
535
+ n17 --> n8
536
+ n18 --> n0
537
+ n18 --> n1
538
+ n18 --> n2
539
+ n18 --> n3
540
+ n18 --> n8
541
+ n18 --> n9
542
+ n18 --> n10
543
+ n18 --> n13
544
+ n18 --> n17
416
545
  ```
417
546
 
418
547
  Supported formats:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "archunitpython"
7
- version = "1.2.1"
7
+ version = "1.4.0"
8
8
  description = "Architecture testing library for Python projects. Enforce dependency rules, detect cycles, validate metrics."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -17,7 +17,7 @@ def read_project_version() -> str:
17
17
  content = PYPROJECT.read_text(encoding="utf-8")
18
18
  match = re.search(r'^version = "([^"]+)"$', content, re.MULTILINE)
19
19
  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")
21
21
  return match.group(1)
22
22
 
23
23
 
@@ -1,6 +1,6 @@
1
1
  """ArchUnitPython - Architecture testing library for Python projects."""
2
2
 
3
- __version__ = "1.2.1"
3
+ __version__ = "1.4.0"
4
4
 
5
5
  # Files API
6
6
  # Common
@@ -1,6 +1,10 @@
1
1
  from archunitpython.common.assertion.violation import EmptyTestViolation, Violation
2
2
  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
+ )
4
8
  from archunitpython.common.logging.types import LoggingOptions
5
9
  from archunitpython.common.types import Filter, Pattern, PatternMatchingOptions
6
10
 
@@ -11,6 +15,7 @@ __all__ = [
11
15
  "UserError",
12
16
  "Checkable",
13
17
  "CheckOptions",
18
+ "RuleRationaleMixin",
14
19
  "LoggingOptions",
15
20
  "Pattern",
16
21
  "Filter",
@@ -29,6 +29,8 @@ _DEFAULT_EXCLUDE = [
29
29
  "*.egg-info",
30
30
  ]
31
31
 
32
+ _ARCHIGNORE_FILE = ".archignore"
33
+
32
34
  _IGNORE_DIRECTIVE_REGEX = re.compile(
33
35
  r"#\s*archunit(?::|-)\s*ignore"
34
36
  r"(?:\([^)]*\))?"
@@ -88,9 +90,7 @@ def extract_graph(
88
90
  project_path = os.getcwd()
89
91
 
90
92
  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)
94
94
  ignore_type_checking_imports = bool(options and options.ignore_type_checking_imports)
95
95
  cache_key = _build_cache_key(project_path, excludes, ignore_type_checking_imports)
96
96
 
@@ -122,6 +122,34 @@ def _build_cache_key(
122
122
  )
123
123
 
124
124
 
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
+
125
153
  def _extract_graph_uncached(
126
154
  project_path: str,
127
155
  exclude_patterns: list[str],
@@ -160,7 +188,7 @@ def _extract_graph_uncached(
160
188
  if resolved and resolved != _normalize(file_path):
161
189
  # Check if the resolved path is in our project
162
190
  if not is_external and resolved not in normalized_py_file_set:
163
- is_external = True
191
+ continue
164
192
 
165
193
  edges.append(
166
194
  Edge(
@@ -182,25 +210,65 @@ def _normalize(path: str) -> str:
182
210
  def _find_python_files(root: str, exclude: list[str]) -> list[str]:
183
211
  """Recursively find all .py files, excluding specified patterns."""
184
212
  py_files: list[str] = []
213
+ root = os.path.abspath(root)
185
214
  for dirpath, dirnames, filenames in os.walk(root):
186
215
  # 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
+ ]
188
221
 
189
222
  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
+ ):
192
227
  py_files.append(os.path.abspath(full_path))
193
228
 
194
229
  return py_files
195
230
 
196
231
 
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."""
199
240
  import fnmatch
200
241
 
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):
203
270
  return True
271
+
204
272
  return False
205
273
 
206
274
 
@@ -0,0 +1,7 @@
1
+ from archunitpython.common.fluentapi.checkable import (
2
+ Checkable,
3
+ CheckOptions,
4
+ RuleRationaleMixin,
5
+ )
6
+
7
+ __all__ = ["Checkable", "CheckOptions", "RuleRationaleMixin"]
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass
6
- from typing import Protocol
6
+ from typing import Protocol, TypeVar
7
7
 
8
8
  from archunitpython.common.assertion.violation import Violation
9
9
  from archunitpython.common.logging.types import LoggingOptions
@@ -19,6 +19,28 @@ class CheckOptions:
19
19
  ignore_type_checking_imports: bool = False
20
20
 
21
21
 
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
+
22
44
  class Checkable(Protocol):
23
45
  """Protocol for any architecture rule that can be checked.
24
46
 
@@ -14,7 +14,7 @@ from collections.abc import Sequence
14
14
 
15
15
  from archunitpython.common.assertion.violation import EmptyTestViolation, Violation
16
16
  from archunitpython.common.extraction.extract_graph import extract_graph
17
- from archunitpython.common.fluentapi.checkable import CheckOptions
17
+ from archunitpython.common.fluentapi.checkable import CheckOptions, RuleRationaleMixin
18
18
  from archunitpython.common.pattern_matching import matches_all_patterns
19
19
  from archunitpython.common.projection.edge_projections import (
20
20
  per_external_edge,
@@ -322,7 +322,7 @@ def _check_empty_test(
322
322
  return None
323
323
 
324
324
 
325
- class CycleFreeFileCondition:
325
+ class CycleFreeFileCondition(RuleRationaleMixin):
326
326
  """Checkable that verifies no cycles exist among filtered files."""
327
327
 
328
328
  def __init__(self, project_path: str | None, filters: list[Filter]) -> None:
@@ -350,7 +350,7 @@ class CycleFreeFileCondition:
350
350
  return gather_cycle_violations(cycles)
351
351
 
352
352
 
353
- class DependOnFileCondition:
353
+ class DependOnFileCondition(RuleRationaleMixin):
354
354
  """Checkable that verifies file dependency rules."""
355
355
 
356
356
  def __init__(
@@ -377,7 +377,7 @@ class DependOnFileCondition:
377
377
  )
378
378
 
379
379
 
380
- class DependOnExternalModuleCondition:
380
+ class DependOnExternalModuleCondition(RuleRationaleMixin):
381
381
  """Checkable that verifies external module dependency rules."""
382
382
 
383
383
  def __init__(
@@ -409,7 +409,7 @@ class DependOnExternalModuleCondition:
409
409
  )
410
410
 
411
411
 
412
- class MatchPatternFileCondition:
412
+ class MatchPatternFileCondition(RuleRationaleMixin):
413
413
  """Checkable that verifies files match/don't match patterns."""
414
414
 
415
415
  def __init__(
@@ -434,7 +434,7 @@ class MatchPatternFileCondition:
434
434
  return gather_regex_matching_violations(nodes, self._check_filters, self._is_negated)
435
435
 
436
436
 
437
- class CustomFileCheckableCondition:
437
+ class CustomFileCheckableCondition(RuleRationaleMixin):
438
438
  """Checkable that evaluates a custom condition on files."""
439
439
 
440
440
  def __init__(
@@ -5,7 +5,10 @@ from __future__ import annotations
5
5
  import ast
6
6
  import os
7
7
 
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
+ )
9
12
  from archunitpython.metrics.common.types import (
10
13
  ClassInfo,
11
14
  EnhancedClassInfo,
@@ -33,7 +36,7 @@ def extract_class_info(
33
36
  project_path = os.getcwd()
34
37
 
35
38
  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)
37
40
  py_files = _find_python_files(project_path, excludes)
38
41
 
39
42
  classes: list[ClassInfo] = []
@@ -53,7 +56,7 @@ def extract_enhanced_class_info(
53
56
  project_path = os.getcwd()
54
57
 
55
58
  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)
57
60
  py_files = _find_python_files(project_path, excludes)
58
61
 
59
62
  results: list[FileAnalysisResult] = []