archunitpython 1.6.1__tar.gz → 1.8.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 (150) hide show
  1. {archunitpython-1.6.1 → archunitpython-1.8.0}/BACKLOG.md +1 -1
  2. {archunitpython-1.6.1 → archunitpython-1.8.0}/CHANGELOG.md +19 -0
  3. {archunitpython-1.6.1 → archunitpython-1.8.0}/PKG-INFO +81 -5
  4. {archunitpython-1.6.1 → archunitpython-1.8.0}/README.md +80 -4
  5. {archunitpython-1.6.1 → archunitpython-1.8.0}/pyproject.toml +1 -1
  6. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/__init__.py +3 -1
  7. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/extraction/extract_graph.py +124 -58
  8. archunitpython-1.8.0/src/archunitpython/common/fluentapi/inspection.py +79 -0
  9. archunitpython-1.8.0/src/archunitpython/common/logging/inspection.py +99 -0
  10. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/logging/types.py +2 -0
  11. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/pattern_matching.py +7 -2
  12. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/projection/project_edges.py +12 -1
  13. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/files/fluentapi/files.py +6 -0
  14. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/layers/fluentapi/layers.py +2 -0
  15. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/metrics/extraction/extract_class_info.py +17 -0
  16. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/metrics/fluentapi/metrics.py +47 -0
  17. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/slices/fluentapi/slices.py +3 -0
  18. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/testing/assertion.py +16 -6
  19. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/testing/common/color_utils.py +12 -1
  20. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/common/test_extract_graph.py +156 -0
  21. archunitpython-1.8.0/tests/common/test_inspection.py +244 -0
  22. archunitpython-1.8.0/tests/common/test_pretty_output.py +51 -0
  23. {archunitpython-1.6.1 → archunitpython-1.8.0}/.editorconfig +0 -0
  24. {archunitpython-1.6.1 → archunitpython-1.8.0}/.gitattributes +0 -0
  25. {archunitpython-1.6.1 → archunitpython-1.8.0}/.github/CODEOWNERS +0 -0
  26. {archunitpython-1.6.1 → archunitpython-1.8.0}/.github/FUNDING.yml +0 -0
  27. {archunitpython-1.6.1 → archunitpython-1.8.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  28. {archunitpython-1.6.1 → archunitpython-1.8.0}/.github/ISSUE_TEMPLATE/documentation.md +0 -0
  29. {archunitpython-1.6.1 → archunitpython-1.8.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  30. {archunitpython-1.6.1 → archunitpython-1.8.0}/.github/ISSUE_TEMPLATE/question.md +0 -0
  31. {archunitpython-1.6.1 → archunitpython-1.8.0}/.github/PAGES.md +0 -0
  32. {archunitpython-1.6.1 → archunitpython-1.8.0}/.github/dependabot.yml +0 -0
  33. {archunitpython-1.6.1 → archunitpython-1.8.0}/.github/pull_request_template.md +0 -0
  34. {archunitpython-1.6.1 → archunitpython-1.8.0}/.github/workflows/docs.yaml +0 -0
  35. {archunitpython-1.6.1 → archunitpython-1.8.0}/.github/workflows/integrate.yaml +0 -0
  36. {archunitpython-1.6.1 → archunitpython-1.8.0}/.github/workflows/stale.yaml +0 -0
  37. {archunitpython-1.6.1 → archunitpython-1.8.0}/.gitignore +0 -0
  38. {archunitpython-1.6.1 → archunitpython-1.8.0}/.releaserc.json +0 -0
  39. {archunitpython-1.6.1 → archunitpython-1.8.0}/CONTRIBUTING.md +0 -0
  40. {archunitpython-1.6.1 → archunitpython-1.8.0}/LICENSE +0 -0
  41. {archunitpython-1.6.1 → archunitpython-1.8.0}/assets/logo-rounded.png +0 -0
  42. {archunitpython-1.6.1 → archunitpython-1.8.0}/research/product-direction/architecture-testing-landscape.md +0 -0
  43. {archunitpython-1.6.1 → archunitpython-1.8.0}/scripts/bump_release_version.py +0 -0
  44. {archunitpython-1.6.1 → archunitpython-1.8.0}/scripts/check_release_metadata.py +0 -0
  45. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/__init__.py +0 -0
  46. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/assertion/__init__.py +0 -0
  47. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/assertion/violation.py +0 -0
  48. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/error/__init__.py +0 -0
  49. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/error/errors.py +0 -0
  50. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/extraction/__init__.py +0 -0
  51. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/extraction/graph.py +0 -0
  52. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/fluentapi/__init__.py +0 -0
  53. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/fluentapi/checkable.py +0 -0
  54. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/logging/__init__.py +0 -0
  55. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/projection/__init__.py +0 -0
  56. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/projection/cycles/__init__.py +0 -0
  57. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/projection/cycles/cycle_utils.py +0 -0
  58. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/projection/cycles/cycles.py +0 -0
  59. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/projection/cycles/johnsons_apsp.py +0 -0
  60. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/projection/cycles/model.py +0 -0
  61. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/projection/cycles/tarjan_scc.py +0 -0
  62. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/projection/edge_projections.py +0 -0
  63. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/projection/project_cycles.py +0 -0
  64. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/projection/project_nodes.py +0 -0
  65. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/projection/types.py +0 -0
  66. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/regex_factory.py +0 -0
  67. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/types.py +0 -0
  68. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/util/__init__.py +0 -0
  69. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/util/declaration_detector.py +0 -0
  70. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/common/util/logger.py +0 -0
  71. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/config/__init__.py +0 -0
  72. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/config/loader.py +0 -0
  73. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/files/__init__.py +0 -0
  74. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/files/assertion/__init__.py +0 -0
  75. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/files/assertion/custom_file_logic.py +0 -0
  76. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/files/assertion/cycle_free.py +0 -0
  77. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/files/assertion/depend_on_external_modules.py +0 -0
  78. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/files/assertion/depend_on_files.py +0 -0
  79. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/files/assertion/matching_files.py +0 -0
  80. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/files/fluentapi/__init__.py +0 -0
  81. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/graph/__init__.py +0 -0
  82. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/graph/graph_reporter.py +0 -0
  83. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/layers/__init__.py +0 -0
  84. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/layers/assertion/__init__.py +0 -0
  85. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/layers/assertion/layer_dependencies.py +0 -0
  86. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/layers/fluentapi/__init__.py +0 -0
  87. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/metrics/__init__.py +0 -0
  88. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/metrics/assertion/__init__.py +0 -0
  89. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/metrics/assertion/metric_thresholds.py +0 -0
  90. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/metrics/calculation/__init__.py +0 -0
  91. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/metrics/calculation/count.py +0 -0
  92. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/metrics/calculation/distance.py +0 -0
  93. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/metrics/calculation/lcom.py +0 -0
  94. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/metrics/common/__init__.py +0 -0
  95. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/metrics/common/types.py +0 -0
  96. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/metrics/extraction/__init__.py +0 -0
  97. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/metrics/fluentapi/__init__.py +0 -0
  98. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/metrics/fluentapi/export_utils.py +0 -0
  99. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/metrics/projection/__init__.py +0 -0
  100. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/py.typed +0 -0
  101. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/slices/__init__.py +0 -0
  102. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/slices/assertion/__init__.py +0 -0
  103. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/slices/assertion/admissible_edges.py +0 -0
  104. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/slices/fluentapi/__init__.py +0 -0
  105. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/slices/projection/__init__.py +0 -0
  106. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/slices/projection/slicing_projections.py +0 -0
  107. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/slices/uml/__init__.py +0 -0
  108. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/slices/uml/export_diagram.py +0 -0
  109. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/slices/uml/generate_rules.py +0 -0
  110. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/testing/__init__.py +0 -0
  111. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/testing/common/__init__.py +0 -0
  112. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/testing/common/violation_factory.py +0 -0
  113. {archunitpython-1.6.1 → archunitpython-1.8.0}/src/archunitpython/testing/pytest_plugin/__init__.py +0 -0
  114. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/__init__.py +0 -0
  115. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/common/__init__.py +0 -0
  116. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/common/test_core_types.py +0 -0
  117. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/common/test_cycles.py +0 -0
  118. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/common/test_declaration_detector.py +0 -0
  119. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/common/test_logger.py +0 -0
  120. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/common/test_pattern_matching.py +0 -0
  121. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/common/test_projection.py +0 -0
  122. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/config/__init__.py +0 -0
  123. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/config/test_config_loader.py +0 -0
  124. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/files/__init__.py +0 -0
  125. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/files/test_file_assertions.py +0 -0
  126. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/files/test_files_fluentapi.py +0 -0
  127. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/fixtures/metrics_project/service.py +0 -0
  128. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/fixtures/sample_project/__init__.py +0 -0
  129. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/fixtures/sample_project/architecture.puml +0 -0
  130. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/fixtures/sample_project/controllers/__init__.py +0 -0
  131. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/fixtures/sample_project/controllers/controller.py +0 -0
  132. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/fixtures/sample_project/models/__init__.py +0 -0
  133. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/fixtures/sample_project/models/model.py +0 -0
  134. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/fixtures/sample_project/services/__init__.py +0 -0
  135. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/fixtures/sample_project/services/service.py +0 -0
  136. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/fixtures/sample_project/services/service_a.py +0 -0
  137. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/fixtures/sample_project/services/service_b.py +0 -0
  138. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/fixtures/sample_project/utils/__init__.py +0 -0
  139. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/fixtures/sample_project/utils/helpers.py +0 -0
  140. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/graph/test_graph_reporter.py +0 -0
  141. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/integration/__init__.py +0 -0
  142. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/integration/test_e2e.py +0 -0
  143. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/layers/test_layers.py +0 -0
  144. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/metrics/__init__.py +0 -0
  145. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/metrics/test_export.py +0 -0
  146. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/metrics/test_metrics.py +0 -0
  147. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/metrics/test_metrics_fluentapi.py +0 -0
  148. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/slices/__init__.py +0 -0
  149. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/slices/test_slices.py +0 -0
  150. {archunitpython-1.6.1 → archunitpython-1.8.0}/tests/test_setup.py +0 -0
@@ -18,7 +18,7 @@ This backlog collects product and maintenance ideas from project research.
18
18
 
19
19
  ## P1 - Python Import Semantics
20
20
 
21
- - Add support for namespace packages that do not contain `__init__.py`.
21
+ - [x] Add support for namespace packages that do not contain `__init__.py`.
22
22
  - [x] Detect dynamic imports such as `importlib.import_module()` and `__import__()`.
23
23
  - [x] Detect conditional imports such as `try/except ImportError`.
24
24
  - Add better `TYPE_CHECKING` import handling, including options to ignore, include, or report type-only imports separately.
@@ -1,3 +1,22 @@
1
+ # [1.8.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.7.0...v1.8.0) (2026-09-30)
2
+
3
+
4
+ ### Features
5
+
6
+ * add detailed per-check inspection and styled reports ([#65](https://github.com/LukasNiessen/ArchUnitPython/issues/65)) ([ab0f9ef](https://github.com/LukasNiessen/ArchUnitPython/commit/ab0f9efb71c90712366490121e82c09b112a5d06))
7
+
8
+ # [1.7.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.6.1...v1.7.0) (2026-09-13)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * preserve mixed namespace import targets ([489624b](https://github.com/LukasNiessen/ArchUnitPython/commit/489624b4de5465847f6e83106cbe0a398a7c1e8e))
14
+
15
+
16
+ ### Features
17
+
18
+ * resolve namespace package submodule imports ([86c53e6](https://github.com/LukasNiessen/ArchUnitPython/commit/86c53e619ad25dfcdfb109de4593967d3d5603a6))
19
+
1
20
  ## [1.6.1](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.6.0...v1.6.1) (2026-09-13)
2
21
 
3
22
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: archunitpython
3
- Version: 1.6.1
3
+ Version: 1.8.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
@@ -377,6 +377,12 @@ ArchUnitPython detects string-based dynamic imports such as `importlib.import_mo
377
377
  from my_app.adapters.sql import Repository # archunit: ignore
378
378
  ```
379
379
 
380
+ ### Namespace Packages
381
+
382
+ ArchUnitPython resolves imports from namespace packages that do not contain
383
+ `__init__.py` files. For example, `from my_app.domain import model` is resolved
384
+ to `my_app/domain/model.py` when `my_app/domain/` is a namespace package.
385
+
380
386
  ### Conditional Imports
381
387
 
382
388
  Imports inside `try` blocks that handle `ImportError` or
@@ -920,6 +926,10 @@ The most important differences:
920
926
 
921
927
  When tests fail, you get helpful output with file paths and violation details:
922
928
 
929
+ Interactive terminals also highlight the failure summary, numbered headings,
930
+ paths, metric values, and rule rationale. Output stays plain when redirected,
931
+ when `CI=true`, or when `NO_COLOR` is set (even to an empty value).
932
+
923
933
  ```
924
934
  Found 2 architecture violation(s):
925
935
 
@@ -932,25 +942,91 @@ Found 2 architecture violation(s):
932
942
 
933
943
  ## 📝 Debug Logging & Configuration
934
944
 
935
- We support logging to help you understand what files are being analyzed and troubleshoot test failures. Logging is disabled by default to keep test output clean.
945
+ Logging is disabled by default. Enable it per check to inspect file discovery,
946
+ graph extraction and cache use, selector decisions, edge projections, class
947
+ analysis, metric values, and the resulting violations. File, layer, slice,
948
+ count/LCOM/distance, and custom metric checks use the same logging options.
949
+
950
+ | Level | What you see |
951
+ | --- | --- |
952
+ | `error` | Analysis exceptions (the original exception is still raised). |
953
+ | `warn` | Failed check summaries and every violation, plus errors. |
954
+ | `info` | Check start, completion, duration, and violations, plus errors. |
955
+ | `debug` | All of the above, plus rule configuration, discovered files, dependencies, cache decisions, selector matches, projected/omitted edges, classes, and measured values. |
936
956
 
937
957
  ### Enabling Debug Logging
938
958
 
939
959
  ```python
940
- from archunitpython import CheckOptions
941
- from archunitpython.common.logging.types import LoggingOptions
960
+ from archunitpython import CheckOptions, LoggingOptions, project_files
961
+
962
+ rule = project_files("src/").should().have_no_cycles()
942
963
 
943
964
  options = CheckOptions(
944
965
  logging=LoggingOptions(
945
966
  enabled=True,
946
967
  level="debug", # "error" | "warn" | "info" | "debug"
947
- log_file=True, # Creates logs/archunit-YYYY-MM-DD_HH-MM-SS.log
968
+ log_file=True, # Creates a unique timestamped file in logs/
948
969
  ),
949
970
  )
950
971
 
951
972
  violations = rule.check(options)
952
973
  ```
953
974
 
975
+ For example, a debug trace includes entries like these (paths and timings depend
976
+ on your project):
977
+
978
+ ```text
979
+ [INFO] Starting check: CycleFreeFileCondition
980
+ [DEBUG] Graph cache miss
981
+ [DEBUG] Discovered file: /project/src/api.py
982
+ [DEBUG] Processing file: /project/src/api.py
983
+ [DEBUG] Dependency: /project/src/api.py -> /project/src/storage.py; external=False; kinds=[]
984
+ [DEBUG] Projection: /project/src/api.py -> /project/src/storage.py becomes /project/src/api.py -> /project/src/storage.py
985
+ [INFO] Finished check: CycleFreeFileCondition - 0 violation(s) (0.002s)
986
+ ```
987
+
988
+ Console diagnostics use the standard `archunitpython` logger when handlers are
989
+ configured, respecting their levels and formatting; otherwise they go to stderr.
990
+ For pytest live logs, use `pytest --log-cli-level=DEBUG`. You can also configure
991
+ Python's standard `logging` module in your application.
992
+
993
+ ### Save detailed inspection without console noise
994
+
995
+ ```python
996
+ options = CheckOptions(logging=LoggingOptions(
997
+ enabled=True,
998
+ level="debug",
999
+ console=False,
1000
+ log_file=True,
1001
+ log_path="logs/architecture.log",
1002
+ append_to_log_file=True,
1003
+ ))
1004
+ violations = rule.check(options)
1005
+ ```
1006
+
1007
+ File logs are UTF-8, timestamped, and contain no added ANSI colors. Automatic
1008
+ paths are unique per check; an explicit `log_path` replaces that path.
1009
+ `append_to_log_file=False` overwrites an explicit file at the start of each check.
1010
+ Each check closes its own output file and isolates its diagnostics from nested
1011
+ and concurrent checks. Use separate paths when concurrent checks write to files.
1012
+ Diagnostic output is best effort: an unavailable file or failing logging handler
1013
+ does not change rule results or hide analysis exceptions. Debug inspection does
1014
+ not re-run custom predicates or metric calculations.
1015
+
1016
+ ### Format results independently of logging
1017
+
1018
+ ```python
1019
+ from archunitpython import assert_passes, format_violations
1020
+
1021
+ print(format_violations(violations)) # Detect terminal colors
1022
+ text = format_violations(violations, color=False) # Stable plain text for artifacts
1023
+ assert_passes(rule, options, color=False) # Plain assertion failure
1024
+ ```
1025
+
1026
+ Pass `color=True` to explicitly request ANSI styling; `NO_COLOR` always wins.
1027
+ The numbered report, rationale, and details remain available at every log level,
1028
+ including when logging is disabled. Existing plain-text report wording is retained.
1029
+
954
1030
  ### CI Pipeline Integration
955
1031
 
956
1032
  ```yaml
@@ -344,6 +344,12 @@ ArchUnitPython detects string-based dynamic imports such as `importlib.import_mo
344
344
  from my_app.adapters.sql import Repository # archunit: ignore
345
345
  ```
346
346
 
347
+ ### Namespace Packages
348
+
349
+ ArchUnitPython resolves imports from namespace packages that do not contain
350
+ `__init__.py` files. For example, `from my_app.domain import model` is resolved
351
+ to `my_app/domain/model.py` when `my_app/domain/` is a namespace package.
352
+
347
353
  ### Conditional Imports
348
354
 
349
355
  Imports inside `try` blocks that handle `ImportError` or
@@ -887,6 +893,10 @@ The most important differences:
887
893
 
888
894
  When tests fail, you get helpful output with file paths and violation details:
889
895
 
896
+ Interactive terminals also highlight the failure summary, numbered headings,
897
+ paths, metric values, and rule rationale. Output stays plain when redirected,
898
+ when `CI=true`, or when `NO_COLOR` is set (even to an empty value).
899
+
890
900
  ```
891
901
  Found 2 architecture violation(s):
892
902
 
@@ -899,25 +909,91 @@ Found 2 architecture violation(s):
899
909
 
900
910
  ## 📝 Debug Logging & Configuration
901
911
 
902
- We support logging to help you understand what files are being analyzed and troubleshoot test failures. Logging is disabled by default to keep test output clean.
912
+ Logging is disabled by default. Enable it per check to inspect file discovery,
913
+ graph extraction and cache use, selector decisions, edge projections, class
914
+ analysis, metric values, and the resulting violations. File, layer, slice,
915
+ count/LCOM/distance, and custom metric checks use the same logging options.
916
+
917
+ | Level | What you see |
918
+ | --- | --- |
919
+ | `error` | Analysis exceptions (the original exception is still raised). |
920
+ | `warn` | Failed check summaries and every violation, plus errors. |
921
+ | `info` | Check start, completion, duration, and violations, plus errors. |
922
+ | `debug` | All of the above, plus rule configuration, discovered files, dependencies, cache decisions, selector matches, projected/omitted edges, classes, and measured values. |
903
923
 
904
924
  ### Enabling Debug Logging
905
925
 
906
926
  ```python
907
- from archunitpython import CheckOptions
908
- from archunitpython.common.logging.types import LoggingOptions
927
+ from archunitpython import CheckOptions, LoggingOptions, project_files
928
+
929
+ rule = project_files("src/").should().have_no_cycles()
909
930
 
910
931
  options = CheckOptions(
911
932
  logging=LoggingOptions(
912
933
  enabled=True,
913
934
  level="debug", # "error" | "warn" | "info" | "debug"
914
- log_file=True, # Creates logs/archunit-YYYY-MM-DD_HH-MM-SS.log
935
+ log_file=True, # Creates a unique timestamped file in logs/
915
936
  ),
916
937
  )
917
938
 
918
939
  violations = rule.check(options)
919
940
  ```
920
941
 
942
+ For example, a debug trace includes entries like these (paths and timings depend
943
+ on your project):
944
+
945
+ ```text
946
+ [INFO] Starting check: CycleFreeFileCondition
947
+ [DEBUG] Graph cache miss
948
+ [DEBUG] Discovered file: /project/src/api.py
949
+ [DEBUG] Processing file: /project/src/api.py
950
+ [DEBUG] Dependency: /project/src/api.py -> /project/src/storage.py; external=False; kinds=[]
951
+ [DEBUG] Projection: /project/src/api.py -> /project/src/storage.py becomes /project/src/api.py -> /project/src/storage.py
952
+ [INFO] Finished check: CycleFreeFileCondition - 0 violation(s) (0.002s)
953
+ ```
954
+
955
+ Console diagnostics use the standard `archunitpython` logger when handlers are
956
+ configured, respecting their levels and formatting; otherwise they go to stderr.
957
+ For pytest live logs, use `pytest --log-cli-level=DEBUG`. You can also configure
958
+ Python's standard `logging` module in your application.
959
+
960
+ ### Save detailed inspection without console noise
961
+
962
+ ```python
963
+ options = CheckOptions(logging=LoggingOptions(
964
+ enabled=True,
965
+ level="debug",
966
+ console=False,
967
+ log_file=True,
968
+ log_path="logs/architecture.log",
969
+ append_to_log_file=True,
970
+ ))
971
+ violations = rule.check(options)
972
+ ```
973
+
974
+ File logs are UTF-8, timestamped, and contain no added ANSI colors. Automatic
975
+ paths are unique per check; an explicit `log_path` replaces that path.
976
+ `append_to_log_file=False` overwrites an explicit file at the start of each check.
977
+ Each check closes its own output file and isolates its diagnostics from nested
978
+ and concurrent checks. Use separate paths when concurrent checks write to files.
979
+ Diagnostic output is best effort: an unavailable file or failing logging handler
980
+ does not change rule results or hide analysis exceptions. Debug inspection does
981
+ not re-run custom predicates or metric calculations.
982
+
983
+ ### Format results independently of logging
984
+
985
+ ```python
986
+ from archunitpython import assert_passes, format_violations
987
+
988
+ print(format_violations(violations)) # Detect terminal colors
989
+ text = format_violations(violations, color=False) # Stable plain text for artifacts
990
+ assert_passes(rule, options, color=False) # Plain assertion failure
991
+ ```
992
+
993
+ Pass `color=True` to explicitly request ANSI styling; `NO_COLOR` always wins.
994
+ The numbered report, rationale, and details remain available at every log level,
995
+ including when logging is disabled. Existing plain-text report wording is retained.
996
+
921
997
  ### CI Pipeline Integration
922
998
 
923
999
  ```yaml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "archunitpython"
7
- version = "1.6.1"
7
+ version = "1.8.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,12 +1,13 @@
1
1
  """ArchUnitPython - Architecture testing library for Python projects."""
2
2
 
3
- __version__ = "1.6.1"
3
+ __version__ = "1.8.0"
4
4
 
5
5
  # Files API
6
6
  # Common
7
7
  from archunitpython.common import (
8
8
  CheckOptions,
9
9
  EmptyTestViolation,
10
+ LoggingOptions,
10
11
  TechnicalError,
11
12
  UserError,
12
13
  Violation,
@@ -50,6 +51,7 @@ __all__ = [
50
51
  "Violation",
51
52
  "EmptyTestViolation",
52
53
  "CheckOptions",
54
+ "LoggingOptions",
53
55
  "TechnicalError",
54
56
  "UserError",
55
57
  "extract_graph",
@@ -9,6 +9,7 @@ from dataclasses import dataclass
9
9
 
10
10
  from archunitpython.common.extraction.graph import Edge, Graph, ImportKind
11
11
  from archunitpython.common.fluentapi.checkable import CheckOptions
12
+ from archunitpython.common.logging.inspection import debug, debug_enabled
12
13
 
13
14
  GraphCacheKey = tuple[str, tuple[str, ...], bool]
14
15
 
@@ -45,6 +46,7 @@ class _LocatedImport:
45
46
  line_number: int
46
47
  resolution_kind: ImportKind | None = None
47
48
  fallback_module_name: str | None = None
49
+ aliases: tuple[str, ...] = ()
48
50
 
49
51
 
50
52
  @dataclass(frozen=True)
@@ -56,8 +58,7 @@ class _IgnoreDirective:
56
58
  if not self.modules:
57
59
  return True
58
60
  return any(
59
- import_.module_name == module
60
- or import_.module_name.startswith(f"{module}.")
61
+ import_.module_name == module or import_.module_name.startswith(f"{module}.")
61
62
  for module in self.modules
62
63
  )
63
64
 
@@ -96,21 +97,46 @@ def extract_graph(
96
97
  ignore_type_checking_imports = bool(options and options.ignore_type_checking_imports)
97
98
  cache_key = _build_cache_key(project_path, excludes, ignore_type_checking_imports)
98
99
 
100
+ debug(
101
+ "Extraction root: %s; excludes: %s; ignore type-only imports: %s",
102
+ project_path,
103
+ excludes,
104
+ ignore_type_checking_imports,
105
+ )
99
106
  if options and options.clear_cache:
107
+ debug("Clearing graph cache for %s", project_path)
100
108
  _graph_cache.pop(cache_key, None)
101
109
 
102
110
  if cache_key in _graph_cache:
111
+ debug("Graph cache hit: %d edges", len(_graph_cache[cache_key]))
112
+ _inspect_graph(_graph_cache[cache_key])
103
113
  return _graph_cache[cache_key]
114
+ debug("Graph cache miss")
104
115
 
105
116
  result = _extract_graph_uncached(
106
117
  project_path,
107
118
  excludes,
108
119
  ignore_type_checking_imports=ignore_type_checking_imports,
109
120
  )
121
+ _inspect_graph(result)
110
122
  _graph_cache[cache_key] = result
111
123
  return result
112
124
 
113
125
 
126
+ def _inspect_graph(graph: Graph) -> None:
127
+ if not debug_enabled():
128
+ return
129
+ debug("Extracted graph: %d edges (including self-edges)", len(graph))
130
+ for edge in graph:
131
+ debug(
132
+ "Dependency: %s -> %s; external=%s; kinds=%s",
133
+ edge.source,
134
+ edge.target,
135
+ edge.external,
136
+ edge.import_kinds,
137
+ )
138
+
139
+
114
140
  def _build_cache_key(
115
141
  project_path: str,
116
142
  exclude_patterns: list[str],
@@ -166,6 +192,7 @@ def _extract_graph_uncached(
166
192
  normalized_py_file_set = {_normalize(f) for f in py_files_set}
167
193
 
168
194
  for file_path in py_files:
195
+ debug("Processing file: %s", file_path)
169
196
  # Add self-referencing edge (ensures the file appears as a node)
170
197
  edges.append(
171
198
  Edge(
@@ -177,39 +204,32 @@ def _extract_graph_uncached(
177
204
 
178
205
  imports = _extract_located_imports(file_path)
179
206
  for located_import in imports:
180
- module_name = located_import.module_name
181
207
  import_kind = located_import.import_kind
182
- if (
183
- ignore_type_checking_imports
184
- and import_kind == ImportKind.TYPE_IMPORT
185
- ):
186
- continue
187
- resolution_kind = located_import.resolution_kind or import_kind
188
- resolved, is_external = _resolve_import(
189
- module_name, file_path, project_path, resolution_kind
190
- )
191
- if is_external and located_import.fallback_module_name is not None:
192
- fallback, fallback_is_external = _resolve_import(
193
- located_import.fallback_module_name,
208
+ if ignore_type_checking_imports and import_kind == ImportKind.TYPE_IMPORT:
209
+ debug(
210
+ "Skipped type-only import: %s:%s -> %s",
194
211
  file_path,
195
- project_path,
196
- resolution_kind,
212
+ located_import.line_number,
213
+ located_import.module_name,
197
214
  )
198
- if fallback and not fallback_is_external:
199
- resolved, is_external = fallback, False
200
- if resolved and resolved != _normalize(file_path):
201
- # Check if the resolved path is in our project
202
- if not is_external and resolved not in normalized_py_file_set:
203
- continue
204
-
205
- edges.append(
206
- Edge(
207
- source=_normalize(file_path),
208
- target=resolved,
209
- external=is_external,
210
- import_kinds=_edge_import_kinds(located_import),
215
+ continue
216
+ for resolved, is_external in _resolve_import_targets(
217
+ located_import, file_path, project_path
218
+ ):
219
+ if resolved and resolved != _normalize(file_path):
220
+ # Check if the resolved path is in our project
221
+ if not is_external and resolved not in normalized_py_file_set:
222
+ debug("Skipped excluded dependency: %s -> %s", file_path, resolved)
223
+ continue
224
+
225
+ edges.append(
226
+ Edge(
227
+ source=_normalize(file_path),
228
+ target=resolved,
229
+ external=is_external,
230
+ import_kinds=_edge_import_kinds(located_import),
231
+ )
211
232
  )
212
- )
213
233
 
214
234
  return _merge_edges(edges)
215
235
 
@@ -237,6 +257,7 @@ def _find_python_files(root: str, exclude: list[str]) -> list[str]:
237
257
  full_path, root, exclude, is_dir=False
238
258
  ):
239
259
  py_files.append(os.path.abspath(full_path))
260
+ debug("Discovered file: %s", full_path)
240
261
 
241
262
  return py_files
242
263
 
@@ -323,9 +344,7 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
323
344
  conditional_import_ranges,
324
345
  )
325
346
  for alias in node.names:
326
- imports.append(
327
- _LocatedImport(alias.name, kind, node.lineno, syntax_kind)
328
- )
347
+ imports.append(_LocatedImport(alias.name, kind, node.lineno, syntax_kind))
329
348
 
330
349
  elif isinstance(node, ast.ImportFrom):
331
350
  syntax_kind = (
@@ -339,17 +358,17 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
339
358
  type_checking_ranges,
340
359
  conditional_import_ranges,
341
360
  )
342
- fallback_module_name = (
343
- "." * node.level if node.level and node.module is None else None
344
- )
361
+ fallback_module_name = "." * node.level if node.level and node.module is None else None
362
+ aliases = _module_aliases(node) if node.module else ()
345
363
  for module_name in _import_from_module_names(node):
346
364
  imports.append(
347
365
  _LocatedImport(
348
- module_name,
349
- kind,
350
- node.lineno,
351
- syntax_kind,
352
- fallback_module_name,
366
+ module_name=module_name,
367
+ import_kind=kind,
368
+ line_number=node.lineno,
369
+ resolution_kind=syntax_kind,
370
+ fallback_module_name=fallback_module_name,
371
+ aliases=aliases,
353
372
  )
354
373
  )
355
374
 
@@ -362,15 +381,9 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
362
381
  conditional_import_ranges,
363
382
  )
364
383
  for module_name in _extract_dynamic_import_names(node):
365
- imports.append(
366
- _LocatedImport(module_name, kind, node.lineno, syntax_kind)
367
- )
384
+ imports.append(_LocatedImport(module_name, kind, node.lineno, syntax_kind))
368
385
 
369
- return [
370
- import_
371
- for import_ in imports
372
- if not _is_ignored_import(import_, ignore_directives)
373
- ]
386
+ return [import_ for import_ in imports if not _is_ignored_import(import_, ignore_directives)]
374
387
 
375
388
 
376
389
  def _find_ignore_directives(source: str) -> dict[int, _IgnoreDirective]:
@@ -429,6 +442,11 @@ def _extract_dynamic_import_names(node: ast.Call) -> list[str]:
429
442
  return []
430
443
 
431
444
 
445
+ def _module_aliases(node: ast.ImportFrom) -> tuple[str, ...]:
446
+ """Return aliases that may refer to imported submodules."""
447
+ return tuple(alias.name for alias in node.names if alias.name != "*")
448
+
449
+
432
450
  def _import_from_module_names(node: ast.ImportFrom) -> tuple[str, ...]:
433
451
  """Return resolvable module names for a from-import statement."""
434
452
  dots = "." * (node.level or 0)
@@ -558,14 +576,10 @@ def _resolve_import(
558
576
  Returns (resolved_path, is_external).
559
577
  The path is normalized with forward slashes.
560
578
  """
561
- if (
562
- kind
563
- in (
564
- ImportKind.RELATIVE_IMPORT,
565
- ImportKind.TYPE_IMPORT,
566
- )
567
- and import_name.startswith(".")
568
- ):
579
+ if kind in (
580
+ ImportKind.RELATIVE_IMPORT,
581
+ ImportKind.TYPE_IMPORT,
582
+ ) and import_name.startswith("."):
569
583
  # Relative import
570
584
  return _resolve_relative_import(import_name, source_file, project_root)
571
585
 
@@ -573,6 +587,58 @@ def _resolve_import(
573
587
  return _resolve_absolute_import(import_name, project_root)
574
588
 
575
589
 
590
+ def _resolve_import_targets(
591
+ import_: _LocatedImport,
592
+ source_file: str,
593
+ project_root: str,
594
+ ) -> list[tuple[str, bool]]:
595
+ """Resolve an import, including namespace-package submodule aliases."""
596
+ resolution_kind = import_.resolution_kind or import_.import_kind
597
+ resolved, is_external = _resolve_import(
598
+ import_.module_name,
599
+ source_file,
600
+ project_root,
601
+ resolution_kind,
602
+ )
603
+ if is_external and import_.fallback_module_name is not None:
604
+ fallback, fallback_is_external = _resolve_import(
605
+ import_.fallback_module_name,
606
+ source_file,
607
+ project_root,
608
+ resolution_kind,
609
+ )
610
+ if fallback and not fallback_is_external:
611
+ resolved, is_external = fallback, False
612
+ if not is_external or not import_.aliases:
613
+ return [(resolved, is_external)]
614
+
615
+ alias_targets: list[tuple[str, bool]] = []
616
+ found_internal_alias = False
617
+ for alias in import_.aliases:
618
+ alias_module = _join_import_alias(import_.module_name, alias)
619
+ alias_resolved, alias_is_external = _resolve_import(
620
+ alias_module,
621
+ source_file,
622
+ project_root,
623
+ resolution_kind,
624
+ )
625
+ alias_targets.append((alias_resolved, alias_is_external))
626
+ found_internal_alias = found_internal_alias or not alias_is_external
627
+
628
+ if found_internal_alias:
629
+ return alias_targets
630
+ return [(resolved, is_external)]
631
+
632
+
633
+ def _join_import_alias(module_name: str, alias: str) -> str:
634
+ """Join a from-import module name with a candidate submodule alias."""
635
+ if not module_name:
636
+ return alias
637
+ if set(module_name) == {"."}:
638
+ return f"{module_name}{alias}"
639
+ return f"{module_name}.{alias}"
640
+
641
+
576
642
  def _resolve_relative_import(
577
643
  import_name: str,
578
644
  source_file: str,