archunitpython 1.2.1__tar.gz → 1.3.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.3.0}/BACKLOG.md +1 -1
  2. {archunitpython-1.2.1 → archunitpython-1.3.0}/CHANGELOG.md +7 -0
  3. {archunitpython-1.2.1 → archunitpython-1.3.0}/PKG-INFO +123 -12
  4. {archunitpython-1.2.1 → archunitpython-1.3.0}/README.md +122 -11
  5. {archunitpython-1.2.1 → archunitpython-1.3.0}/pyproject.toml +1 -1
  6. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/__init__.py +1 -1
  7. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/__init__.py +6 -1
  8. archunitpython-1.3.0/src/archunitpython/common/fluentapi/__init__.py +7 -0
  9. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/fluentapi/checkable.py +23 -1
  10. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/files/fluentapi/files.py +6 -6
  11. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/metrics/fluentapi/metrics.py +7 -7
  12. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/slices/fluentapi/slices.py +3 -3
  13. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/testing/assertion.py +11 -3
  14. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/files/test_files_fluentapi.py +19 -0
  15. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/integration/test_e2e.py +25 -0
  16. archunitpython-1.2.1/src/archunitpython/common/fluentapi/__init__.py +0 -3
  17. {archunitpython-1.2.1 → archunitpython-1.3.0}/.editorconfig +0 -0
  18. {archunitpython-1.2.1 → archunitpython-1.3.0}/.gitattributes +0 -0
  19. {archunitpython-1.2.1 → archunitpython-1.3.0}/.github/CODEOWNERS +0 -0
  20. {archunitpython-1.2.1 → archunitpython-1.3.0}/.github/FUNDING.yml +0 -0
  21. {archunitpython-1.2.1 → archunitpython-1.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  22. {archunitpython-1.2.1 → archunitpython-1.3.0}/.github/ISSUE_TEMPLATE/documentation.md +0 -0
  23. {archunitpython-1.2.1 → archunitpython-1.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  24. {archunitpython-1.2.1 → archunitpython-1.3.0}/.github/ISSUE_TEMPLATE/question.md +0 -0
  25. {archunitpython-1.2.1 → archunitpython-1.3.0}/.github/PAGES.md +0 -0
  26. {archunitpython-1.2.1 → archunitpython-1.3.0}/.github/dependabot.yml +0 -0
  27. {archunitpython-1.2.1 → archunitpython-1.3.0}/.github/pull_request_template.md +0 -0
  28. {archunitpython-1.2.1 → archunitpython-1.3.0}/.github/workflows/docs.yaml +0 -0
  29. {archunitpython-1.2.1 → archunitpython-1.3.0}/.github/workflows/integrate.yaml +0 -0
  30. {archunitpython-1.2.1 → archunitpython-1.3.0}/.github/workflows/stale.yaml +0 -0
  31. {archunitpython-1.2.1 → archunitpython-1.3.0}/.gitignore +0 -0
  32. {archunitpython-1.2.1 → archunitpython-1.3.0}/.releaserc.json +0 -0
  33. {archunitpython-1.2.1 → archunitpython-1.3.0}/CONTRIBUTING.md +0 -0
  34. {archunitpython-1.2.1 → archunitpython-1.3.0}/LICENSE +0 -0
  35. {archunitpython-1.2.1 → archunitpython-1.3.0}/assets/logo-rounded.png +0 -0
  36. {archunitpython-1.2.1 → archunitpython-1.3.0}/research/product-direction/architecture-testing-landscape.md +0 -0
  37. {archunitpython-1.2.1 → archunitpython-1.3.0}/scripts/bump_release_version.py +0 -0
  38. {archunitpython-1.2.1 → archunitpython-1.3.0}/scripts/check_release_metadata.py +0 -0
  39. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/assertion/__init__.py +0 -0
  40. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/assertion/violation.py +0 -0
  41. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/error/__init__.py +0 -0
  42. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/error/errors.py +0 -0
  43. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/extraction/__init__.py +0 -0
  44. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/extraction/extract_graph.py +0 -0
  45. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/extraction/graph.py +0 -0
  46. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/logging/__init__.py +0 -0
  47. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/logging/types.py +0 -0
  48. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/pattern_matching.py +0 -0
  49. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/projection/__init__.py +0 -0
  50. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/projection/cycles/__init__.py +0 -0
  51. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/projection/cycles/cycle_utils.py +0 -0
  52. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/projection/cycles/cycles.py +0 -0
  53. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/projection/cycles/johnsons_apsp.py +0 -0
  54. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/projection/cycles/model.py +0 -0
  55. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/projection/cycles/tarjan_scc.py +0 -0
  56. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/projection/edge_projections.py +0 -0
  57. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/projection/project_cycles.py +0 -0
  58. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/projection/project_edges.py +0 -0
  59. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/projection/project_nodes.py +0 -0
  60. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/projection/types.py +0 -0
  61. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/regex_factory.py +0 -0
  62. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/types.py +0 -0
  63. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/util/__init__.py +0 -0
  64. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/util/declaration_detector.py +0 -0
  65. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/common/util/logger.py +0 -0
  66. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/files/__init__.py +0 -0
  67. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/files/assertion/__init__.py +0 -0
  68. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/files/assertion/custom_file_logic.py +0 -0
  69. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/files/assertion/cycle_free.py +0 -0
  70. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/files/assertion/depend_on_external_modules.py +0 -0
  71. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/files/assertion/depend_on_files.py +0 -0
  72. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/files/assertion/matching_files.py +0 -0
  73. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/files/fluentapi/__init__.py +0 -0
  74. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/graph/__init__.py +0 -0
  75. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/graph/graph_reporter.py +0 -0
  76. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/layers/__init__.py +0 -0
  77. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/layers/assertion/__init__.py +0 -0
  78. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/layers/assertion/layer_dependencies.py +0 -0
  79. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/layers/fluentapi/__init__.py +0 -0
  80. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/layers/fluentapi/layers.py +0 -0
  81. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/metrics/__init__.py +0 -0
  82. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/metrics/assertion/__init__.py +0 -0
  83. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/metrics/assertion/metric_thresholds.py +0 -0
  84. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/metrics/calculation/__init__.py +0 -0
  85. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/metrics/calculation/count.py +0 -0
  86. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/metrics/calculation/distance.py +0 -0
  87. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/metrics/calculation/lcom.py +0 -0
  88. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/metrics/common/__init__.py +0 -0
  89. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/metrics/common/types.py +0 -0
  90. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/metrics/extraction/__init__.py +0 -0
  91. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/metrics/extraction/extract_class_info.py +0 -0
  92. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/metrics/fluentapi/__init__.py +0 -0
  93. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/metrics/fluentapi/export_utils.py +0 -0
  94. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/metrics/projection/__init__.py +0 -0
  95. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/py.typed +0 -0
  96. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/slices/__init__.py +0 -0
  97. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/slices/assertion/__init__.py +0 -0
  98. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/slices/assertion/admissible_edges.py +0 -0
  99. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/slices/fluentapi/__init__.py +0 -0
  100. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/slices/projection/__init__.py +0 -0
  101. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/slices/projection/slicing_projections.py +0 -0
  102. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/slices/uml/__init__.py +0 -0
  103. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/slices/uml/export_diagram.py +0 -0
  104. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/slices/uml/generate_rules.py +0 -0
  105. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/testing/__init__.py +0 -0
  106. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/testing/common/__init__.py +0 -0
  107. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/testing/common/color_utils.py +0 -0
  108. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/testing/common/violation_factory.py +0 -0
  109. {archunitpython-1.2.1 → archunitpython-1.3.0}/src/archunitpython/testing/pytest_plugin/__init__.py +0 -0
  110. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/__init__.py +0 -0
  111. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/common/__init__.py +0 -0
  112. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/common/test_core_types.py +0 -0
  113. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/common/test_cycles.py +0 -0
  114. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/common/test_declaration_detector.py +0 -0
  115. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/common/test_extract_graph.py +0 -0
  116. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/common/test_logger.py +0 -0
  117. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/common/test_pattern_matching.py +0 -0
  118. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/common/test_projection.py +0 -0
  119. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/files/__init__.py +0 -0
  120. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/files/test_file_assertions.py +0 -0
  121. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/fixtures/metrics_project/service.py +0 -0
  122. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/fixtures/sample_project/__init__.py +0 -0
  123. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/fixtures/sample_project/architecture.puml +0 -0
  124. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/fixtures/sample_project/controllers/__init__.py +0 -0
  125. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/fixtures/sample_project/controllers/controller.py +0 -0
  126. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/fixtures/sample_project/models/__init__.py +0 -0
  127. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/fixtures/sample_project/models/model.py +0 -0
  128. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/fixtures/sample_project/services/__init__.py +0 -0
  129. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/fixtures/sample_project/services/service.py +0 -0
  130. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/fixtures/sample_project/services/service_a.py +0 -0
  131. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/fixtures/sample_project/services/service_b.py +0 -0
  132. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/fixtures/sample_project/utils/__init__.py +0 -0
  133. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/fixtures/sample_project/utils/helpers.py +0 -0
  134. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/graph/test_graph_reporter.py +0 -0
  135. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/integration/__init__.py +0 -0
  136. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/layers/test_layers.py +0 -0
  137. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/metrics/__init__.py +0 -0
  138. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/metrics/test_export.py +0 -0
  139. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/metrics/test_metrics.py +0 -0
  140. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/metrics/test_metrics_fluentapi.py +0 -0
  141. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/slices/__init__.py +0 -0
  142. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/slices/test_slices.py +0 -0
  143. {archunitpython-1.2.1 → archunitpython-1.3.0}/tests/test_setup.py +0 -0
@@ -12,7 +12,7 @@ This backlog collects product and maintenance ideas from project research.
12
12
  ## P1 - Adoption Workflow
13
13
 
14
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.
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,10 @@
1
+ # [1.3.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.2.1...v1.3.0) (2026-07-05)
2
+
3
+
4
+ ### Features
5
+
6
+ * add because rule rationales ([491c666](https://github.com/LukasNiessen/ArchUnitPython/commit/491c666f1f8a6ef2f5ccd782a4e4c79ed39321f6))
7
+
1
8
  ## [1.2.1](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.2.0...v1.2.1) (2026-06-28)
2
9
 
3
10
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: archunitpython
3
- Version: 1.2.1
3
+ Version: 1.3.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,25 @@ options = CheckOptions(
188
188
  violations = rule.check(options)
189
189
  ```
190
190
 
191
+ ### Explaining Rules With `.because(...)`
192
+
193
+ Attach a rationale to a rule so failing assertions explain why the rule exists:
194
+
195
+ ```python
196
+ rule = (
197
+ project_files("src/")
198
+ .in_folder("**/controllers/**")
199
+ .should_not()
200
+ .depend_on_files()
201
+ .in_folder("**/database/**")
202
+ .because("controllers should stay thin and delegate persistence")
203
+ )
204
+
205
+ assert_passes(rule)
206
+ ```
207
+
208
+ When the rule fails, the rationale is included in the assertion message.
209
+
191
210
  ## 🐹 Use Cases
192
211
 
193
212
  Here is an overview of common use cases.
@@ -431,21 +450,113 @@ def test_no_forbidden_dependency():
431
450
 
432
451
  Generate dependency graph reports in multiple formats and narrow them to the part of the codebase you want to inspect.
433
452
 
453
+ **Using `requests` library repo for example**
454
+
434
455
  ```python
435
456
  from archunitpython import project_graph
436
457
 
437
458
  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
459
+ graph = project_graph("src/requests").titled("Application Architecture")
460
+
461
+ graph.collapse_to_folder_depth(2).export_as_mermaid("reports/dependencies.md")
462
+
463
+ if __name__ == "__main__":
464
+ test_export_dependency_graph_reports()
465
+ ```
466
+ **Rendered mermain diagram**
467
+ ``` mermaid
468
+ flowchart LR
469
+ n0["__init__.py"]
470
+ n1["__version__.py"]
471
+ n2["_internal_utils.py"]
472
+ n3["_types.py"]
473
+ n4["adapters.py"]
474
+ n5["api.py"]
475
+ n6["auth.py"]
476
+ n7["certs.py"]
477
+ n8["compat.py"]
478
+ n9["cookies.py"]
479
+ n10["exceptions.py"]
480
+ n11["help.py"]
481
+ n12["hooks.py"]
482
+ n13["models.py"]
483
+ n14["packages.py"]
484
+ n15["sessions.py"]
485
+ n16["status_codes.py"]
486
+ n17["structures.py"]
487
+ n18["utils.py"]
488
+ n0 --> n1
489
+ n0 --> n5
490
+ n0 --> n10
491
+ n0 --> n13
492
+ n0 --> n15
493
+ n0 --> n16
494
+ n2 --> n8
495
+ n3 --> n6
496
+ n3 --> n9
497
+ n3 --> n13
498
+ n3 --> n17
499
+ n4 --> n0
500
+ n4 --> n3
501
+ n4 --> n6
502
+ n4 --> n8
503
+ n4 --> n9
504
+ n4 --> n10
505
+ n4 --> n13
506
+ n4 --> n17
507
+ n4 --> n18
508
+ n5 --> n0
509
+ n5 --> n13
510
+ n6 --> n2
511
+ n6 --> n8
512
+ n6 --> n9
513
+ n6 --> n13
514
+ n6 --> n18
515
+ n9 --> n2
516
+ n9 --> n3
517
+ n9 --> n8
518
+ n9 --> n13
519
+ n10 --> n8
520
+ n10 --> n13
521
+ n11 --> n0
522
+ n12 --> n0
523
+ n12 --> n13
524
+ n13 --> n0
525
+ n13 --> n2
526
+ n13 --> n4
527
+ n13 --> n6
528
+ n13 --> n8
529
+ n13 --> n9
530
+ n13 --> n10
531
+ n13 --> n12
532
+ n13 --> n16
533
+ n13 --> n17
534
+ n13 --> n18
535
+ n14 --> n8
536
+ n15 --> n0
537
+ n15 --> n2
538
+ n15 --> n3
539
+ n15 --> n4
540
+ n15 --> n6
541
+ n15 --> n8
542
+ n15 --> n9
543
+ n15 --> n10
544
+ n15 --> n12
545
+ n15 --> n13
546
+ n15 --> n16
547
+ n15 --> n17
548
+ n15 --> n18
549
+ n16 --> n17
550
+ n17 --> n8
551
+ n18 --> n0
552
+ n18 --> n1
553
+ n18 --> n2
554
+ n18 --> n3
555
+ n18 --> n8
556
+ n18 --> n9
557
+ n18 --> n10
558
+ n18 --> n13
559
+ n18 --> n17
449
560
  ```
450
561
 
451
562
  Supported formats:
@@ -155,6 +155,25 @@ options = CheckOptions(
155
155
  violations = rule.check(options)
156
156
  ```
157
157
 
158
+ ### Explaining Rules With `.because(...)`
159
+
160
+ Attach a rationale to a rule so failing assertions explain why the rule exists:
161
+
162
+ ```python
163
+ rule = (
164
+ project_files("src/")
165
+ .in_folder("**/controllers/**")
166
+ .should_not()
167
+ .depend_on_files()
168
+ .in_folder("**/database/**")
169
+ .because("controllers should stay thin and delegate persistence")
170
+ )
171
+
172
+ assert_passes(rule)
173
+ ```
174
+
175
+ When the rule fails, the rationale is included in the assertion message.
176
+
158
177
  ## 🐹 Use Cases
159
178
 
160
179
  Here is an overview of common use cases.
@@ -398,21 +417,113 @@ def test_no_forbidden_dependency():
398
417
 
399
418
  Generate dependency graph reports in multiple formats and narrow them to the part of the codebase you want to inspect.
400
419
 
420
+ **Using `requests` library repo for example**
421
+
401
422
  ```python
402
423
  from archunitpython import project_graph
403
424
 
404
425
  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
426
+ graph = project_graph("src/requests").titled("Application Architecture")
427
+
428
+ graph.collapse_to_folder_depth(2).export_as_mermaid("reports/dependencies.md")
429
+
430
+ if __name__ == "__main__":
431
+ test_export_dependency_graph_reports()
432
+ ```
433
+ **Rendered mermain diagram**
434
+ ``` mermaid
435
+ flowchart LR
436
+ n0["__init__.py"]
437
+ n1["__version__.py"]
438
+ n2["_internal_utils.py"]
439
+ n3["_types.py"]
440
+ n4["adapters.py"]
441
+ n5["api.py"]
442
+ n6["auth.py"]
443
+ n7["certs.py"]
444
+ n8["compat.py"]
445
+ n9["cookies.py"]
446
+ n10["exceptions.py"]
447
+ n11["help.py"]
448
+ n12["hooks.py"]
449
+ n13["models.py"]
450
+ n14["packages.py"]
451
+ n15["sessions.py"]
452
+ n16["status_codes.py"]
453
+ n17["structures.py"]
454
+ n18["utils.py"]
455
+ n0 --> n1
456
+ n0 --> n5
457
+ n0 --> n10
458
+ n0 --> n13
459
+ n0 --> n15
460
+ n0 --> n16
461
+ n2 --> n8
462
+ n3 --> n6
463
+ n3 --> n9
464
+ n3 --> n13
465
+ n3 --> n17
466
+ n4 --> n0
467
+ n4 --> n3
468
+ n4 --> n6
469
+ n4 --> n8
470
+ n4 --> n9
471
+ n4 --> n10
472
+ n4 --> n13
473
+ n4 --> n17
474
+ n4 --> n18
475
+ n5 --> n0
476
+ n5 --> n13
477
+ n6 --> n2
478
+ n6 --> n8
479
+ n6 --> n9
480
+ n6 --> n13
481
+ n6 --> n18
482
+ n9 --> n2
483
+ n9 --> n3
484
+ n9 --> n8
485
+ n9 --> n13
486
+ n10 --> n8
487
+ n10 --> n13
488
+ n11 --> n0
489
+ n12 --> n0
490
+ n12 --> n13
491
+ n13 --> n0
492
+ n13 --> n2
493
+ n13 --> n4
494
+ n13 --> n6
495
+ n13 --> n8
496
+ n13 --> n9
497
+ n13 --> n10
498
+ n13 --> n12
499
+ n13 --> n16
500
+ n13 --> n17
501
+ n13 --> n18
502
+ n14 --> n8
503
+ n15 --> n0
504
+ n15 --> n2
505
+ n15 --> n3
506
+ n15 --> n4
507
+ n15 --> n6
508
+ n15 --> n8
509
+ n15 --> n9
510
+ n15 --> n10
511
+ n15 --> n12
512
+ n15 --> n13
513
+ n15 --> n16
514
+ n15 --> n17
515
+ n15 --> n18
516
+ n16 --> n17
517
+ n17 --> n8
518
+ n18 --> n0
519
+ n18 --> n1
520
+ n18 --> n2
521
+ n18 --> n3
522
+ n18 --> n8
523
+ n18 --> n9
524
+ n18 --> n10
525
+ n18 --> n13
526
+ n18 --> n17
416
527
  ```
417
528
 
418
529
  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.3.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"
@@ -1,6 +1,6 @@
1
1
  """ArchUnitPython - Architecture testing library for Python projects."""
2
2
 
3
- __version__ = "1.2.1"
3
+ __version__ = "1.3.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",
@@ -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__(
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
  from typing import Any, Callable
12
12
 
13
13
  from archunitpython.common.assertion.violation import Violation
14
- from archunitpython.common.fluentapi.checkable import CheckOptions
14
+ from archunitpython.common.fluentapi.checkable import CheckOptions, RuleRationaleMixin
15
15
  from archunitpython.common.pattern_matching import matches_pattern_classname
16
16
  from archunitpython.common.regex_factory import RegexFactory
17
17
  from archunitpython.common.types import Filter, Pattern
@@ -170,7 +170,7 @@ class ClassMetricThresholdBuilder:
170
170
  )
171
171
 
172
172
 
173
- class ClassMetricCondition:
173
+ class ClassMetricCondition(RuleRationaleMixin):
174
174
  """Checkable that verifies a class-level metric threshold."""
175
175
 
176
176
  def __init__(
@@ -230,7 +230,7 @@ class FileMetricThresholdBuilder:
230
230
  )
231
231
 
232
232
 
233
- class FileMetricCondition:
233
+ class FileMetricCondition(RuleRationaleMixin):
234
234
  """Checkable that verifies a file-level metric threshold."""
235
235
 
236
236
  def __init__(
@@ -364,7 +364,7 @@ class DistanceThresholdBuilder:
364
364
  )
365
365
 
366
366
 
367
- class DistanceCondition:
367
+ class DistanceCondition(RuleRationaleMixin):
368
368
  """Checkable for distance metric thresholds."""
369
369
 
370
370
  def __init__(
@@ -404,7 +404,7 @@ class DistanceCondition:
404
404
  return violations
405
405
 
406
406
 
407
- class ZoneCondition:
407
+ class ZoneCondition(RuleRationaleMixin):
408
408
  """Checkable for zone detection (pain/uselessness)."""
409
409
 
410
410
  def __init__(self, project_path: str | None, filters: list[Filter], zone_type: str) -> None:
@@ -485,7 +485,7 @@ class CustomMetricsBuilder:
485
485
  )
486
486
 
487
487
 
488
- class CustomMetricCondition:
488
+ class CustomMetricCondition(RuleRationaleMixin):
489
489
  """Checkable for custom metric thresholds."""
490
490
 
491
491
  def __init__(
@@ -525,7 +525,7 @@ class CustomMetricCondition:
525
525
  return violations
526
526
 
527
527
 
528
- class CustomAssertionCondition:
528
+ class CustomAssertionCondition(RuleRationaleMixin):
529
529
  """Checkable for custom metric assertions."""
530
530
 
531
531
  def __init__(
@@ -14,7 +14,7 @@ import re
14
14
 
15
15
  from archunitpython.common.assertion.violation import 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.projection.project_edges import project_edges
19
19
  from archunitpython.common.projection.types import MapFunction
20
20
  from archunitpython.slices.assertion.admissible_edges import (
@@ -140,7 +140,7 @@ class NegativeConditionBuilder:
140
140
  )
141
141
 
142
142
 
143
- class PositiveSliceCondition:
143
+ class PositiveSliceCondition(RuleRationaleMixin):
144
144
  """Checkable that verifies slices adhere to a diagram."""
145
145
 
146
146
  def __init__(
@@ -176,7 +176,7 @@ class PositiveSliceCondition:
176
176
  return identity()
177
177
 
178
178
 
179
- class NegativeSliceCondition:
179
+ class NegativeSliceCondition(RuleRationaleMixin):
180
180
  """Checkable that verifies a specific dependency does NOT exist."""
181
181
 
182
182
  def __init__(
@@ -7,7 +7,11 @@ from archunitpython.common.fluentapi.checkable import Checkable, CheckOptions
7
7
  from archunitpython.testing.common.violation_factory import ViolationFactory
8
8
 
9
9
 
10
- def format_violations(violations: list[Violation]) -> str:
10
+ def format_violations(
11
+ violations: list[Violation],
12
+ *,
13
+ because: str | None = None,
14
+ ) -> str:
11
15
  """Format violations into a human-readable string.
12
16
 
13
17
  Args:
@@ -19,7 +23,10 @@ def format_violations(violations: list[Violation]) -> str:
19
23
  if not violations:
20
24
  return "No violations found."
21
25
 
22
- lines = [f"Found {len(violations)} architecture violation(s):", ""]
26
+ lines = [f"Found {len(violations)} architecture violation(s):"]
27
+ if because:
28
+ lines.extend(["", f"Because: {because}"])
29
+ lines.append("")
23
30
  for i, violation in enumerate(violations, 1):
24
31
  tv = ViolationFactory.from_violation(violation)
25
32
  lines.append(f" {i}. {tv.message}")
@@ -44,4 +51,5 @@ def assert_passes(
44
51
  """
45
52
  violations = checkable.check(options)
46
53
  if violations:
47
- raise AssertionError(format_violations(violations))
54
+ because = getattr(checkable, "because_reason", None)
55
+ raise AssertionError(format_violations(violations, because=because))
@@ -5,6 +5,8 @@ import shutil
5
5
  from pathlib import Path
6
6
  from uuid import uuid4
7
7
 
8
+ import pytest
9
+
8
10
  from archunitpython.common.assertion.violation import EmptyTestViolation
9
11
  from archunitpython.common.extraction.extract_graph import clear_graph_cache
10
12
  from archunitpython.files.assertion.custom_file_logic import CustomFileViolation
@@ -223,6 +225,23 @@ class TestMethodChaining:
223
225
  cycle_violations = [v for v in violations if isinstance(v, ViolatingCycle)]
224
226
  assert len(cycle_violations) == 0
225
227
 
228
+ def test_because_adds_rule_rationale(self):
229
+ rule = (
230
+ project_files(FIXTURES_DIR)
231
+ .in_folder("**/services*")
232
+ .should()
233
+ .have_no_cycles()
234
+ .because("service cycles are hard to refactor")
235
+ )
236
+
237
+ assert rule.because_reason == "service cycles are hard to refactor"
238
+
239
+ def test_because_rejects_empty_rationale(self):
240
+ rule = project_files(FIXTURES_DIR).should().have_no_cycles()
241
+
242
+ with pytest.raises(ValueError, match="must not be empty"):
243
+ rule.because(" ")
244
+
226
245
 
227
246
  class TestTypeCheckingImports:
228
247
  def setup_method(self):
@@ -83,6 +83,19 @@ class TestAssertPasses:
83
83
  with pytest.raises(AssertionError, match="architecture violation"):
84
84
  assert_passes(rule)
85
85
 
86
+ def test_failing_rule_includes_because_rationale(self):
87
+ rule = (
88
+ project_files(FIXTURES_DIR)
89
+ .in_folder("**/controllers*")
90
+ .should_not()
91
+ .depend_on_files()
92
+ .in_folder("**/services*")
93
+ .because("controllers should stay thin")
94
+ )
95
+
96
+ with pytest.raises(AssertionError, match="Because: controllers should stay thin"):
97
+ assert_passes(rule)
98
+
86
99
 
87
100
  class TestFormatViolations:
88
101
  def test_no_violations(self):
@@ -102,6 +115,18 @@ class TestFormatViolations:
102
115
  assert "1 architecture violation" in result
103
116
  assert "Circular dependency" in result
104
117
 
118
+ def test_with_because_rationale(self):
119
+ from archunitpython.common.projection.types import ProjectedEdge
120
+
121
+ violation = ViolatingCycle(
122
+ cycle=[
123
+ ProjectedEdge(source_label="a.py", target_label="b.py"),
124
+ ProjectedEdge(source_label="b.py", target_label="a.py"),
125
+ ]
126
+ )
127
+ result = format_violations([violation], because="cycles make changes risky")
128
+ assert "Because: cycles make changes risky" in result
129
+
105
130
 
106
131
  class TestSelfTesting:
107
132
  """ArchUnitPython tests its own architecture."""
@@ -1,3 +0,0 @@
1
- from archunitpython.common.fluentapi.checkable import Checkable, CheckOptions
2
-
3
- __all__ = ["Checkable", "CheckOptions"]
File without changes