archunitpython 1.2.0__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.0 → archunitpython-1.3.0}/BACKLOG.md +1 -1
  2. {archunitpython-1.2.0 → archunitpython-1.3.0}/CHANGELOG.md +25 -0
  3. {archunitpython-1.2.0 → archunitpython-1.3.0}/PKG-INFO +127 -18
  4. {archunitpython-1.2.0 → archunitpython-1.3.0}/README.md +126 -17
  5. {archunitpython-1.2.0 → archunitpython-1.3.0}/pyproject.toml +1 -1
  6. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/__init__.py +1 -1
  7. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/__init__.py +6 -1
  8. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/extraction/extract_graph.py +8 -22
  9. archunitpython-1.3.0/src/archunitpython/common/fluentapi/__init__.py +7 -0
  10. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/fluentapi/checkable.py +23 -1
  11. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/pattern_matching.py +1 -3
  12. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/projection/cycles/johnsons_apsp.py +4 -9
  13. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/projection/cycles/tarjan_scc.py +2 -6
  14. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/projection/project_cycles.py +1 -2
  15. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/util/logger.py +1 -3
  16. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/files/assertion/custom_file_logic.py +3 -9
  17. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/files/assertion/depend_on_external_modules.py +3 -8
  18. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/files/assertion/depend_on_files.py +4 -12
  19. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/files/fluentapi/files.py +15 -33
  20. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/metrics/assertion/metric_thresholds.py +1 -3
  21. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/metrics/calculation/distance.py +1 -3
  22. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/metrics/extraction/extract_class_info.py +3 -9
  23. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/metrics/fluentapi/export_utils.py +2 -6
  24. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/metrics/fluentapi/metrics.py +19 -45
  25. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/slices/fluentapi/slices.py +7 -15
  26. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/slices/uml/generate_rules.py +2 -6
  27. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/testing/assertion.py +11 -3
  28. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/testing/common/violation_factory.py +1 -3
  29. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/common/test_extract_graph.py +5 -19
  30. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/files/test_files_fluentapi.py +36 -70
  31. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/fixtures/metrics_project/service.py +1 -2
  32. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/integration/test_e2e.py +30 -6
  33. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/metrics/test_export.py +2 -6
  34. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/metrics/test_metrics.py +5 -0
  35. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/metrics/test_metrics_fluentapi.py +53 -31
  36. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/slices/test_slices.py +36 -9
  37. archunitpython-1.2.0/src/archunitpython/common/fluentapi/__init__.py +0 -3
  38. {archunitpython-1.2.0 → archunitpython-1.3.0}/.editorconfig +0 -0
  39. {archunitpython-1.2.0 → archunitpython-1.3.0}/.gitattributes +0 -0
  40. {archunitpython-1.2.0 → archunitpython-1.3.0}/.github/CODEOWNERS +0 -0
  41. {archunitpython-1.2.0 → archunitpython-1.3.0}/.github/FUNDING.yml +0 -0
  42. {archunitpython-1.2.0 → archunitpython-1.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  43. {archunitpython-1.2.0 → archunitpython-1.3.0}/.github/ISSUE_TEMPLATE/documentation.md +0 -0
  44. {archunitpython-1.2.0 → archunitpython-1.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  45. {archunitpython-1.2.0 → archunitpython-1.3.0}/.github/ISSUE_TEMPLATE/question.md +0 -0
  46. {archunitpython-1.2.0 → archunitpython-1.3.0}/.github/PAGES.md +0 -0
  47. {archunitpython-1.2.0 → archunitpython-1.3.0}/.github/dependabot.yml +0 -0
  48. {archunitpython-1.2.0 → archunitpython-1.3.0}/.github/pull_request_template.md +0 -0
  49. {archunitpython-1.2.0 → archunitpython-1.3.0}/.github/workflows/docs.yaml +0 -0
  50. {archunitpython-1.2.0 → archunitpython-1.3.0}/.github/workflows/integrate.yaml +0 -0
  51. {archunitpython-1.2.0 → archunitpython-1.3.0}/.github/workflows/stale.yaml +0 -0
  52. {archunitpython-1.2.0 → archunitpython-1.3.0}/.gitignore +0 -0
  53. {archunitpython-1.2.0 → archunitpython-1.3.0}/.releaserc.json +0 -0
  54. {archunitpython-1.2.0 → archunitpython-1.3.0}/CONTRIBUTING.md +0 -0
  55. {archunitpython-1.2.0 → archunitpython-1.3.0}/LICENSE +0 -0
  56. {archunitpython-1.2.0 → archunitpython-1.3.0}/assets/logo-rounded.png +0 -0
  57. {archunitpython-1.2.0 → archunitpython-1.3.0}/research/product-direction/architecture-testing-landscape.md +0 -0
  58. {archunitpython-1.2.0 → archunitpython-1.3.0}/scripts/bump_release_version.py +0 -0
  59. {archunitpython-1.2.0 → archunitpython-1.3.0}/scripts/check_release_metadata.py +0 -0
  60. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/assertion/__init__.py +0 -0
  61. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/assertion/violation.py +0 -0
  62. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/error/__init__.py +0 -0
  63. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/error/errors.py +0 -0
  64. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/extraction/__init__.py +0 -0
  65. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/extraction/graph.py +0 -0
  66. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/logging/__init__.py +0 -0
  67. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/logging/types.py +0 -0
  68. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/projection/__init__.py +0 -0
  69. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/projection/cycles/__init__.py +0 -0
  70. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/projection/cycles/cycle_utils.py +0 -0
  71. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/projection/cycles/cycles.py +0 -0
  72. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/projection/cycles/model.py +0 -0
  73. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/projection/edge_projections.py +0 -0
  74. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/projection/project_edges.py +0 -0
  75. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/projection/project_nodes.py +0 -0
  76. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/projection/types.py +0 -0
  77. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/regex_factory.py +0 -0
  78. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/types.py +0 -0
  79. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/util/__init__.py +0 -0
  80. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/common/util/declaration_detector.py +0 -0
  81. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/files/__init__.py +0 -0
  82. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/files/assertion/__init__.py +0 -0
  83. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/files/assertion/cycle_free.py +0 -0
  84. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/files/assertion/matching_files.py +0 -0
  85. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/files/fluentapi/__init__.py +0 -0
  86. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/graph/__init__.py +0 -0
  87. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/graph/graph_reporter.py +0 -0
  88. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/layers/__init__.py +0 -0
  89. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/layers/assertion/__init__.py +0 -0
  90. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/layers/assertion/layer_dependencies.py +0 -0
  91. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/layers/fluentapi/__init__.py +0 -0
  92. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/layers/fluentapi/layers.py +0 -0
  93. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/metrics/__init__.py +0 -0
  94. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/metrics/assertion/__init__.py +0 -0
  95. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/metrics/calculation/__init__.py +0 -0
  96. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/metrics/calculation/count.py +0 -0
  97. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/metrics/calculation/lcom.py +0 -0
  98. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/metrics/common/__init__.py +0 -0
  99. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/metrics/common/types.py +0 -0
  100. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/metrics/extraction/__init__.py +0 -0
  101. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/metrics/fluentapi/__init__.py +0 -0
  102. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/metrics/projection/__init__.py +0 -0
  103. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/py.typed +0 -0
  104. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/slices/__init__.py +0 -0
  105. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/slices/assertion/__init__.py +0 -0
  106. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/slices/assertion/admissible_edges.py +0 -0
  107. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/slices/fluentapi/__init__.py +0 -0
  108. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/slices/projection/__init__.py +0 -0
  109. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/slices/projection/slicing_projections.py +0 -0
  110. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/slices/uml/__init__.py +0 -0
  111. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/slices/uml/export_diagram.py +0 -0
  112. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/testing/__init__.py +0 -0
  113. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/testing/common/__init__.py +0 -0
  114. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/testing/common/color_utils.py +0 -0
  115. {archunitpython-1.2.0 → archunitpython-1.3.0}/src/archunitpython/testing/pytest_plugin/__init__.py +0 -0
  116. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/__init__.py +0 -0
  117. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/common/__init__.py +0 -0
  118. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/common/test_core_types.py +0 -0
  119. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/common/test_cycles.py +0 -0
  120. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/common/test_declaration_detector.py +0 -0
  121. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/common/test_logger.py +0 -0
  122. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/common/test_pattern_matching.py +0 -0
  123. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/common/test_projection.py +0 -0
  124. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/files/__init__.py +0 -0
  125. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/files/test_file_assertions.py +0 -0
  126. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/fixtures/sample_project/__init__.py +0 -0
  127. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/fixtures/sample_project/architecture.puml +0 -0
  128. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/fixtures/sample_project/controllers/__init__.py +0 -0
  129. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/fixtures/sample_project/controllers/controller.py +0 -0
  130. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/fixtures/sample_project/models/__init__.py +0 -0
  131. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/fixtures/sample_project/models/model.py +0 -0
  132. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/fixtures/sample_project/services/__init__.py +0 -0
  133. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/fixtures/sample_project/services/service.py +0 -0
  134. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/fixtures/sample_project/services/service_a.py +0 -0
  135. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/fixtures/sample_project/services/service_b.py +0 -0
  136. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/fixtures/sample_project/utils/__init__.py +0 -0
  137. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/fixtures/sample_project/utils/helpers.py +0 -0
  138. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/graph/test_graph_reporter.py +0 -0
  139. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/integration/__init__.py +0 -0
  140. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/layers/test_layers.py +0 -0
  141. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/metrics/__init__.py +0 -0
  142. {archunitpython-1.2.0 → archunitpython-1.3.0}/tests/slices/__init__.py +0 -0
  143. {archunitpython-1.2.0 → 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,28 @@
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
+
8
+ ## [1.2.1](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.2.0...v1.2.1) (2026-06-28)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **extract_graph:** validate AST line number before range lookup ([84c9ef4](https://github.com/LukasNiessen/ArchUnitPython/commit/84c9ef4a97333ed1fa302778725ab6d5ba891574))
14
+
15
+
16
+ ### Performance Improvements
17
+
18
+ * **extract_graph:** optimize file path normalization ([14b4a95](https://github.com/LukasNiessen/ArchUnitPython/commit/14b4a955576d4ee2464c8c2cceb71f28b9135198))
19
+ * **extract_graph:** replace linear search in `_in_type_checking()` with binary search ([1285b5e](https://github.com/LukasNiessen/ArchUnitPython/commit/1285b5e5ac513d7897d42b5e6c1fcb0bd83c1835))
20
+
21
+
22
+ ### Reverts
23
+
24
+ * Reverted the binary search implementation in `_in_type_checking()` function. ([d06a455](https://github.com/LukasNiessen/ArchUnitPython/commit/d06a45569fdfe5eab5e6825f6046c773b66d61c7))
25
+
1
26
  # [1.2.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.1.2...v1.2.0) (2026-06-27)
2
27
 
3
28
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: archunitpython
3
- Version: 1.2.0
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
@@ -39,11 +39,9 @@ Description-Content-Type: text/markdown
39
39
  <!-- spacer -->
40
40
  <p></p>
41
41
 
42
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
43
- [![PyPI version](https://img.shields.io/pypi/v/archunitpython.svg)](https://pypi.org/project/archunitpython/)
44
- [![Downloads](https://static.pepy.tech/badge/archunitpython)](https://pepy.tech/project/archunitpython)
45
- [![Monthly downloads](https://static.pepy.tech/badge/archunitpython/month)](https://pepy.tech/project/archunitpython)
46
- [![GitHub stars](https://img.shields.io/github/stars/LukasNiessen/ArchUnitPython.svg)](https://github.com/LukasNiessen/ArchUnitPython)
42
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Build & tests](https://img.shields.io/github/actions/workflow/status/LukasNiessen/ArchUnitPython/integrate.yaml?branch=main&label=build%20%26%20tests)](https://github.com/LukasNiessen/ArchUnitPython/actions/workflows/integrate.yaml) [![GitHub stars](https://img.shields.io/github/stars/LukasNiessen/ArchUnitPython.svg)](https://github.com/LukasNiessen/ArchUnitPython)<br>
43
+ [![PyPI downloads](https://static.pepy.tech/badge/archunitpython/month)](https://pepy.tech/project/archunitpython) [![PyPI total downloads](https://img.shields.io/pepy/dt/archunitpython?label=total%20downloads&color=007ec6)](https://pepy.tech/project/archunitpython)
44
+ <!-- [![PyPI version](https://img.shields.io/pypi/v/archunitpython.svg)](https://pypi.org/project/archunitpython/) -->
47
45
 
48
46
  </div>
49
47
 
@@ -53,7 +51,7 @@ The #1 ArchUnit-style architecture testing library for Python, measured by GitHu
53
51
 
54
52
  _Inspired by the amazing ArchUnit library but we are not affiliated with ArchUnit._
55
53
 
56
- [Setup](#-setup) • [Use Cases](#-use-cases) • [Features](#-features) • [Why ArchUnitPython?](#-library-comparison) • [Contributing](CONTRIBUTING.md)
54
+ [Setup](#-setup) • [Use Cases](#-use-cases) • [Features](#-features) • [Why ArchUnitPython?](#-library-comparison) • [Sponsor](https://github.com/sponsors/LukasNiessen) • [Contributing](CONTRIBUTING.md)
57
55
 
58
56
  ## ⚡ 5 min Quickstart
59
57
 
@@ -190,6 +188,25 @@ options = CheckOptions(
190
188
  violations = rule.check(options)
191
189
  ```
192
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
+
193
210
  ## 🐹 Use Cases
194
211
 
195
212
  Here is an overview of common use cases.
@@ -433,21 +450,113 @@ def test_no_forbidden_dependency():
433
450
 
434
451
  Generate dependency graph reports in multiple formats and narrow them to the part of the codebase you want to inspect.
435
452
 
453
+ **Using `requests` library repo for example**
454
+
436
455
  ```python
437
456
  from archunitpython import project_graph
438
457
 
439
458
  def test_export_dependency_graph_reports():
440
- graph = project_graph("src/").titled("Application Architecture")
441
-
442
- graph.collapse_to_folder_depth(2).export_as_mermaid(
443
- "reports/dependencies.mmd"
444
- )
445
-
446
- graph.focus_on("**/domain/**", 1).export_as_html(
447
- "reports/domain-dependencies.html"
448
- )
449
-
450
- 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
451
560
  ```
452
561
 
453
562
  Supported formats:
@@ -6,11 +6,9 @@
6
6
  <!-- spacer -->
7
7
  <p></p>
8
8
 
9
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
10
- [![PyPI version](https://img.shields.io/pypi/v/archunitpython.svg)](https://pypi.org/project/archunitpython/)
11
- [![Downloads](https://static.pepy.tech/badge/archunitpython)](https://pepy.tech/project/archunitpython)
12
- [![Monthly downloads](https://static.pepy.tech/badge/archunitpython/month)](https://pepy.tech/project/archunitpython)
13
- [![GitHub stars](https://img.shields.io/github/stars/LukasNiessen/ArchUnitPython.svg)](https://github.com/LukasNiessen/ArchUnitPython)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Build & tests](https://img.shields.io/github/actions/workflow/status/LukasNiessen/ArchUnitPython/integrate.yaml?branch=main&label=build%20%26%20tests)](https://github.com/LukasNiessen/ArchUnitPython/actions/workflows/integrate.yaml) [![GitHub stars](https://img.shields.io/github/stars/LukasNiessen/ArchUnitPython.svg)](https://github.com/LukasNiessen/ArchUnitPython)<br>
10
+ [![PyPI downloads](https://static.pepy.tech/badge/archunitpython/month)](https://pepy.tech/project/archunitpython) [![PyPI total downloads](https://img.shields.io/pepy/dt/archunitpython?label=total%20downloads&color=007ec6)](https://pepy.tech/project/archunitpython)
11
+ <!-- [![PyPI version](https://img.shields.io/pypi/v/archunitpython.svg)](https://pypi.org/project/archunitpython/) -->
14
12
 
15
13
  </div>
16
14
 
@@ -20,7 +18,7 @@ The #1 ArchUnit-style architecture testing library for Python, measured by GitHu
20
18
 
21
19
  _Inspired by the amazing ArchUnit library but we are not affiliated with ArchUnit._
22
20
 
23
- [Setup](#-setup) • [Use Cases](#-use-cases) • [Features](#-features) • [Why ArchUnitPython?](#-library-comparison) • [Contributing](CONTRIBUTING.md)
21
+ [Setup](#-setup) • [Use Cases](#-use-cases) • [Features](#-features) • [Why ArchUnitPython?](#-library-comparison) • [Sponsor](https://github.com/sponsors/LukasNiessen) • [Contributing](CONTRIBUTING.md)
24
22
 
25
23
  ## ⚡ 5 min Quickstart
26
24
 
@@ -157,6 +155,25 @@ options = CheckOptions(
157
155
  violations = rule.check(options)
158
156
  ```
159
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
+
160
177
  ## 🐹 Use Cases
161
178
 
162
179
  Here is an overview of common use cases.
@@ -400,21 +417,113 @@ def test_no_forbidden_dependency():
400
417
 
401
418
  Generate dependency graph reports in multiple formats and narrow them to the part of the codebase you want to inspect.
402
419
 
420
+ **Using `requests` library repo for example**
421
+
403
422
  ```python
404
423
  from archunitpython import project_graph
405
424
 
406
425
  def test_export_dependency_graph_reports():
407
- graph = project_graph("src/").titled("Application Architecture")
408
-
409
- graph.collapse_to_folder_depth(2).export_as_mermaid(
410
- "reports/dependencies.mmd"
411
- )
412
-
413
- graph.focus_on("**/domain/**", 1).export_as_html(
414
- "reports/domain-dependencies.html"
415
- )
416
-
417
- 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
418
527
  ```
419
528
 
420
529
  Supported formats:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "archunitpython"
7
- version = "1.2.0"
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.0"
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",
@@ -91,12 +91,8 @@ def extract_graph(
91
91
  excludes = (
92
92
  list(set(exclude_patterns)) if exclude_patterns is not None else list(_DEFAULT_EXCLUDE)
93
93
  )
94
- ignore_type_checking_imports = bool(
95
- options and options.ignore_type_checking_imports
96
- )
97
- cache_key = _build_cache_key(
98
- project_path, excludes, ignore_type_checking_imports
99
- )
94
+ ignore_type_checking_imports = bool(options and options.ignore_type_checking_imports)
95
+ cache_key = _build_cache_key(project_path, excludes, ignore_type_checking_imports)
100
96
 
101
97
  if options and options.clear_cache:
102
98
  _graph_cache.pop(cache_key, None)
@@ -137,6 +133,7 @@ def _extract_graph_uncached(
137
133
 
138
134
  edges: list[Edge] = []
139
135
  py_files_set = set(py_files)
136
+ normalized_py_file_set = {_normalize(f) for f in py_files_set}
140
137
 
141
138
  for file_path in py_files:
142
139
  # Add self-referencing edge (ensures the file appears as a node)
@@ -148,7 +145,6 @@ def _extract_graph_uncached(
148
145
  )
149
146
  )
150
147
 
151
- # Extract and resolve imports
152
148
  imports = _extract_located_imports(file_path)
153
149
  for located_import in imports:
154
150
  module_name = located_import.module_name
@@ -163,9 +159,7 @@ def _extract_graph_uncached(
163
159
  )
164
160
  if resolved and resolved != _normalize(file_path):
165
161
  # Check if the resolved path is in our project
166
- if not is_external and resolved not in {
167
- _normalize(f) for f in py_files_set
168
- }:
162
+ if not is_external and resolved not in normalized_py_file_set:
169
163
  is_external = True
170
164
 
171
165
  edges.append(
@@ -190,11 +184,7 @@ def _find_python_files(root: str, exclude: list[str]) -> list[str]:
190
184
  py_files: list[str] = []
191
185
  for dirpath, dirnames, filenames in os.walk(root):
192
186
  # Filter out excluded directories in-place
193
- dirnames[:] = [
194
- d
195
- for d in dirnames
196
- if not _should_exclude(d, exclude)
197
- ]
187
+ dirnames[:] = [d for d in dirnames if not _should_exclude(d, exclude)]
198
188
 
199
189
  for filename in filenames:
200
190
  if filename.endswith(".py") and not _should_exclude(filename, exclude):
@@ -349,18 +339,14 @@ def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
349
339
  if is_type_checking and node.body:
350
340
  start = node.body[0].lineno
351
341
  end = max(
352
- getattr(n, "end_lineno", n.lineno)
353
- for n in node.body
354
- if hasattr(n, "lineno")
342
+ getattr(n, "end_lineno", n.lineno) for n in node.body if hasattr(n, "lineno")
355
343
  )
356
344
  ranges.append((start, end))
357
345
 
358
- return ranges
346
+ return sorted(ranges, key=lambda ele: ele[0])
359
347
 
360
348
 
361
- def _in_type_checking(
362
- node: ast.AST, ranges: list[tuple[int, int]]
363
- ) -> bool:
349
+ def _in_type_checking(node: ast.AST, ranges: list[tuple[int, int]]) -> bool:
364
350
  """Check if a node is inside a TYPE_CHECKING block."""
365
351
  if not hasattr(node, "lineno"):
366
352
  return False
@@ -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
 
@@ -50,9 +50,7 @@ def matches_pattern(file_path: str, filter_: Filter) -> bool:
50
50
  return bool(filter_.regexp.search(target_string))
51
51
 
52
52
 
53
- def matches_pattern_classname(
54
- class_name: str, file_path: str, filter_: Filter
55
- ) -> bool:
53
+ def matches_pattern_classname(class_name: str, file_path: str, filter_: Filter) -> bool:
56
54
  """Check if a class/file matches a filter, supporting classname target."""
57
55
  target = filter_.options.target
58
56
 
@@ -53,13 +53,9 @@ class JohnsonsAPSP:
53
53
  if self._is_part_of_current_start_cycle(current_node):
54
54
  self._unblock(current_node)
55
55
  else:
56
- for neighbour in CycleUtils.get_outgoing_neighbours(
57
- current_node, self._graph
58
- ):
56
+ for neighbour in CycleUtils.get_outgoing_neighbours(current_node, self._graph):
59
57
  if self._is_blocked(neighbour):
60
- self._blocked_map.append(
61
- _BlockedBy(blocked=current_node, by=neighbour)
62
- )
58
+ self._blocked_map.append(_BlockedBy(blocked=current_node, by=neighbour))
63
59
 
64
60
  def _unblock(self, node: NumberNode) -> None:
65
61
  self._blocked = [n for n in self._blocked if n is not node]
@@ -74,9 +70,8 @@ class JohnsonsAPSP:
74
70
  if self._start is None:
75
71
  return False
76
72
  for cycle in self._cycles:
77
- if (
78
- cycle[0].from_node == self._start.node
79
- and any(e.from_node == current_node.node for e in cycle)
73
+ if cycle[0].from_node == self._start.node and any(
74
+ e.from_node == current_node.node for e in cycle
80
75
  ):
81
76
  return True
82
77
  return False
@@ -18,9 +18,7 @@ class _Vertex:
18
18
  class TarjanSCC:
19
19
  """Tarjan's algorithm for finding strongly connected components."""
20
20
 
21
- def find_strongly_connected_components(
22
- self, edges: list[NumberEdge]
23
- ) -> list[list[NumberEdge]]:
21
+ def find_strongly_connected_components(self, edges: list[NumberEdge]) -> list[list[NumberEdge]]:
24
22
  """Find all strongly connected components in the graph.
25
23
 
26
24
  Returns a list of edge lists, where each inner list contains
@@ -78,9 +76,7 @@ class TarjanSCC:
78
76
  if scc_vertices:
79
77
  scc_ids = {v.id for v in scc_vertices}
80
78
  scc_edges = [
81
- e
82
- for e in self._edges
83
- if e.from_node in scc_ids and e.to_node in scc_ids
79
+ e for e in self._edges if e.from_node in scc_ids and e.to_node in scc_ids
84
80
  ]
85
81
  if scc_edges:
86
82
  self._sccs.append(scc_edges)
@@ -72,8 +72,7 @@ class _CycleProcessor:
72
72
  (
73
73
  se
74
74
  for se in self._source_edges
75
- if se.source_label == source_label
76
- and se.target_label == target_label
75
+ if se.source_label == source_label and se.target_label == target_label
77
76
  ),
78
77
  None,
79
78
  )
@@ -44,9 +44,7 @@ class CheckLogger:
44
44
 
45
45
  mode = "a" if options.append_to_log_file else "w"
46
46
  self._file_handler = logging.FileHandler(str(log_path), mode=mode)
47
- self._file_handler.setFormatter(
48
- logging.Formatter("[%(levelname)s] %(message)s")
49
- )
47
+ self._file_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
50
48
  self._logger.addHandler(self._file_handler)
51
49
 
52
50
  def _log(self, level: str, options: LoggingOptions | None, message: str) -> None:
@@ -83,9 +83,7 @@ def gather_custom_file_violations(
83
83
 
84
84
  for node in nodes:
85
85
  # Check if node matches all pre-filters
86
- if pre_filters and not all(
87
- matches_pattern(node.label, f) for f in pre_filters
88
- ):
86
+ if pre_filters and not all(matches_pattern(node.label, f) for f in pre_filters):
89
87
  continue
90
88
 
91
89
  file_info = _build_file_info(node.label)
@@ -94,14 +92,10 @@ def gather_custom_file_violations(
94
92
  if is_negated:
95
93
  # shouldNot: violation if condition IS True
96
94
  if result:
97
- violations.append(
98
- CustomFileViolation(message=message, file_info=file_info)
99
- )
95
+ violations.append(CustomFileViolation(message=message, file_info=file_info))
100
96
  else:
101
97
  # should: violation if condition is NOT True
102
98
  if not result:
103
- violations.append(
104
- CustomFileViolation(message=message, file_info=file_info)
105
- )
99
+ violations.append(CustomFileViolation(message=message, file_info=file_info))
106
100
 
107
101
  return violations