archunitpython 1.3.0__tar.gz → 1.5.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 (146) hide show
  1. {archunitpython-1.3.0 → archunitpython-1.5.0}/BACKLOG.md +2 -2
  2. {archunitpython-1.3.0 → archunitpython-1.5.0}/CHANGELOG.md +20 -0
  3. {archunitpython-1.3.0 → archunitpython-1.5.0}/PKG-INFO +57 -3
  4. {archunitpython-1.3.0 → archunitpython-1.5.0}/README.md +56 -2
  5. {archunitpython-1.3.0 → archunitpython-1.5.0}/pyproject.toml +1 -1
  6. {archunitpython-1.3.0 → archunitpython-1.5.0}/scripts/check_release_metadata.py +1 -1
  7. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/__init__.py +5 -1
  8. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/extraction/extract_graph.py +79 -11
  9. archunitpython-1.5.0/src/archunitpython/config/__init__.py +5 -0
  10. archunitpython-1.5.0/src/archunitpython/config/loader.py +119 -0
  11. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/metrics/extraction/extract_class_info.py +6 -3
  12. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/metrics/fluentapi/metrics.py +2 -2
  13. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/common/test_declaration_detector.py +2 -2
  14. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/common/test_extract_graph.py +68 -0
  15. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/common/test_logger.py +0 -4
  16. archunitpython-1.5.0/tests/config/__init__.py +1 -0
  17. archunitpython-1.5.0/tests/config/test_config_loader.py +115 -0
  18. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/files/test_file_assertions.py +0 -2
  19. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/fixtures/sample_project/utils/helpers.py +5 -1
  20. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/integration/test_e2e.py +5 -0
  21. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/metrics/test_metrics.py +8 -9
  22. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/metrics/test_metrics_fluentapi.py +26 -0
  23. {archunitpython-1.3.0 → archunitpython-1.5.0}/.editorconfig +0 -0
  24. {archunitpython-1.3.0 → archunitpython-1.5.0}/.gitattributes +0 -0
  25. {archunitpython-1.3.0 → archunitpython-1.5.0}/.github/CODEOWNERS +0 -0
  26. {archunitpython-1.3.0 → archunitpython-1.5.0}/.github/FUNDING.yml +0 -0
  27. {archunitpython-1.3.0 → archunitpython-1.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  28. {archunitpython-1.3.0 → archunitpython-1.5.0}/.github/ISSUE_TEMPLATE/documentation.md +0 -0
  29. {archunitpython-1.3.0 → archunitpython-1.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  30. {archunitpython-1.3.0 → archunitpython-1.5.0}/.github/ISSUE_TEMPLATE/question.md +0 -0
  31. {archunitpython-1.3.0 → archunitpython-1.5.0}/.github/PAGES.md +0 -0
  32. {archunitpython-1.3.0 → archunitpython-1.5.0}/.github/dependabot.yml +0 -0
  33. {archunitpython-1.3.0 → archunitpython-1.5.0}/.github/pull_request_template.md +0 -0
  34. {archunitpython-1.3.0 → archunitpython-1.5.0}/.github/workflows/docs.yaml +0 -0
  35. {archunitpython-1.3.0 → archunitpython-1.5.0}/.github/workflows/integrate.yaml +0 -0
  36. {archunitpython-1.3.0 → archunitpython-1.5.0}/.github/workflows/stale.yaml +0 -0
  37. {archunitpython-1.3.0 → archunitpython-1.5.0}/.gitignore +0 -0
  38. {archunitpython-1.3.0 → archunitpython-1.5.0}/.releaserc.json +0 -0
  39. {archunitpython-1.3.0 → archunitpython-1.5.0}/CONTRIBUTING.md +0 -0
  40. {archunitpython-1.3.0 → archunitpython-1.5.0}/LICENSE +0 -0
  41. {archunitpython-1.3.0 → archunitpython-1.5.0}/assets/logo-rounded.png +0 -0
  42. {archunitpython-1.3.0 → archunitpython-1.5.0}/research/product-direction/architecture-testing-landscape.md +0 -0
  43. {archunitpython-1.3.0 → archunitpython-1.5.0}/scripts/bump_release_version.py +0 -0
  44. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/__init__.py +0 -0
  45. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/assertion/__init__.py +0 -0
  46. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/assertion/violation.py +0 -0
  47. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/error/__init__.py +0 -0
  48. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/error/errors.py +0 -0
  49. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/extraction/__init__.py +0 -0
  50. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/extraction/graph.py +0 -0
  51. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/fluentapi/__init__.py +0 -0
  52. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/fluentapi/checkable.py +0 -0
  53. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/logging/__init__.py +0 -0
  54. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/logging/types.py +0 -0
  55. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/pattern_matching.py +0 -0
  56. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/projection/__init__.py +0 -0
  57. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/projection/cycles/__init__.py +0 -0
  58. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/projection/cycles/cycle_utils.py +0 -0
  59. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/projection/cycles/cycles.py +0 -0
  60. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/projection/cycles/johnsons_apsp.py +0 -0
  61. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/projection/cycles/model.py +0 -0
  62. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/projection/cycles/tarjan_scc.py +0 -0
  63. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/projection/edge_projections.py +0 -0
  64. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/projection/project_cycles.py +0 -0
  65. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/projection/project_edges.py +0 -0
  66. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/projection/project_nodes.py +0 -0
  67. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/projection/types.py +0 -0
  68. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/regex_factory.py +0 -0
  69. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/types.py +0 -0
  70. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/util/__init__.py +0 -0
  71. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/util/declaration_detector.py +0 -0
  72. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/common/util/logger.py +0 -0
  73. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/files/__init__.py +0 -0
  74. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/files/assertion/__init__.py +0 -0
  75. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/files/assertion/custom_file_logic.py +0 -0
  76. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/files/assertion/cycle_free.py +0 -0
  77. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/files/assertion/depend_on_external_modules.py +0 -0
  78. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/files/assertion/depend_on_files.py +0 -0
  79. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/files/assertion/matching_files.py +0 -0
  80. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/files/fluentapi/__init__.py +0 -0
  81. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/files/fluentapi/files.py +0 -0
  82. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/graph/__init__.py +0 -0
  83. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/graph/graph_reporter.py +0 -0
  84. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/layers/__init__.py +0 -0
  85. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/layers/assertion/__init__.py +0 -0
  86. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/layers/assertion/layer_dependencies.py +0 -0
  87. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/layers/fluentapi/__init__.py +0 -0
  88. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/layers/fluentapi/layers.py +0 -0
  89. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/metrics/__init__.py +0 -0
  90. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/metrics/assertion/__init__.py +0 -0
  91. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/metrics/assertion/metric_thresholds.py +0 -0
  92. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/metrics/calculation/__init__.py +0 -0
  93. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/metrics/calculation/count.py +0 -0
  94. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/metrics/calculation/distance.py +0 -0
  95. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/metrics/calculation/lcom.py +0 -0
  96. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/metrics/common/__init__.py +0 -0
  97. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/metrics/common/types.py +0 -0
  98. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/metrics/extraction/__init__.py +0 -0
  99. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/metrics/fluentapi/__init__.py +0 -0
  100. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/metrics/fluentapi/export_utils.py +0 -0
  101. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/metrics/projection/__init__.py +0 -0
  102. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/py.typed +0 -0
  103. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/slices/__init__.py +0 -0
  104. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/slices/assertion/__init__.py +0 -0
  105. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/slices/assertion/admissible_edges.py +0 -0
  106. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/slices/fluentapi/__init__.py +0 -0
  107. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/slices/fluentapi/slices.py +0 -0
  108. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/slices/projection/__init__.py +0 -0
  109. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/slices/projection/slicing_projections.py +0 -0
  110. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/slices/uml/__init__.py +0 -0
  111. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/slices/uml/export_diagram.py +0 -0
  112. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/slices/uml/generate_rules.py +0 -0
  113. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/testing/__init__.py +0 -0
  114. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/testing/assertion.py +0 -0
  115. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/testing/common/__init__.py +0 -0
  116. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/testing/common/color_utils.py +0 -0
  117. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/testing/common/violation_factory.py +0 -0
  118. {archunitpython-1.3.0 → archunitpython-1.5.0}/src/archunitpython/testing/pytest_plugin/__init__.py +0 -0
  119. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/__init__.py +0 -0
  120. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/common/__init__.py +0 -0
  121. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/common/test_core_types.py +0 -0
  122. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/common/test_cycles.py +0 -0
  123. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/common/test_pattern_matching.py +0 -0
  124. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/common/test_projection.py +0 -0
  125. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/files/__init__.py +0 -0
  126. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/files/test_files_fluentapi.py +0 -0
  127. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/fixtures/metrics_project/service.py +0 -0
  128. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/fixtures/sample_project/__init__.py +0 -0
  129. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/fixtures/sample_project/architecture.puml +0 -0
  130. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/fixtures/sample_project/controllers/__init__.py +0 -0
  131. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/fixtures/sample_project/controllers/controller.py +0 -0
  132. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/fixtures/sample_project/models/__init__.py +0 -0
  133. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/fixtures/sample_project/models/model.py +0 -0
  134. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/fixtures/sample_project/services/__init__.py +0 -0
  135. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/fixtures/sample_project/services/service.py +0 -0
  136. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/fixtures/sample_project/services/service_a.py +0 -0
  137. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/fixtures/sample_project/services/service_b.py +0 -0
  138. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/fixtures/sample_project/utils/__init__.py +0 -0
  139. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/graph/test_graph_reporter.py +0 -0
  140. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/integration/__init__.py +0 -0
  141. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/layers/test_layers.py +0 -0
  142. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/metrics/__init__.py +0 -0
  143. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/metrics/test_export.py +0 -0
  144. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/slices/__init__.py +0 -0
  145. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/slices/test_slices.py +0 -0
  146. {archunitpython-1.3.0 → archunitpython-1.5.0}/tests/test_setup.py +0 -0
@@ -11,9 +11,9 @@ This backlog collects product and maintenance ideas from project research.
11
11
 
12
12
  ## P1 - Adoption Workflow
13
13
 
14
- - Add an `.archignore` or similar file, modeled after `.gitignore`, for files that should never be analyzed.
14
+ - [x] Add an `.archignore` or similar file, modeled after `.gitignore`, for files that should never be analyzed.
15
15
  - [x] Add a `.because(...)` API so rules can carry user-facing rationale into failure messages and generated architecture documentation.
16
- - Add configuration-file support for common rules, while keeping the fluent Python API as the primary interface.
16
+ - [x] 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
 
19
19
  ## P1 - Python Import Semantics
@@ -1,3 +1,23 @@
1
+ # [1.5.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.4.0...v1.5.0) (2026-07-18)
2
+
3
+
4
+ ### Features
5
+
6
+ * load common rules from config ([c7c968a](https://github.com/LukasNiessen/ArchUnitPython/commit/c7c968af20e929f7e7a2372275eaa7e26cb38e5f))
7
+
8
+ # [1.4.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.3.0...v1.4.0) (2026-07-18)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * clarify metadata and exclude messages ([999bb68](https://github.com/LukasNiessen/ArchUnitPython/commit/999bb689594ff613b5f4445191509bcdaa9a514f))
14
+ * harden archignore loading ([ae88c93](https://github.com/LukasNiessen/ArchUnitPython/commit/ae88c932ae2838cff0eabab687ac948f4920c3eb))
15
+
16
+
17
+ ### Features
18
+
19
+ * support archignore exclusions ([c51c401](https://github.com/LukasNiessen/ArchUnitPython/commit/c51c40148425b08ce0257d75dd95c996d0505b00))
20
+
1
21
  # [1.3.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.2.1...v1.3.0) (2026-07-05)
2
22
 
3
23
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: archunitpython
3
- Version: 1.3.0
3
+ Version: 1.5.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,60 @@ options = CheckOptions(
188
188
  violations = rule.check(options)
189
189
  ```
190
190
 
191
+ ### Excluding Files With `.archignore`
192
+
193
+ Add a `.archignore` file to your project root to permanently exclude generated or
194
+ irrelevant files from architecture checks and file-based metrics:
195
+
196
+ ```gitignore
197
+ # Generated code
198
+ generated/
199
+
200
+ # Migration scripts
201
+ migrations/*.py
202
+
203
+ # A single root-level file
204
+ /legacy_adapter.py
205
+ ```
206
+
207
+ Patterns support comments, blank lines, glob syntax, root-relative paths, path
208
+ patterns, and directory patterns with a trailing `/`.
209
+
210
+ ### Loading Common Rules From Config
211
+
212
+ For straightforward shared rules, you can load a JSON config file and still run
213
+ the resulting rules in your normal test suite:
214
+
215
+ ```json
216
+ {
217
+ "project_path": "src",
218
+ "rules": [
219
+ {
220
+ "name": "controllers must not use services directly",
221
+ "type": "forbidden_dependency",
222
+ "source": "**/controllers/**",
223
+ "target": "**/services/**"
224
+ },
225
+ {
226
+ "name": "source files have no cycles",
227
+ "type": "no_cycles"
228
+ }
229
+ ]
230
+ }
231
+ ```
232
+
233
+ ```python
234
+ from archunitpython import assert_passes, rules_from_config
235
+
236
+ def test_configured_architecture_rules():
237
+ for rule in rules_from_config("archunitpython.json"):
238
+ assert_passes(rule)
239
+ ```
240
+
241
+ Supported rule types are `no_cycles`, `forbidden_dependency`, and
242
+ `forbidden_external_dependency`. The fluent Python API remains the primary and
243
+ most flexible interface.
244
+
191
245
  ### Explaining Rules With `.because(...)`
192
246
 
193
247
  Attach a rationale to a rule so failing assertions explain why the rule exists:
@@ -450,7 +504,7 @@ def test_no_forbidden_dependency():
450
504
 
451
505
  Generate dependency graph reports in multiple formats and narrow them to the part of the codebase you want to inspect.
452
506
 
453
- **Using `requests` library repo for example**
507
+ **Using [`requests`](https://github.com/psf/requests) library repo for example**
454
508
 
455
509
  ```python
456
510
  from archunitpython import project_graph
@@ -463,7 +517,7 @@ def test_export_dependency_graph_reports():
463
517
  if __name__ == "__main__":
464
518
  test_export_dependency_graph_reports()
465
519
  ```
466
- **Rendered mermain diagram**
520
+ **Exported mermaid diagram**
467
521
  ``` mermaid
468
522
  flowchart LR
469
523
  n0["__init__.py"]
@@ -155,6 +155,60 @@ options = CheckOptions(
155
155
  violations = rule.check(options)
156
156
  ```
157
157
 
158
+ ### Excluding Files With `.archignore`
159
+
160
+ Add a `.archignore` file to your project root to permanently exclude generated or
161
+ irrelevant files from architecture checks and file-based metrics:
162
+
163
+ ```gitignore
164
+ # Generated code
165
+ generated/
166
+
167
+ # Migration scripts
168
+ migrations/*.py
169
+
170
+ # A single root-level file
171
+ /legacy_adapter.py
172
+ ```
173
+
174
+ Patterns support comments, blank lines, glob syntax, root-relative paths, path
175
+ patterns, and directory patterns with a trailing `/`.
176
+
177
+ ### Loading Common Rules From Config
178
+
179
+ For straightforward shared rules, you can load a JSON config file and still run
180
+ the resulting rules in your normal test suite:
181
+
182
+ ```json
183
+ {
184
+ "project_path": "src",
185
+ "rules": [
186
+ {
187
+ "name": "controllers must not use services directly",
188
+ "type": "forbidden_dependency",
189
+ "source": "**/controllers/**",
190
+ "target": "**/services/**"
191
+ },
192
+ {
193
+ "name": "source files have no cycles",
194
+ "type": "no_cycles"
195
+ }
196
+ ]
197
+ }
198
+ ```
199
+
200
+ ```python
201
+ from archunitpython import assert_passes, rules_from_config
202
+
203
+ def test_configured_architecture_rules():
204
+ for rule in rules_from_config("archunitpython.json"):
205
+ assert_passes(rule)
206
+ ```
207
+
208
+ Supported rule types are `no_cycles`, `forbidden_dependency`, and
209
+ `forbidden_external_dependency`. The fluent Python API remains the primary and
210
+ most flexible interface.
211
+
158
212
  ### Explaining Rules With `.because(...)`
159
213
 
160
214
  Attach a rationale to a rule so failing assertions explain why the rule exists:
@@ -417,7 +471,7 @@ def test_no_forbidden_dependency():
417
471
 
418
472
  Generate dependency graph reports in multiple formats and narrow them to the part of the codebase you want to inspect.
419
473
 
420
- **Using `requests` library repo for example**
474
+ **Using [`requests`](https://github.com/psf/requests) library repo for example**
421
475
 
422
476
  ```python
423
477
  from archunitpython import project_graph
@@ -430,7 +484,7 @@ def test_export_dependency_graph_reports():
430
484
  if __name__ == "__main__":
431
485
  test_export_dependency_graph_reports()
432
486
  ```
433
- **Rendered mermain diagram**
487
+ **Exported mermaid diagram**
434
488
  ``` mermaid
435
489
  flowchart LR
436
490
  n0["__init__.py"]
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "archunitpython"
7
- version = "1.3.0"
7
+ version = "1.5.0"
8
8
  description = "Architecture testing library for Python projects. Enforce dependency rules, detect cycles, validate metrics."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -17,7 +17,7 @@ def read_project_version() -> str:
17
17
  content = PYPROJECT.read_text(encoding="utf-8")
18
18
  match = re.search(r'^version = "([^"]+)"$', content, re.MULTILINE)
19
19
  if match is None:
20
- raise RuntimeError("Could not find project.version in pyproject.toml")
20
+ raise RuntimeError("Could not find [project].version in pyproject.toml")
21
21
  return match.group(1)
22
22
 
23
23
 
@@ -1,6 +1,6 @@
1
1
  """ArchUnitPython - Architecture testing library for Python projects."""
2
2
 
3
- __version__ = "1.3.0"
3
+ __version__ = "1.5.0"
4
4
 
5
5
  # Files API
6
6
  # Common
@@ -12,6 +12,7 @@ from archunitpython.common import (
12
12
  Violation,
13
13
  )
14
14
  from archunitpython.common.extraction import clear_graph_cache, extract_graph
15
+ from archunitpython.config import ConfiguredRule, rules_from_config
15
16
  from archunitpython.files import files, project_files
16
17
  from archunitpython.graph import dependency_graph, project_graph
17
18
  from archunitpython.layers import layers, project_layers
@@ -35,6 +36,9 @@ __all__ = [
35
36
  # Layers
36
37
  "project_layers",
37
38
  "layers",
39
+ # Config
40
+ "rules_from_config",
41
+ "ConfiguredRule",
38
42
  # Slices
39
43
  "project_slices",
40
44
  # Metrics
@@ -29,6 +29,8 @@ _DEFAULT_EXCLUDE = [
29
29
  "*.egg-info",
30
30
  ]
31
31
 
32
+ _ARCHIGNORE_FILE = ".archignore"
33
+
32
34
  _IGNORE_DIRECTIVE_REGEX = re.compile(
33
35
  r"#\s*archunit(?::|-)\s*ignore"
34
36
  r"(?:\([^)]*\))?"
@@ -88,9 +90,7 @@ def extract_graph(
88
90
  project_path = os.getcwd()
89
91
 
90
92
  project_path = os.path.abspath(project_path)
91
- excludes = (
92
- list(set(exclude_patterns)) if exclude_patterns is not None else list(_DEFAULT_EXCLUDE)
93
- )
93
+ excludes = _resolve_exclude_patterns(project_path, exclude_patterns)
94
94
  ignore_type_checking_imports = bool(options and options.ignore_type_checking_imports)
95
95
  cache_key = _build_cache_key(project_path, excludes, ignore_type_checking_imports)
96
96
 
@@ -122,6 +122,34 @@ def _build_cache_key(
122
122
  )
123
123
 
124
124
 
125
+ def _resolve_exclude_patterns(
126
+ project_path: str,
127
+ exclude_patterns: list[str] | None,
128
+ ) -> list[str]:
129
+ """Resolve exclude patterns (explicit or defaults) plus any .archignore patterns."""
130
+ excludes = list(exclude_patterns) if exclude_patterns is not None else list(_DEFAULT_EXCLUDE)
131
+ excludes.extend(_load_archignore_patterns(project_path))
132
+ return excludes
133
+
134
+
135
+ def _load_archignore_patterns(project_path: str) -> list[str]:
136
+ """Load .archignore patterns from a project root, if present."""
137
+ archignore_path = os.path.join(project_path, _ARCHIGNORE_FILE)
138
+ try:
139
+ with open(archignore_path, "r", encoding="utf-8", errors="replace") as f:
140
+ lines = f.readlines()
141
+ except OSError:
142
+ return []
143
+
144
+ patterns: list[str] = []
145
+ for line in lines:
146
+ pattern = line.strip()
147
+ if not pattern or pattern.startswith("#"):
148
+ continue
149
+ patterns.append(pattern)
150
+ return patterns
151
+
152
+
125
153
  def _extract_graph_uncached(
126
154
  project_path: str,
127
155
  exclude_patterns: list[str],
@@ -160,7 +188,7 @@ def _extract_graph_uncached(
160
188
  if resolved and resolved != _normalize(file_path):
161
189
  # Check if the resolved path is in our project
162
190
  if not is_external and resolved not in normalized_py_file_set:
163
- is_external = True
191
+ continue
164
192
 
165
193
  edges.append(
166
194
  Edge(
@@ -182,25 +210,65 @@ def _normalize(path: str) -> str:
182
210
  def _find_python_files(root: str, exclude: list[str]) -> list[str]:
183
211
  """Recursively find all .py files, excluding specified patterns."""
184
212
  py_files: list[str] = []
213
+ root = os.path.abspath(root)
185
214
  for dirpath, dirnames, filenames in os.walk(root):
186
215
  # Filter out excluded directories in-place
187
- dirnames[:] = [d for d in dirnames if not _should_exclude(d, exclude)]
216
+ dirnames[:] = [
217
+ d
218
+ for d in dirnames
219
+ if not _should_exclude_path(os.path.join(dirpath, d), root, exclude, is_dir=True)
220
+ ]
188
221
 
189
222
  for filename in filenames:
190
- if filename.endswith(".py") and not _should_exclude(filename, exclude):
191
- full_path = os.path.join(dirpath, filename)
223
+ full_path = os.path.join(dirpath, filename)
224
+ if filename.endswith(".py") and not _should_exclude_path(
225
+ full_path, root, exclude, is_dir=False
226
+ ):
192
227
  py_files.append(os.path.abspath(full_path))
193
228
 
194
229
  return py_files
195
230
 
196
231
 
197
- def _should_exclude(name: str, patterns: list[str]) -> bool:
198
- """Check if a name matches any exclude pattern."""
232
+ def _should_exclude_path(
233
+ path: str,
234
+ root: str,
235
+ patterns: list[str],
236
+ *,
237
+ is_dir: bool,
238
+ ) -> bool:
239
+ """Check if a path matches any exclude pattern."""
199
240
  import fnmatch
200
241
 
201
- for pattern in patterns:
202
- if fnmatch.fnmatch(name, pattern):
242
+ rel_path = _normalize(os.path.relpath(path, root))
243
+ name = os.path.basename(path)
244
+
245
+ for raw_pattern in patterns:
246
+ pattern = raw_pattern.strip().replace("\\", "/")
247
+ if not pattern or pattern.startswith("#"):
248
+ continue
249
+
250
+ pattern = pattern.removeprefix("./")
251
+ anchored = pattern.startswith("/")
252
+ if anchored:
253
+ pattern = pattern[1:]
254
+
255
+ dir_only = pattern.endswith("/")
256
+ if dir_only:
257
+ pattern = pattern.rstrip("/")
258
+ if not is_dir:
259
+ continue
260
+
261
+ if not pattern:
262
+ continue
263
+
264
+ if "/" in pattern or anchored:
265
+ if fnmatch.fnmatch(rel_path, pattern):
266
+ return True
267
+ if is_dir and rel_path == pattern:
268
+ return True
269
+ elif fnmatch.fnmatch(name, pattern):
203
270
  return True
271
+
204
272
  return False
205
273
 
206
274
 
@@ -0,0 +1,5 @@
1
+ """Configuration-file support for common architecture rules."""
2
+
3
+ from archunitpython.config.loader import ConfiguredRule, rules_from_config
4
+
5
+ __all__ = ["ConfiguredRule", "rules_from_config"]
@@ -0,0 +1,119 @@
1
+ """Load common architecture rules from a JSON configuration file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from archunitpython.common.assertion.violation import Violation
12
+ from archunitpython.common.error.errors import UserError
13
+ from archunitpython.common.fluentapi.checkable import Checkable, CheckOptions
14
+ from archunitpython.files.fluentapi.files import project_files
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ConfiguredRule:
19
+ """A named rule loaded from a configuration file."""
20
+
21
+ name: str
22
+ rule: Checkable
23
+
24
+ def check(self, options: CheckOptions | None = None) -> list[Violation]:
25
+ """Run the configured rule."""
26
+ return self.rule.check(options)
27
+
28
+
29
+ def rules_from_config(config_path: str) -> list[ConfiguredRule]:
30
+ """Load common architecture rules from a JSON config file.
31
+
32
+ The fluent Python API remains the primary interface. Config files provide a
33
+ lightweight way to share straightforward rules across projects or teams.
34
+ """
35
+ path = Path(config_path)
36
+ try:
37
+ raw_config = json.loads(path.read_text(encoding="utf-8"))
38
+ except OSError as exc:
39
+ raise UserError(f"Could not read config file: {config_path}") from exc
40
+ except json.JSONDecodeError as exc:
41
+ raise UserError(f"Invalid JSON config file: {config_path}") from exc
42
+
43
+ if not isinstance(raw_config, dict):
44
+ raise UserError("Architecture config must be a JSON object.")
45
+
46
+ project_path = _optional_string(raw_config, "project_path") or os.getcwd()
47
+ rules = raw_config.get("rules")
48
+ if not isinstance(rules, list):
49
+ raise UserError("Architecture config must define a 'rules' list.")
50
+
51
+ base_dir = str(path.parent if path.parent != Path("") else Path.cwd())
52
+ resolved_project_path = _resolve_project_path(base_dir, project_path)
53
+
54
+ return [_build_rule(resolved_project_path, item, index) for index, item in enumerate(rules, 1)]
55
+
56
+
57
+ def _build_rule(project_path: str, item: Any, index: int) -> ConfiguredRule:
58
+ if not isinstance(item, dict):
59
+ raise UserError(f"Rule #{index} must be a JSON object.")
60
+
61
+ rule_type = _required_string(item, "type", index)
62
+ name = _optional_string(item, "name") or f"{rule_type} rule #{index}"
63
+ rule: Checkable
64
+ if rule_type == "no_cycles":
65
+ subject = _optional_string(item, "subject")
66
+ builder = project_files(project_path)
67
+ if subject is not None:
68
+ rule = builder.in_path(subject).should().have_no_cycles()
69
+ else:
70
+ rule = builder.should().have_no_cycles()
71
+ elif rule_type == "forbidden_dependency":
72
+ source = _required_string(item, "source", index)
73
+ target = _required_string(item, "target", index)
74
+ rule = (
75
+ project_files(project_path)
76
+ .in_path(source)
77
+ .should_not()
78
+ .depend_on_files()
79
+ .in_path(target)
80
+ )
81
+ elif rule_type == "forbidden_external_dependency":
82
+ source = _required_string(item, "source", index)
83
+ module = _required_string(item, "module", index)
84
+ rule = (
85
+ project_files(project_path)
86
+ .in_path(source)
87
+ .should_not()
88
+ .depend_on_external_modules()
89
+ .matching(module)
90
+ )
91
+ else:
92
+ raise UserError(
93
+ f"Unsupported rule type '{rule_type}'. Supported types: "
94
+ "no_cycles, forbidden_dependency, forbidden_external_dependency."
95
+ )
96
+
97
+ return ConfiguredRule(name=name, rule=rule)
98
+
99
+
100
+ def _resolve_project_path(base_dir: str, project_path: str) -> str:
101
+ if os.path.isabs(project_path):
102
+ return project_path
103
+ return os.path.abspath(os.path.join(base_dir, project_path))
104
+
105
+
106
+ def _required_string(rule: dict[str, Any], key: str, index: int) -> str:
107
+ value = rule.get(key)
108
+ if not isinstance(value, str) or not value.strip():
109
+ raise UserError(f"Rule #{index} must define a non-empty string '{key}'.")
110
+ return value
111
+
112
+
113
+ def _optional_string(rule: dict[str, Any], key: str) -> str | None:
114
+ value = rule.get(key)
115
+ if value is None:
116
+ return None
117
+ if not isinstance(value, str) or not value.strip():
118
+ raise UserError(f"Config value '{key}' must be a non-empty string.")
119
+ return value
@@ -5,7 +5,10 @@ from __future__ import annotations
5
5
  import ast
6
6
  import os
7
7
 
8
- from archunitpython.common.extraction.extract_graph import _DEFAULT_EXCLUDE, _find_python_files
8
+ from archunitpython.common.extraction.extract_graph import (
9
+ _find_python_files,
10
+ _resolve_exclude_patterns,
11
+ )
9
12
  from archunitpython.metrics.common.types import (
10
13
  ClassInfo,
11
14
  EnhancedClassInfo,
@@ -33,7 +36,7 @@ def extract_class_info(
33
36
  project_path = os.getcwd()
34
37
 
35
38
  project_path = os.path.abspath(project_path)
36
- excludes = exclude_patterns if exclude_patterns is not None else _DEFAULT_EXCLUDE
39
+ excludes = _resolve_exclude_patterns(project_path, exclude_patterns)
37
40
  py_files = _find_python_files(project_path, excludes)
38
41
 
39
42
  classes: list[ClassInfo] = []
@@ -53,7 +56,7 @@ def extract_enhanced_class_info(
53
56
  project_path = os.getcwd()
54
57
 
55
58
  project_path = os.path.abspath(project_path)
56
- excludes = exclude_patterns if exclude_patterns is not None else _DEFAULT_EXCLUDE
59
+ excludes = _resolve_exclude_patterns(project_path, exclude_patterns)
57
60
  py_files = _find_python_files(project_path, excludes)
58
61
 
59
62
  results: list[FileAnalysisResult] = []
@@ -251,13 +251,13 @@ class FileMetricCondition(RuleRationaleMixin):
251
251
  import os
252
252
 
253
253
  from archunitpython.common.extraction.extract_graph import (
254
- _DEFAULT_EXCLUDE,
255
254
  _find_python_files,
255
+ _resolve_exclude_patterns,
256
256
  )
257
257
 
258
258
  project = self._project_path or os.getcwd()
259
259
  project = os.path.abspath(project)
260
- files = _find_python_files(project, _DEFAULT_EXCLUDE)
260
+ files = _find_python_files(project, _resolve_exclude_patterns(project, None))
261
261
  violations: list[Violation] = []
262
262
 
263
263
  for file_path in files:
@@ -1,13 +1,13 @@
1
1
  """Tests for declaration detection utilities."""
2
2
 
3
+ import ast
4
+
3
5
  from archunitpython.common.util.declaration_detector import (
4
- DeclarationCounts,
5
6
  count_declarations,
6
7
  is_abstract_class,
7
8
  is_abstract_method,
8
9
  is_protocol_class,
9
10
  )
10
- import ast
11
11
 
12
12
 
13
13
  class TestIsAbstractClass:
@@ -11,6 +11,7 @@ from archunitpython.common.extraction.extract_graph import (
11
11
  _extract_imports,
12
12
  _find_python_files,
13
13
  _normalize,
14
+ _resolve_exclude_patterns,
14
15
  clear_graph_cache,
15
16
  extract_graph,
16
17
  )
@@ -164,6 +165,73 @@ class TestExtractGraph:
164
165
  assert len(edges_with_kinds) > 0
165
166
 
166
167
 
168
+ class TestArchignore:
169
+ def setup_method(self):
170
+ clear_graph_cache()
171
+ self._temp_dir = Path(__file__).resolve().parent / ".tmp" / f"project_{uuid4().hex}"
172
+ self._temp_dir.mkdir(parents=True)
173
+
174
+ def teardown_method(self):
175
+ shutil.rmtree(self._temp_dir, ignore_errors=True)
176
+
177
+ def _write(self, relative_path: str, content: str = "") -> None:
178
+ path = self._temp_dir / relative_path
179
+ path.parent.mkdir(parents=True, exist_ok=True)
180
+ path.write_text(content, encoding="utf-8")
181
+
182
+ def test_archignore_excludes_files_and_directories(self):
183
+ self._write(
184
+ ".archignore",
185
+ "\n".join(
186
+ [
187
+ "# Ignore generated architecture-test inputs",
188
+ "ignored.py",
189
+ "generated/",
190
+ "nested/*.py",
191
+ "/root_ignored.py",
192
+ ]
193
+ ),
194
+ )
195
+ self._write("keep.py")
196
+ self._write("ignored.py")
197
+ self._write("root_ignored.py")
198
+ self._write("generated/generated.py")
199
+ self._write("nested/ignored_nested.py")
200
+
201
+ excludes = _resolve_exclude_patterns(str(self._temp_dir), ["__pycache__"])
202
+ files = _find_python_files(str(self._temp_dir), excludes)
203
+ relative_files = {
204
+ Path(file_path).relative_to(self._temp_dir).as_posix()
205
+ for file_path in files
206
+ }
207
+
208
+ assert relative_files == {"keep.py"}
209
+
210
+ def test_archignore_ignored_files_are_not_dependency_targets(self):
211
+ self._write(".archignore", "ignored.py\n")
212
+ self._write("keep.py", "import ignored\n")
213
+ self._write("ignored.py", "VALUE = 1\n")
214
+
215
+ graph = extract_graph(str(self._temp_dir))
216
+ targets = {edge.target for edge in graph}
217
+
218
+ ignored_path = _normalize(str((self._temp_dir / "ignored.py").resolve()))
219
+ assert ignored_path not in targets
220
+
221
+ def test_archignore_with_invalid_utf8_bytes_does_not_abort_extraction(self):
222
+ (self._temp_dir / ".archignore").write_bytes(b"ignored.py\n\xff\n")
223
+ self._write("keep.py")
224
+ self._write("ignored.py")
225
+
226
+ graph = extract_graph(str(self._temp_dir))
227
+ sources = {edge.source for edge in graph}
228
+
229
+ keep_path = _normalize(str((self._temp_dir / "keep.py").resolve()))
230
+ ignored_path = _normalize(str((self._temp_dir / "ignored.py").resolve()))
231
+ assert keep_path in sources
232
+ assert ignored_path not in sources
233
+
234
+
167
235
  class TestTypeCheckingImportHandling:
168
236
  def setup_method(self):
169
237
  clear_graph_cache()
@@ -1,9 +1,5 @@
1
1
  """Tests for the logging system."""
2
2
 
3
- import os
4
- import shutil
5
- from pathlib import Path
6
-
7
3
  from archunitpython.common.logging.types import LoggingOptions
8
4
  from archunitpython.common.util.logger import CheckLogger
9
5
 
@@ -0,0 +1 @@
1
+ """Tests for configuration-file rule loading."""