archunitpython 1.2.0__tar.gz → 1.2.1__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 (142) hide show
  1. {archunitpython-1.2.0 → archunitpython-1.2.1}/CHANGELOG.md +18 -0
  2. {archunitpython-1.2.0 → archunitpython-1.2.1}/PKG-INFO +5 -7
  3. {archunitpython-1.2.0 → archunitpython-1.2.1}/README.md +4 -6
  4. {archunitpython-1.2.0 → archunitpython-1.2.1}/pyproject.toml +1 -1
  5. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/__init__.py +1 -1
  6. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/extraction/extract_graph.py +8 -22
  7. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/pattern_matching.py +1 -3
  8. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/projection/cycles/johnsons_apsp.py +4 -9
  9. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/projection/cycles/tarjan_scc.py +2 -6
  10. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/projection/project_cycles.py +1 -2
  11. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/util/logger.py +1 -3
  12. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/files/assertion/custom_file_logic.py +3 -9
  13. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/files/assertion/depend_on_external_modules.py +3 -8
  14. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/files/assertion/depend_on_files.py +4 -12
  15. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/files/fluentapi/files.py +9 -27
  16. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/metrics/assertion/metric_thresholds.py +1 -3
  17. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/metrics/calculation/distance.py +1 -3
  18. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/metrics/extraction/extract_class_info.py +3 -9
  19. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/metrics/fluentapi/export_utils.py +2 -6
  20. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/metrics/fluentapi/metrics.py +12 -38
  21. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/slices/fluentapi/slices.py +4 -12
  22. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/slices/uml/generate_rules.py +2 -6
  23. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/testing/common/violation_factory.py +1 -3
  24. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/common/test_extract_graph.py +5 -19
  25. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/files/test_files_fluentapi.py +17 -70
  26. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/fixtures/metrics_project/service.py +1 -2
  27. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/integration/test_e2e.py +5 -6
  28. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/metrics/test_export.py +2 -6
  29. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/metrics/test_metrics.py +5 -0
  30. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/metrics/test_metrics_fluentapi.py +53 -31
  31. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/slices/test_slices.py +36 -9
  32. {archunitpython-1.2.0 → archunitpython-1.2.1}/.editorconfig +0 -0
  33. {archunitpython-1.2.0 → archunitpython-1.2.1}/.gitattributes +0 -0
  34. {archunitpython-1.2.0 → archunitpython-1.2.1}/.github/CODEOWNERS +0 -0
  35. {archunitpython-1.2.0 → archunitpython-1.2.1}/.github/FUNDING.yml +0 -0
  36. {archunitpython-1.2.0 → archunitpython-1.2.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  37. {archunitpython-1.2.0 → archunitpython-1.2.1}/.github/ISSUE_TEMPLATE/documentation.md +0 -0
  38. {archunitpython-1.2.0 → archunitpython-1.2.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  39. {archunitpython-1.2.0 → archunitpython-1.2.1}/.github/ISSUE_TEMPLATE/question.md +0 -0
  40. {archunitpython-1.2.0 → archunitpython-1.2.1}/.github/PAGES.md +0 -0
  41. {archunitpython-1.2.0 → archunitpython-1.2.1}/.github/dependabot.yml +0 -0
  42. {archunitpython-1.2.0 → archunitpython-1.2.1}/.github/pull_request_template.md +0 -0
  43. {archunitpython-1.2.0 → archunitpython-1.2.1}/.github/workflows/docs.yaml +0 -0
  44. {archunitpython-1.2.0 → archunitpython-1.2.1}/.github/workflows/integrate.yaml +0 -0
  45. {archunitpython-1.2.0 → archunitpython-1.2.1}/.github/workflows/stale.yaml +0 -0
  46. {archunitpython-1.2.0 → archunitpython-1.2.1}/.gitignore +0 -0
  47. {archunitpython-1.2.0 → archunitpython-1.2.1}/.releaserc.json +0 -0
  48. {archunitpython-1.2.0 → archunitpython-1.2.1}/BACKLOG.md +0 -0
  49. {archunitpython-1.2.0 → archunitpython-1.2.1}/CONTRIBUTING.md +0 -0
  50. {archunitpython-1.2.0 → archunitpython-1.2.1}/LICENSE +0 -0
  51. {archunitpython-1.2.0 → archunitpython-1.2.1}/assets/logo-rounded.png +0 -0
  52. {archunitpython-1.2.0 → archunitpython-1.2.1}/research/product-direction/architecture-testing-landscape.md +0 -0
  53. {archunitpython-1.2.0 → archunitpython-1.2.1}/scripts/bump_release_version.py +0 -0
  54. {archunitpython-1.2.0 → archunitpython-1.2.1}/scripts/check_release_metadata.py +0 -0
  55. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/__init__.py +0 -0
  56. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/assertion/__init__.py +0 -0
  57. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/assertion/violation.py +0 -0
  58. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/error/__init__.py +0 -0
  59. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/error/errors.py +0 -0
  60. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/extraction/__init__.py +0 -0
  61. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/extraction/graph.py +0 -0
  62. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/fluentapi/__init__.py +0 -0
  63. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/fluentapi/checkable.py +0 -0
  64. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/logging/__init__.py +0 -0
  65. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/logging/types.py +0 -0
  66. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/projection/__init__.py +0 -0
  67. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/projection/cycles/__init__.py +0 -0
  68. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/projection/cycles/cycle_utils.py +0 -0
  69. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/projection/cycles/cycles.py +0 -0
  70. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/projection/cycles/model.py +0 -0
  71. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/projection/edge_projections.py +0 -0
  72. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/projection/project_edges.py +0 -0
  73. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/projection/project_nodes.py +0 -0
  74. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/projection/types.py +0 -0
  75. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/regex_factory.py +0 -0
  76. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/types.py +0 -0
  77. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/util/__init__.py +0 -0
  78. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/common/util/declaration_detector.py +0 -0
  79. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/files/__init__.py +0 -0
  80. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/files/assertion/__init__.py +0 -0
  81. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/files/assertion/cycle_free.py +0 -0
  82. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/files/assertion/matching_files.py +0 -0
  83. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/files/fluentapi/__init__.py +0 -0
  84. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/graph/__init__.py +0 -0
  85. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/graph/graph_reporter.py +0 -0
  86. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/layers/__init__.py +0 -0
  87. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/layers/assertion/__init__.py +0 -0
  88. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/layers/assertion/layer_dependencies.py +0 -0
  89. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/layers/fluentapi/__init__.py +0 -0
  90. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/layers/fluentapi/layers.py +0 -0
  91. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/metrics/__init__.py +0 -0
  92. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/metrics/assertion/__init__.py +0 -0
  93. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/metrics/calculation/__init__.py +0 -0
  94. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/metrics/calculation/count.py +0 -0
  95. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/metrics/calculation/lcom.py +0 -0
  96. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/metrics/common/__init__.py +0 -0
  97. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/metrics/common/types.py +0 -0
  98. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/metrics/extraction/__init__.py +0 -0
  99. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/metrics/fluentapi/__init__.py +0 -0
  100. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/metrics/projection/__init__.py +0 -0
  101. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/py.typed +0 -0
  102. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/slices/__init__.py +0 -0
  103. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/slices/assertion/__init__.py +0 -0
  104. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/slices/assertion/admissible_edges.py +0 -0
  105. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/slices/fluentapi/__init__.py +0 -0
  106. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/slices/projection/__init__.py +0 -0
  107. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/slices/projection/slicing_projections.py +0 -0
  108. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/slices/uml/__init__.py +0 -0
  109. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/slices/uml/export_diagram.py +0 -0
  110. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/testing/__init__.py +0 -0
  111. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/testing/assertion.py +0 -0
  112. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/testing/common/__init__.py +0 -0
  113. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/testing/common/color_utils.py +0 -0
  114. {archunitpython-1.2.0 → archunitpython-1.2.1}/src/archunitpython/testing/pytest_plugin/__init__.py +0 -0
  115. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/__init__.py +0 -0
  116. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/common/__init__.py +0 -0
  117. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/common/test_core_types.py +0 -0
  118. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/common/test_cycles.py +0 -0
  119. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/common/test_declaration_detector.py +0 -0
  120. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/common/test_logger.py +0 -0
  121. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/common/test_pattern_matching.py +0 -0
  122. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/common/test_projection.py +0 -0
  123. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/files/__init__.py +0 -0
  124. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/files/test_file_assertions.py +0 -0
  125. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/fixtures/sample_project/__init__.py +0 -0
  126. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/fixtures/sample_project/architecture.puml +0 -0
  127. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/fixtures/sample_project/controllers/__init__.py +0 -0
  128. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/fixtures/sample_project/controllers/controller.py +0 -0
  129. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/fixtures/sample_project/models/__init__.py +0 -0
  130. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/fixtures/sample_project/models/model.py +0 -0
  131. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/fixtures/sample_project/services/__init__.py +0 -0
  132. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/fixtures/sample_project/services/service.py +0 -0
  133. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/fixtures/sample_project/services/service_a.py +0 -0
  134. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/fixtures/sample_project/services/service_b.py +0 -0
  135. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/fixtures/sample_project/utils/__init__.py +0 -0
  136. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/fixtures/sample_project/utils/helpers.py +0 -0
  137. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/graph/test_graph_reporter.py +0 -0
  138. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/integration/__init__.py +0 -0
  139. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/layers/test_layers.py +0 -0
  140. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/metrics/__init__.py +0 -0
  141. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/slices/__init__.py +0 -0
  142. {archunitpython-1.2.0 → archunitpython-1.2.1}/tests/test_setup.py +0 -0
@@ -1,3 +1,21 @@
1
+ ## [1.2.1](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.2.0...v1.2.1) (2026-06-28)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **extract_graph:** validate AST line number before range lookup ([84c9ef4](https://github.com/LukasNiessen/ArchUnitPython/commit/84c9ef4a97333ed1fa302778725ab6d5ba891574))
7
+
8
+
9
+ ### Performance Improvements
10
+
11
+ * **extract_graph:** optimize file path normalization ([14b4a95](https://github.com/LukasNiessen/ArchUnitPython/commit/14b4a955576d4ee2464c8c2cceb71f28b9135198))
12
+ * **extract_graph:** replace linear search in `_in_type_checking()` with binary search ([1285b5e](https://github.com/LukasNiessen/ArchUnitPython/commit/1285b5e5ac513d7897d42b5e6c1fcb0bd83c1835))
13
+
14
+
15
+ ### Reverts
16
+
17
+ * Reverted the binary search implementation in `_in_type_checking()` function. ([d06a455](https://github.com/LukasNiessen/ArchUnitPython/commit/d06a45569fdfe5eab5e6825f6046c773b66d61c7))
18
+
1
19
  # [1.2.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.1.2...v1.2.0) (2026-06-27)
2
20
 
3
21
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: archunitpython
3
- Version: 1.2.0
3
+ Version: 1.2.1
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
 
@@ -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
 
@@ -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.2.1"
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.2.1"
4
4
 
5
5
  # Files API
6
6
  # Common
@@ -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
@@ -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
@@ -34,8 +34,7 @@ def gather_depend_on_external_module_violations(
34
34
 
35
35
  for edge in edges:
36
36
  source_matches = all(
37
- matches_pattern(edge.source_label, filter_)
38
- for filter_ in subject_filters
37
+ matches_pattern(edge.source_label, filter_) for filter_ in subject_filters
39
38
  )
40
39
  if not source_matches:
41
40
  continue
@@ -49,16 +48,12 @@ def gather_depend_on_external_module_violations(
49
48
  if is_negated:
50
49
  if target_matches:
51
50
  violations.append(
52
- ViolatingExternalModuleDependency(
53
- dependency=edge, is_negated=True
54
- )
51
+ ViolatingExternalModuleDependency(dependency=edge, is_negated=True)
55
52
  )
56
53
  else:
57
54
  if not target_matches:
58
55
  violations.append(
59
- ViolatingExternalModuleDependency(
60
- dependency=edge, is_negated=False
61
- )
56
+ ViolatingExternalModuleDependency(dependency=edge, is_negated=False)
62
57
  )
63
58
 
64
59
  return violations
@@ -41,27 +41,19 @@ def gather_depend_on_file_violations(
41
41
  violations: list[Violation] = []
42
42
 
43
43
  for edge in edges:
44
- source_matches = all(
45
- matches_pattern(edge.source_label, f) for f in subject_filters
46
- )
44
+ source_matches = all(matches_pattern(edge.source_label, f) for f in subject_filters)
47
45
  if not source_matches:
48
46
  continue
49
47
 
50
- target_matches = all(
51
- matches_pattern(edge.target_label, f) for f in object_filters
52
- )
48
+ target_matches = all(matches_pattern(edge.target_label, f) for f in object_filters)
53
49
 
54
50
  if is_negated:
55
51
  # shouldNot: violation if dependency EXISTS
56
52
  if target_matches:
57
- violations.append(
58
- ViolatingFileDependency(dependency=edge, is_negated=True)
59
- )
53
+ violations.append(ViolatingFileDependency(dependency=edge, is_negated=True))
60
54
  else:
61
55
  # should: violation if dependency does NOT match
62
56
  if not target_matches:
63
- violations.append(
64
- ViolatingFileDependency(dependency=edge, is_negated=False)
65
- )
57
+ violations.append(ViolatingFileDependency(dependency=edge, is_negated=False))
66
58
 
67
59
  return violations
@@ -76,15 +76,11 @@ class FileConditionBuilder:
76
76
 
77
77
  def should(self) -> "PositiveMatchPatternFileConditionBuilder":
78
78
  """Begin positive assertion (files SHOULD ...)."""
79
- return PositiveMatchPatternFileConditionBuilder(
80
- self._project_path, list(self._filters)
81
- )
79
+ return PositiveMatchPatternFileConditionBuilder(self._project_path, list(self._filters))
82
80
 
83
81
  def should_not(self) -> "NegatedMatchPatternFileConditionBuilder":
84
82
  """Begin negative assertion (files SHOULD NOT ...)."""
85
- return NegatedMatchPatternFileConditionBuilder(
86
- self._project_path, list(self._filters)
87
- )
83
+ return NegatedMatchPatternFileConditionBuilder(self._project_path, list(self._filters))
88
84
 
89
85
 
90
86
  class FilesShouldCondition:
@@ -111,15 +107,11 @@ class FilesShouldCondition:
111
107
 
112
108
  def should(self) -> "PositiveMatchPatternFileConditionBuilder":
113
109
  """Begin positive assertion (files SHOULD ...)."""
114
- return PositiveMatchPatternFileConditionBuilder(
115
- self._project_path, list(self._filters)
116
- )
110
+ return PositiveMatchPatternFileConditionBuilder(self._project_path, list(self._filters))
117
111
 
118
112
  def should_not(self) -> "NegatedMatchPatternFileConditionBuilder":
119
113
  """Begin negative assertion (files SHOULD NOT ...)."""
120
- return NegatedMatchPatternFileConditionBuilder(
121
- self._project_path, list(self._filters)
122
- )
114
+ return NegatedMatchPatternFileConditionBuilder(self._project_path, list(self._filters))
123
115
 
124
116
 
125
117
  class PositiveMatchPatternFileConditionBuilder:
@@ -135,9 +127,7 @@ class PositiveMatchPatternFileConditionBuilder:
135
127
 
136
128
  def depend_on_files(self) -> "DependOnFileConditionBuilder":
137
129
  """Begin dependency assertion - files SHOULD depend on ..."""
138
- return DependOnFileConditionBuilder(
139
- self._project_path, self._filters, is_negated=False
140
- )
130
+ return DependOnFileConditionBuilder(self._project_path, self._filters, is_negated=False)
141
131
 
142
132
  def depend_on_external_modules(
143
133
  self,
@@ -192,9 +182,7 @@ class NegatedMatchPatternFileConditionBuilder:
192
182
 
193
183
  def depend_on_files(self) -> "DependOnFileConditionBuilder":
194
184
  """Begin dependency assertion - files SHOULD NOT depend on ..."""
195
- return DependOnFileConditionBuilder(
196
- self._project_path, self._filters, is_negated=True
197
- )
185
+ return DependOnFileConditionBuilder(self._project_path, self._filters, is_negated=True)
198
186
 
199
187
  def depend_on_external_modules(
200
188
  self,
@@ -243,9 +231,7 @@ class NegatedMatchPatternFileConditionBuilder:
243
231
  class DependOnFileConditionBuilder:
244
232
  """Configure dependency target patterns."""
245
233
 
246
- def __init__(
247
- self, project_path: str | None, filters: list[Filter], is_negated: bool
248
- ) -> None:
234
+ def __init__(self, project_path: str | None, filters: list[Filter], is_negated: bool) -> None:
249
235
  self._project_path = project_path
250
236
  self._filters = filters
251
237
  self._is_negated = is_negated
@@ -285,9 +271,7 @@ class DependOnFileConditionBuilder:
285
271
  class DependOnExternalModuleConditionBuilder:
286
272
  """Configure external module dependency target patterns."""
287
273
 
288
- def __init__(
289
- self, project_path: str | None, filters: list[Filter], is_negated: bool
290
- ) -> None:
274
+ def __init__(self, project_path: str | None, filters: list[Filter], is_negated: bool) -> None:
291
275
  self._project_path = project_path
292
276
  self._filters = filters
293
277
  self._is_negated = is_negated
@@ -447,9 +431,7 @@ class MatchPatternFileCondition:
447
431
  if empty is not None:
448
432
  return empty
449
433
 
450
- return gather_regex_matching_violations(
451
- nodes, self._check_filters, self._is_negated
452
- )
434
+ return gather_regex_matching_violations(nodes, self._check_filters, self._is_negated)
453
435
 
454
436
 
455
437
  class CustomFileCheckableCondition:
@@ -31,9 +31,7 @@ class FileCountViolation(Violation):
31
31
  comparison: MetricComparison
32
32
 
33
33
 
34
- def check_threshold(
35
- value: float, threshold: float, comparison: MetricComparison
36
- ) -> bool:
34
+ def check_threshold(value: float, threshold: float, comparison: MetricComparison) -> bool:
37
35
  """Check if a value violates a threshold.
38
36
 
39
37
  Returns True if the value is a VIOLATION.
@@ -103,8 +103,6 @@ def calculate_distance_metrics_for_project(
103
103
  average_instability=sum(m.instability for m in metrics) / len(metrics),
104
104
  average_distance=sum(m.distance for m in metrics) / len(metrics),
105
105
  files_in_zone_of_pain=sum(1 for m in metrics if m.in_zone_of_pain),
106
- files_in_zone_of_uselessness=sum(
107
- 1 for m in metrics if m.in_zone_of_uselessness
108
- ),
106
+ files_in_zone_of_uselessness=sum(1 for m in metrics if m.in_zone_of_uselessness),
109
107
  total_files=len(files),
110
108
  )
@@ -129,9 +129,7 @@ def _extract_class(node: ast.ClassDef, file_path: str) -> ClassInfo:
129
129
  for item in ast.walk(node):
130
130
  if isinstance(item, ast.Assign):
131
131
  for target in item.targets:
132
- if isinstance(target, ast.Attribute) and isinstance(
133
- target.value, ast.Name
134
- ):
132
+ if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name):
135
133
  if target.value.id == "self":
136
134
  field_name = target.attr
137
135
  if field_name not in fields:
@@ -142,9 +140,7 @@ def _extract_class(node: ast.ClassDef, file_path: str) -> ClassInfo:
142
140
  if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
143
141
  method_name = item.name
144
142
  accessed = _find_field_accesses(item, set(fields.keys()))
145
- methods.append(
146
- MethodInfo(name=method_name, accessed_fields=accessed)
147
- )
143
+ methods.append(MethodInfo(name=method_name, accessed_fields=accessed))
148
144
  # Update field access tracking
149
145
  for field_name in accessed:
150
146
  if field_name in fields:
@@ -159,9 +155,7 @@ def _extract_class(node: ast.ClassDef, file_path: str) -> ClassInfo:
159
155
  )
160
156
 
161
157
 
162
- def _extract_enhanced_class(
163
- node: ast.ClassDef, file_path: str
164
- ) -> EnhancedClassInfo:
158
+ def _extract_enhanced_class(node: ast.ClassDef, file_path: str) -> EnhancedClassInfo:
165
159
  """Extract EnhancedClassInfo from a ClassDef AST node."""
166
160
  base = _extract_class(node, file_path)
167
161
 
@@ -35,11 +35,7 @@ class MetricsExporter:
35
35
  HTML content as a string. Also writes to file if output_path specified.
36
36
  """
37
37
  opts = options or ExportOptions()
38
- timestamp = (
39
- datetime.now().strftime("%Y-%m-%d %H:%M:%S")
40
- if opts.include_timestamp
41
- else ""
42
- )
38
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") if opts.include_timestamp else ""
43
39
 
44
40
  css = opts.custom_css or _DEFAULT_CSS
45
41
 
@@ -55,7 +51,7 @@ class MetricsExporter:
55
51
  </head>
56
52
  <body>
57
53
  <h1>{opts.title}</h1>
58
- {f'<p class="timestamp">Generated: {timestamp}</p>' if timestamp else ''}
54
+ {f'<p class="timestamp">Generated: {timestamp}</p>' if timestamp else ""}
59
55
  <table>
60
56
  <thead>
61
57
  <tr><th>Metric</th><th>Value</th></tr>
@@ -97,9 +97,7 @@ class MetricsBuilder:
97
97
  )
98
98
 
99
99
 
100
- def _get_filtered_classes(
101
- project_path: str | None, filters: list[Filter]
102
- ) -> list[ClassInfo]:
100
+ def _get_filtered_classes(project_path: str | None, filters: list[Filter]) -> list[ClassInfo]:
103
101
  classes = extract_class_info(project_path)
104
102
  if not filters:
105
103
  return classes
@@ -119,39 +117,25 @@ class CountMetricsBuilder:
119
117
  self._filters = filters
120
118
 
121
119
  def method_count(self) -> "ClassMetricThresholdBuilder":
122
- return ClassMetricThresholdBuilder(
123
- self._project_path, self._filters, MethodCountMetric()
124
- )
120
+ return ClassMetricThresholdBuilder(self._project_path, self._filters, MethodCountMetric())
125
121
 
126
122
  def field_count(self) -> "ClassMetricThresholdBuilder":
127
- return ClassMetricThresholdBuilder(
128
- self._project_path, self._filters, FieldCountMetric()
129
- )
123
+ return ClassMetricThresholdBuilder(self._project_path, self._filters, FieldCountMetric())
130
124
 
131
125
  def lines_of_code(self) -> "FileMetricThresholdBuilder":
132
- return FileMetricThresholdBuilder(
133
- self._project_path, self._filters, LinesOfCodeMetric()
134
- )
126
+ return FileMetricThresholdBuilder(self._project_path, self._filters, LinesOfCodeMetric())
135
127
 
136
128
  def statements(self) -> "FileMetricThresholdBuilder":
137
- return FileMetricThresholdBuilder(
138
- self._project_path, self._filters, StatementCountMetric()
139
- )
129
+ return FileMetricThresholdBuilder(self._project_path, self._filters, StatementCountMetric())
140
130
 
141
131
  def imports(self) -> "FileMetricThresholdBuilder":
142
- return FileMetricThresholdBuilder(
143
- self._project_path, self._filters, ImportCountMetric()
144
- )
132
+ return FileMetricThresholdBuilder(self._project_path, self._filters, ImportCountMetric())
145
133
 
146
134
  def classes(self) -> "FileMetricThresholdBuilder":
147
- return FileMetricThresholdBuilder(
148
- self._project_path, self._filters, ClassCountMetric()
149
- )
135
+ return FileMetricThresholdBuilder(self._project_path, self._filters, ClassCountMetric())
150
136
 
151
137
  def functions(self) -> "FileMetricThresholdBuilder":
152
- return FileMetricThresholdBuilder(
153
- self._project_path, self._filters, FunctionCountMetric()
154
- )
138
+ return FileMetricThresholdBuilder(self._project_path, self._filters, FunctionCountMetric())
155
139
 
156
140
 
157
141
  class ClassMetricThresholdBuilder:
@@ -340,19 +324,13 @@ class DistanceMetricsBuilder:
340
324
  self._filters = filters
341
325
 
342
326
  def abstractness(self) -> "DistanceThresholdBuilder":
343
- return DistanceThresholdBuilder(
344
- self._project_path, self._filters, "abstractness"
345
- )
327
+ return DistanceThresholdBuilder(self._project_path, self._filters, "abstractness")
346
328
 
347
329
  def instability(self) -> "DistanceThresholdBuilder":
348
- return DistanceThresholdBuilder(
349
- self._project_path, self._filters, "instability"
350
- )
330
+ return DistanceThresholdBuilder(self._project_path, self._filters, "instability")
351
331
 
352
332
  def distance_from_main_sequence(self) -> "DistanceThresholdBuilder":
353
- return DistanceThresholdBuilder(
354
- self._project_path, self._filters, "distance"
355
- )
333
+ return DistanceThresholdBuilder(self._project_path, self._filters, "distance")
356
334
 
357
335
  def not_in_zone_of_pain(self) -> "ZoneCondition":
358
336
  return ZoneCondition(self._project_path, self._filters, "pain")
@@ -440,11 +418,7 @@ class ZoneCondition:
440
418
 
441
419
  for file_result in files:
442
420
  dm = calculate_file_distance_metrics(file_result, files)
443
- in_zone = (
444
- dm.in_zone_of_pain
445
- if self._zone_type == "pain"
446
- else dm.in_zone_of_uselessness
447
- )
421
+ in_zone = dm.in_zone_of_pain if self._zone_type == "pain" else dm.in_zone_of_uselessness
448
422
 
449
423
  if in_zone:
450
424
  violations.append(