archunitpython 1.7.0__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.7.0 → archunitpython-1.8.0}/CHANGELOG.md +7 -0
  2. {archunitpython-1.7.0 → archunitpython-1.8.0}/PKG-INFO +75 -5
  3. {archunitpython-1.7.0 → archunitpython-1.8.0}/README.md +74 -4
  4. {archunitpython-1.7.0 → archunitpython-1.8.0}/pyproject.toml +1 -1
  5. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/__init__.py +3 -1
  6. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/extraction/extract_graph.py +45 -28
  7. archunitpython-1.8.0/src/archunitpython/common/fluentapi/inspection.py +79 -0
  8. archunitpython-1.8.0/src/archunitpython/common/logging/inspection.py +99 -0
  9. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/logging/types.py +2 -0
  10. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/pattern_matching.py +7 -2
  11. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/projection/project_edges.py +12 -1
  12. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/files/fluentapi/files.py +6 -0
  13. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/layers/fluentapi/layers.py +2 -0
  14. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/metrics/extraction/extract_class_info.py +17 -0
  15. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/metrics/fluentapi/metrics.py +47 -0
  16. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/slices/fluentapi/slices.py +3 -0
  17. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/testing/assertion.py +16 -6
  18. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/testing/common/color_utils.py +12 -1
  19. archunitpython-1.8.0/tests/common/test_inspection.py +244 -0
  20. archunitpython-1.8.0/tests/common/test_pretty_output.py +51 -0
  21. {archunitpython-1.7.0 → archunitpython-1.8.0}/.editorconfig +0 -0
  22. {archunitpython-1.7.0 → archunitpython-1.8.0}/.gitattributes +0 -0
  23. {archunitpython-1.7.0 → archunitpython-1.8.0}/.github/CODEOWNERS +0 -0
  24. {archunitpython-1.7.0 → archunitpython-1.8.0}/.github/FUNDING.yml +0 -0
  25. {archunitpython-1.7.0 → archunitpython-1.8.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  26. {archunitpython-1.7.0 → archunitpython-1.8.0}/.github/ISSUE_TEMPLATE/documentation.md +0 -0
  27. {archunitpython-1.7.0 → archunitpython-1.8.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  28. {archunitpython-1.7.0 → archunitpython-1.8.0}/.github/ISSUE_TEMPLATE/question.md +0 -0
  29. {archunitpython-1.7.0 → archunitpython-1.8.0}/.github/PAGES.md +0 -0
  30. {archunitpython-1.7.0 → archunitpython-1.8.0}/.github/dependabot.yml +0 -0
  31. {archunitpython-1.7.0 → archunitpython-1.8.0}/.github/pull_request_template.md +0 -0
  32. {archunitpython-1.7.0 → archunitpython-1.8.0}/.github/workflows/docs.yaml +0 -0
  33. {archunitpython-1.7.0 → archunitpython-1.8.0}/.github/workflows/integrate.yaml +0 -0
  34. {archunitpython-1.7.0 → archunitpython-1.8.0}/.github/workflows/stale.yaml +0 -0
  35. {archunitpython-1.7.0 → archunitpython-1.8.0}/.gitignore +0 -0
  36. {archunitpython-1.7.0 → archunitpython-1.8.0}/.releaserc.json +0 -0
  37. {archunitpython-1.7.0 → archunitpython-1.8.0}/BACKLOG.md +0 -0
  38. {archunitpython-1.7.0 → archunitpython-1.8.0}/CONTRIBUTING.md +0 -0
  39. {archunitpython-1.7.0 → archunitpython-1.8.0}/LICENSE +0 -0
  40. {archunitpython-1.7.0 → archunitpython-1.8.0}/assets/logo-rounded.png +0 -0
  41. {archunitpython-1.7.0 → archunitpython-1.8.0}/research/product-direction/architecture-testing-landscape.md +0 -0
  42. {archunitpython-1.7.0 → archunitpython-1.8.0}/scripts/bump_release_version.py +0 -0
  43. {archunitpython-1.7.0 → archunitpython-1.8.0}/scripts/check_release_metadata.py +0 -0
  44. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/__init__.py +0 -0
  45. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/assertion/__init__.py +0 -0
  46. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/assertion/violation.py +0 -0
  47. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/error/__init__.py +0 -0
  48. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/error/errors.py +0 -0
  49. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/extraction/__init__.py +0 -0
  50. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/extraction/graph.py +0 -0
  51. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/fluentapi/__init__.py +0 -0
  52. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/fluentapi/checkable.py +0 -0
  53. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/logging/__init__.py +0 -0
  54. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/projection/__init__.py +0 -0
  55. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/projection/cycles/__init__.py +0 -0
  56. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/projection/cycles/cycle_utils.py +0 -0
  57. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/projection/cycles/cycles.py +0 -0
  58. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/projection/cycles/johnsons_apsp.py +0 -0
  59. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/projection/cycles/model.py +0 -0
  60. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/projection/cycles/tarjan_scc.py +0 -0
  61. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/projection/edge_projections.py +0 -0
  62. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/projection/project_cycles.py +0 -0
  63. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/projection/project_nodes.py +0 -0
  64. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/projection/types.py +0 -0
  65. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/regex_factory.py +0 -0
  66. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/types.py +0 -0
  67. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/util/__init__.py +0 -0
  68. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/util/declaration_detector.py +0 -0
  69. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/common/util/logger.py +0 -0
  70. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/config/__init__.py +0 -0
  71. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/config/loader.py +0 -0
  72. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/files/__init__.py +0 -0
  73. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/files/assertion/__init__.py +0 -0
  74. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/files/assertion/custom_file_logic.py +0 -0
  75. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/files/assertion/cycle_free.py +0 -0
  76. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/files/assertion/depend_on_external_modules.py +0 -0
  77. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/files/assertion/depend_on_files.py +0 -0
  78. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/files/assertion/matching_files.py +0 -0
  79. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/files/fluentapi/__init__.py +0 -0
  80. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/graph/__init__.py +0 -0
  81. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/graph/graph_reporter.py +0 -0
  82. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/layers/__init__.py +0 -0
  83. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/layers/assertion/__init__.py +0 -0
  84. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/layers/assertion/layer_dependencies.py +0 -0
  85. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/layers/fluentapi/__init__.py +0 -0
  86. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/metrics/__init__.py +0 -0
  87. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/metrics/assertion/__init__.py +0 -0
  88. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/metrics/assertion/metric_thresholds.py +0 -0
  89. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/metrics/calculation/__init__.py +0 -0
  90. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/metrics/calculation/count.py +0 -0
  91. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/metrics/calculation/distance.py +0 -0
  92. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/metrics/calculation/lcom.py +0 -0
  93. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/metrics/common/__init__.py +0 -0
  94. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/metrics/common/types.py +0 -0
  95. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/metrics/extraction/__init__.py +0 -0
  96. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/metrics/fluentapi/__init__.py +0 -0
  97. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/metrics/fluentapi/export_utils.py +0 -0
  98. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/metrics/projection/__init__.py +0 -0
  99. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/py.typed +0 -0
  100. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/slices/__init__.py +0 -0
  101. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/slices/assertion/__init__.py +0 -0
  102. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/slices/assertion/admissible_edges.py +0 -0
  103. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/slices/fluentapi/__init__.py +0 -0
  104. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/slices/projection/__init__.py +0 -0
  105. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/slices/projection/slicing_projections.py +0 -0
  106. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/slices/uml/__init__.py +0 -0
  107. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/slices/uml/export_diagram.py +0 -0
  108. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/slices/uml/generate_rules.py +0 -0
  109. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/testing/__init__.py +0 -0
  110. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/testing/common/__init__.py +0 -0
  111. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/testing/common/violation_factory.py +0 -0
  112. {archunitpython-1.7.0 → archunitpython-1.8.0}/src/archunitpython/testing/pytest_plugin/__init__.py +0 -0
  113. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/__init__.py +0 -0
  114. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/common/__init__.py +0 -0
  115. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/common/test_core_types.py +0 -0
  116. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/common/test_cycles.py +0 -0
  117. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/common/test_declaration_detector.py +0 -0
  118. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/common/test_extract_graph.py +0 -0
  119. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/common/test_logger.py +0 -0
  120. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/common/test_pattern_matching.py +0 -0
  121. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/common/test_projection.py +0 -0
  122. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/config/__init__.py +0 -0
  123. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/config/test_config_loader.py +0 -0
  124. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/files/__init__.py +0 -0
  125. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/files/test_file_assertions.py +0 -0
  126. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/files/test_files_fluentapi.py +0 -0
  127. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/fixtures/metrics_project/service.py +0 -0
  128. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/fixtures/sample_project/__init__.py +0 -0
  129. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/fixtures/sample_project/architecture.puml +0 -0
  130. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/fixtures/sample_project/controllers/__init__.py +0 -0
  131. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/fixtures/sample_project/controllers/controller.py +0 -0
  132. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/fixtures/sample_project/models/__init__.py +0 -0
  133. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/fixtures/sample_project/models/model.py +0 -0
  134. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/fixtures/sample_project/services/__init__.py +0 -0
  135. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/fixtures/sample_project/services/service.py +0 -0
  136. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/fixtures/sample_project/services/service_a.py +0 -0
  137. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/fixtures/sample_project/services/service_b.py +0 -0
  138. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/fixtures/sample_project/utils/__init__.py +0 -0
  139. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/fixtures/sample_project/utils/helpers.py +0 -0
  140. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/graph/test_graph_reporter.py +0 -0
  141. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/integration/__init__.py +0 -0
  142. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/integration/test_e2e.py +0 -0
  143. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/layers/test_layers.py +0 -0
  144. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/metrics/__init__.py +0 -0
  145. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/metrics/test_export.py +0 -0
  146. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/metrics/test_metrics.py +0 -0
  147. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/metrics/test_metrics_fluentapi.py +0 -0
  148. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/slices/__init__.py +0 -0
  149. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/slices/test_slices.py +0 -0
  150. {archunitpython-1.7.0 → archunitpython-1.8.0}/tests/test_setup.py +0 -0
@@ -1,3 +1,10 @@
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
+
1
8
  # [1.7.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.6.1...v1.7.0) (2026-09-13)
2
9
 
3
10
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: archunitpython
3
- Version: 1.7.0
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
@@ -926,6 +926,10 @@ The most important differences:
926
926
 
927
927
  When tests fail, you get helpful output with file paths and violation details:
928
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
+
929
933
  ```
930
934
  Found 2 architecture violation(s):
931
935
 
@@ -938,25 +942,91 @@ Found 2 architecture violation(s):
938
942
 
939
943
  ## 📝 Debug Logging & Configuration
940
944
 
941
- 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. |
942
956
 
943
957
  ### Enabling Debug Logging
944
958
 
945
959
  ```python
946
- from archunitpython import CheckOptions
947
- 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()
948
963
 
949
964
  options = CheckOptions(
950
965
  logging=LoggingOptions(
951
966
  enabled=True,
952
967
  level="debug", # "error" | "warn" | "info" | "debug"
953
- log_file=True, # Creates logs/archunit-YYYY-MM-DD_HH-MM-SS.log
968
+ log_file=True, # Creates a unique timestamped file in logs/
954
969
  ),
955
970
  )
956
971
 
957
972
  violations = rule.check(options)
958
973
  ```
959
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
+
960
1030
  ### CI Pipeline Integration
961
1031
 
962
1032
  ```yaml
@@ -893,6 +893,10 @@ The most important differences:
893
893
 
894
894
  When tests fail, you get helpful output with file paths and violation details:
895
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
+
896
900
  ```
897
901
  Found 2 architecture violation(s):
898
902
 
@@ -905,25 +909,91 @@ Found 2 architecture violation(s):
905
909
 
906
910
  ## 📝 Debug Logging & Configuration
907
911
 
908
- 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. |
909
923
 
910
924
  ### Enabling Debug Logging
911
925
 
912
926
  ```python
913
- from archunitpython import CheckOptions
914
- 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()
915
930
 
916
931
  options = CheckOptions(
917
932
  logging=LoggingOptions(
918
933
  enabled=True,
919
934
  level="debug", # "error" | "warn" | "info" | "debug"
920
- log_file=True, # Creates logs/archunit-YYYY-MM-DD_HH-MM-SS.log
935
+ log_file=True, # Creates a unique timestamped file in logs/
921
936
  ),
922
937
  )
923
938
 
924
939
  violations = rule.check(options)
925
940
  ```
926
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
+
927
997
  ### CI Pipeline Integration
928
998
 
929
999
  ```yaml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "archunitpython"
7
- version = "1.7.0"
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.7.0"
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
 
@@ -57,8 +58,7 @@ class _IgnoreDirective:
57
58
  if not self.modules:
58
59
  return True
59
60
  return any(
60
- import_.module_name == module
61
- or import_.module_name.startswith(f"{module}.")
61
+ import_.module_name == module or import_.module_name.startswith(f"{module}.")
62
62
  for module in self.modules
63
63
  )
64
64
 
@@ -97,21 +97,46 @@ def extract_graph(
97
97
  ignore_type_checking_imports = bool(options and options.ignore_type_checking_imports)
98
98
  cache_key = _build_cache_key(project_path, excludes, ignore_type_checking_imports)
99
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
+ )
100
106
  if options and options.clear_cache:
107
+ debug("Clearing graph cache for %s", project_path)
101
108
  _graph_cache.pop(cache_key, None)
102
109
 
103
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])
104
113
  return _graph_cache[cache_key]
114
+ debug("Graph cache miss")
105
115
 
106
116
  result = _extract_graph_uncached(
107
117
  project_path,
108
118
  excludes,
109
119
  ignore_type_checking_imports=ignore_type_checking_imports,
110
120
  )
121
+ _inspect_graph(result)
111
122
  _graph_cache[cache_key] = result
112
123
  return result
113
124
 
114
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
+
115
140
  def _build_cache_key(
116
141
  project_path: str,
117
142
  exclude_patterns: list[str],
@@ -167,6 +192,7 @@ def _extract_graph_uncached(
167
192
  normalized_py_file_set = {_normalize(f) for f in py_files_set}
168
193
 
169
194
  for file_path in py_files:
195
+ debug("Processing file: %s", file_path)
170
196
  # Add self-referencing edge (ensures the file appears as a node)
171
197
  edges.append(
172
198
  Edge(
@@ -179,10 +205,13 @@ def _extract_graph_uncached(
179
205
  imports = _extract_located_imports(file_path)
180
206
  for located_import in imports:
181
207
  import_kind = located_import.import_kind
182
- if (
183
- ignore_type_checking_imports
184
- and import_kind == ImportKind.TYPE_IMPORT
185
- ):
208
+ if ignore_type_checking_imports and import_kind == ImportKind.TYPE_IMPORT:
209
+ debug(
210
+ "Skipped type-only import: %s:%s -> %s",
211
+ file_path,
212
+ located_import.line_number,
213
+ located_import.module_name,
214
+ )
186
215
  continue
187
216
  for resolved, is_external in _resolve_import_targets(
188
217
  located_import, file_path, project_path
@@ -190,6 +219,7 @@ def _extract_graph_uncached(
190
219
  if resolved and resolved != _normalize(file_path):
191
220
  # Check if the resolved path is in our project
192
221
  if not is_external and resolved not in normalized_py_file_set:
222
+ debug("Skipped excluded dependency: %s -> %s", file_path, resolved)
193
223
  continue
194
224
 
195
225
  edges.append(
@@ -227,6 +257,7 @@ def _find_python_files(root: str, exclude: list[str]) -> list[str]:
227
257
  full_path, root, exclude, is_dir=False
228
258
  ):
229
259
  py_files.append(os.path.abspath(full_path))
260
+ debug("Discovered file: %s", full_path)
230
261
 
231
262
  return py_files
232
263
 
@@ -313,9 +344,7 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
313
344
  conditional_import_ranges,
314
345
  )
315
346
  for alias in node.names:
316
- imports.append(
317
- _LocatedImport(alias.name, kind, node.lineno, syntax_kind)
318
- )
347
+ imports.append(_LocatedImport(alias.name, kind, node.lineno, syntax_kind))
319
348
 
320
349
  elif isinstance(node, ast.ImportFrom):
321
350
  syntax_kind = (
@@ -329,9 +358,7 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
329
358
  type_checking_ranges,
330
359
  conditional_import_ranges,
331
360
  )
332
- fallback_module_name = (
333
- "." * node.level if node.level and node.module is None else None
334
- )
361
+ fallback_module_name = "." * node.level if node.level and node.module is None else None
335
362
  aliases = _module_aliases(node) if node.module else ()
336
363
  for module_name in _import_from_module_names(node):
337
364
  imports.append(
@@ -354,15 +381,9 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
354
381
  conditional_import_ranges,
355
382
  )
356
383
  for module_name in _extract_dynamic_import_names(node):
357
- imports.append(
358
- _LocatedImport(module_name, kind, node.lineno, syntax_kind)
359
- )
384
+ imports.append(_LocatedImport(module_name, kind, node.lineno, syntax_kind))
360
385
 
361
- return [
362
- import_
363
- for import_ in imports
364
- if not _is_ignored_import(import_, ignore_directives)
365
- ]
386
+ return [import_ for import_ in imports if not _is_ignored_import(import_, ignore_directives)]
366
387
 
367
388
 
368
389
  def _find_ignore_directives(source: str) -> dict[int, _IgnoreDirective]:
@@ -555,14 +576,10 @@ def _resolve_import(
555
576
  Returns (resolved_path, is_external).
556
577
  The path is normalized with forward slashes.
557
578
  """
558
- if (
559
- kind
560
- in (
561
- ImportKind.RELATIVE_IMPORT,
562
- ImportKind.TYPE_IMPORT,
563
- )
564
- and import_name.startswith(".")
565
- ):
579
+ if kind in (
580
+ ImportKind.RELATIVE_IMPORT,
581
+ ImportKind.TYPE_IMPORT,
582
+ ) and import_name.startswith("."):
566
583
  # Relative import
567
584
  return _resolve_relative_import(import_name, source_file, project_root)
568
585
 
@@ -0,0 +1,79 @@
1
+ """Observational instrumentation for the existing check contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import wraps
6
+ from time import perf_counter
7
+ from typing import Callable, TypeVar, cast
8
+
9
+ from archunitpython.common.assertion.violation import Violation
10
+ from archunitpython.common.fluentapi.checkable import CheckOptions
11
+ from archunitpython.common.logging.inspection import InspectionSession, current_session
12
+
13
+ F = TypeVar("F", bound=Callable[..., list[Violation]])
14
+
15
+
16
+ def inspect_check(check: F) -> F:
17
+ """Trace a check while preserving its options, results, and exceptions."""
18
+
19
+ @wraps(check)
20
+ def inspected(self: object, options: CheckOptions | None = None) -> list[Violation]:
21
+ logging_options = options.logging if options else None
22
+ session = (
23
+ InspectionSession(logging_options)
24
+ if logging_options and logging_options.enabled
25
+ else None
26
+ )
27
+ token = current_session.set(session)
28
+ started = perf_counter() if session else 0.0
29
+ rule_name = type(self).__name__
30
+ try:
31
+ if session:
32
+ session.emit("info", "Starting check: %s", rule_name)
33
+ # Describe only known rule configuration, never user callbacks.
34
+ for name in (
35
+ "_project_path",
36
+ "_filters",
37
+ "_subject_filters",
38
+ "_object_filters",
39
+ "_module_filters",
40
+ "_pre_filters",
41
+ "_check_filters",
42
+ "_is_negated",
43
+ "_pattern",
44
+ "_regex",
45
+ "_source",
46
+ "_target",
47
+ "_layers",
48
+ "_allowed_dependencies",
49
+ "_forbidden_dependencies",
50
+ "_threshold",
51
+ "_comparison",
52
+ "_metric_attr",
53
+ "_name",
54
+ "_zone_type",
55
+ ):
56
+ if session.options.level == "debug" and name in vars(self):
57
+ session.emit("debug", "%s: %s", name.removeprefix("_"), vars(self)[name])
58
+ violations = check(self, options)
59
+ if session:
60
+ session.emit(
61
+ "warn" if violations else "info",
62
+ "Finished check: %s - %d violation(s) (%.3fs)",
63
+ rule_name,
64
+ len(violations),
65
+ perf_counter() - started,
66
+ )
67
+ for violation in violations:
68
+ session.emit("warn", "Violation: %s", violation)
69
+ return violations
70
+ except Exception as error:
71
+ if session:
72
+ session.emit("error", "Check failed: %s - %s", rule_name, error)
73
+ raise
74
+ finally:
75
+ current_session.reset(token)
76
+ if session:
77
+ session.close()
78
+
79
+ return cast(F, inspected)
@@ -0,0 +1,99 @@
1
+ """Per-check diagnostic sessions, isolated from architecture evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from contextvars import ContextVar
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import TextIO
11
+ from uuid import uuid4
12
+
13
+ from archunitpython.common.logging.types import LoggingOptions
14
+
15
+ _LEVELS = {
16
+ "debug": logging.DEBUG,
17
+ "info": logging.INFO,
18
+ "warn": logging.WARNING,
19
+ "error": logging.ERROR,
20
+ }
21
+
22
+
23
+ class InspectionSession:
24
+ """Own a check's output without attaching process-wide logging handlers.
25
+
26
+ Diagnostics are best effort: formatting and output errors must never replace
27
+ a rule result or an exception raised by the analysis itself.
28
+ """
29
+
30
+ def __init__(self, options: LoggingOptions) -> None:
31
+ self.options = options
32
+ self._file: TextIO | None = None
33
+ self._file_attempted = False
34
+
35
+ def emit(self, level: str, message: str, *args: object) -> None:
36
+ if _LEVELS[level] < _LEVELS.get(self.options.level, logging.INFO):
37
+ return
38
+ try:
39
+ text = message % args if args else message
40
+ except Exception:
41
+ return
42
+ if self.options.console:
43
+ try:
44
+ logger = logging.getLogger("archunitpython")
45
+ if logger.hasHandlers():
46
+ logger.log(_LEVELS[level], text)
47
+ else:
48
+ sys.stderr.write(f"[{level.upper()}] {text}\n")
49
+ except Exception:
50
+ pass
51
+ if self.options.log_file:
52
+ try:
53
+ if not self._file_attempted:
54
+ self._file_attempted = True
55
+ path = (
56
+ Path(self.options.log_path)
57
+ if self.options.log_path
58
+ else Path("logs")
59
+ / (
60
+ datetime.now().strftime("archunit-%Y-%m-%d_%H-%M-%S-")
61
+ + uuid4().hex
62
+ + ".log"
63
+ )
64
+ )
65
+ path.parent.mkdir(parents=True, exist_ok=True)
66
+ self._file = path.open(
67
+ "a" if self.options.append_to_log_file else "w", encoding="utf-8"
68
+ )
69
+ if self._file is not None:
70
+ self._file.write(f"[{datetime.now().isoformat()}] [{level.upper()}] {text}\n")
71
+ self._file.flush()
72
+ except Exception:
73
+ self.close()
74
+
75
+ def close(self) -> None:
76
+ if self._file is not None:
77
+ try:
78
+ self._file.close()
79
+ except Exception:
80
+ pass
81
+ self._file = None
82
+
83
+
84
+ current_session: ContextVar[InspectionSession | None] = ContextVar(
85
+ "archunitpython_inspection", default=None
86
+ )
87
+
88
+
89
+ def debug(message: str, *args: object) -> None:
90
+ """Lazily emit details only inside an enabled debug check."""
91
+ session = current_session.get()
92
+ if session is not None:
93
+ session.emit("debug", message, *args)
94
+
95
+
96
+ def debug_enabled() -> bool:
97
+ """Guard expensive diagnostic traversal before constructing its details."""
98
+ session = current_session.get()
99
+ return session is not None and session.options.level == "debug"
@@ -16,3 +16,5 @@ class LoggingOptions:
16
16
  level: LogLevel = "info"
17
17
  log_file: bool = False
18
18
  append_to_log_file: bool = False
19
+ console: bool = True
20
+ log_path: str | None = None
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from archunitpython.common.logging.inspection import debug
5
6
  from archunitpython.common.types import Filter
6
7
 
7
8
 
@@ -47,7 +48,9 @@ def matches_pattern(file_path: str, filter_: Filter) -> bool:
47
48
  else:
48
49
  target_string = normalize_path(file_path)
49
50
 
50
- return bool(filter_.regexp.search(target_string))
51
+ matched = bool(filter_.regexp.search(target_string))
52
+ debug("Selector %s against %s (%s): %s", filter_.regexp.pattern, target_string, target, matched)
53
+ return matched
51
54
 
52
55
 
53
56
  def matches_pattern_classname(class_name: str, file_path: str, filter_: Filter) -> bool:
@@ -65,7 +68,9 @@ def matches_pattern_classname(class_name: str, file_path: str, filter_: Filter)
65
68
  else:
66
69
  target_string = normalize_path(file_path)
67
70
 
68
- return bool(filter_.regexp.search(target_string))
71
+ matched = bool(filter_.regexp.search(target_string))
72
+ debug("Selector %s against %s (%s): %s", filter_.regexp.pattern, target_string, target, matched)
73
+ return matched
69
74
 
70
75
 
71
76
  def matches_all_patterns(file_path: str, filters: list[Filter]) -> bool:
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from collections import defaultdict
6
6
 
7
7
  from archunitpython.common.extraction.graph import Edge
8
+ from archunitpython.common.logging.inspection import debug
8
9
  from archunitpython.common.projection.types import MapFunction, ProjectedEdge
9
10
 
10
11
 
@@ -29,11 +30,19 @@ def project_edges(
29
30
  for edge in graph:
30
31
  mapped = mapper(edge)
31
32
  if mapped is None:
33
+ debug("Projection omitted: %s -> %s", edge.source, edge.target)
32
34
  continue
35
+ debug(
36
+ "Projection: %s -> %s becomes %s -> %s",
37
+ edge.source,
38
+ edge.target,
39
+ mapped.source_label,
40
+ mapped.target_label,
41
+ )
33
42
  key = (mapped.source_label, mapped.target_label)
34
43
  groups[key].append(edge)
35
44
 
36
- return [
45
+ projected = [
37
46
  ProjectedEdge(
38
47
  source_label=source_label,
39
48
  target_label=target_label,
@@ -41,3 +50,5 @@ def project_edges(
41
50
  )
42
51
  for (source_label, target_label), edges in groups.items()
43
52
  ]
53
+ debug("Projection complete: %d raw edges -> %d projected edges", len(graph), len(projected))
54
+ return projected