archunitpython 1.0.1__tar.gz → 1.1.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 (129) hide show
  1. {archunitpython-1.0.1 → archunitpython-1.1.0}/CHANGELOG.md +12 -0
  2. {archunitpython-1.0.1 → archunitpython-1.1.0}/PKG-INFO +2 -2
  3. {archunitpython-1.0.1 → archunitpython-1.1.0}/README.md +1 -1
  4. {archunitpython-1.0.1 → archunitpython-1.1.0}/pyproject.toml +3 -3
  5. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/extraction/extract_graph.py +37 -6
  6. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/fluentapi/checkable.py +1 -0
  7. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/projection/edge_projections.py +17 -0
  8. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/files/assertion/__init__.py +6 -0
  9. archunitpython-1.1.0/src/archunitpython/files/assertion/depend_on_external_modules.py +64 -0
  10. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/files/fluentapi/files.py +80 -1
  11. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/testing/common/violation_factory.py +12 -0
  12. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/common/test_core_types.py +3 -0
  13. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/common/test_extract_graph.py +98 -2
  14. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/common/test_projection.py +22 -1
  15. archunitpython-1.1.0/tests/files/test_files_fluentapi.py +355 -0
  16. archunitpython-1.0.1/tests/files/test_files_fluentapi.py +0 -174
  17. {archunitpython-1.0.1 → archunitpython-1.1.0}/.editorconfig +0 -0
  18. {archunitpython-1.0.1 → archunitpython-1.1.0}/.gitattributes +0 -0
  19. {archunitpython-1.0.1 → archunitpython-1.1.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  20. {archunitpython-1.0.1 → archunitpython-1.1.0}/.github/ISSUE_TEMPLATE/documentation.md +0 -0
  21. {archunitpython-1.0.1 → archunitpython-1.1.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  22. {archunitpython-1.0.1 → archunitpython-1.1.0}/.github/ISSUE_TEMPLATE/question.md +0 -0
  23. {archunitpython-1.0.1 → archunitpython-1.1.0}/.github/PAGES.md +0 -0
  24. {archunitpython-1.0.1 → archunitpython-1.1.0}/.github/dependabot.yml +0 -0
  25. {archunitpython-1.0.1 → archunitpython-1.1.0}/.github/pull_request_template.md +0 -0
  26. {archunitpython-1.0.1 → archunitpython-1.1.0}/.github/workflows/docs.yaml +0 -0
  27. {archunitpython-1.0.1 → archunitpython-1.1.0}/.github/workflows/integrate.yaml +0 -0
  28. {archunitpython-1.0.1 → archunitpython-1.1.0}/.github/workflows/stale.yaml +0 -0
  29. {archunitpython-1.0.1 → archunitpython-1.1.0}/.gitignore +0 -0
  30. {archunitpython-1.0.1 → archunitpython-1.1.0}/.releaserc.json +0 -0
  31. {archunitpython-1.0.1 → archunitpython-1.1.0}/CONTRIBUTING.md +0 -0
  32. {archunitpython-1.0.1 → archunitpython-1.1.0}/LICENSE +0 -0
  33. {archunitpython-1.0.1 → archunitpython-1.1.0}/TODO.md +0 -0
  34. {archunitpython-1.0.1 → archunitpython-1.1.0}/assets/logo-rounded.png +0 -0
  35. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/__init__.py +0 -0
  36. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/__init__.py +0 -0
  37. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/assertion/__init__.py +0 -0
  38. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/assertion/violation.py +0 -0
  39. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/error/__init__.py +0 -0
  40. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/error/errors.py +0 -0
  41. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/extraction/__init__.py +0 -0
  42. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/extraction/graph.py +0 -0
  43. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/fluentapi/__init__.py +0 -0
  44. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/logging/__init__.py +0 -0
  45. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/logging/types.py +0 -0
  46. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/pattern_matching.py +0 -0
  47. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/projection/__init__.py +0 -0
  48. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/projection/cycles/__init__.py +0 -0
  49. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/projection/cycles/cycle_utils.py +0 -0
  50. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/projection/cycles/cycles.py +0 -0
  51. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/projection/cycles/johnsons_apsp.py +0 -0
  52. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/projection/cycles/model.py +0 -0
  53. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/projection/cycles/tarjan_scc.py +0 -0
  54. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/projection/project_cycles.py +0 -0
  55. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/projection/project_edges.py +0 -0
  56. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/projection/project_nodes.py +0 -0
  57. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/projection/types.py +0 -0
  58. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/regex_factory.py +0 -0
  59. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/types.py +0 -0
  60. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/util/__init__.py +0 -0
  61. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/util/declaration_detector.py +0 -0
  62. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/common/util/logger.py +0 -0
  63. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/files/__init__.py +0 -0
  64. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/files/assertion/custom_file_logic.py +0 -0
  65. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/files/assertion/cycle_free.py +0 -0
  66. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/files/assertion/depend_on_files.py +0 -0
  67. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/files/assertion/matching_files.py +0 -0
  68. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/files/fluentapi/__init__.py +0 -0
  69. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/metrics/__init__.py +0 -0
  70. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/metrics/assertion/__init__.py +0 -0
  71. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/metrics/assertion/metric_thresholds.py +0 -0
  72. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/metrics/calculation/__init__.py +0 -0
  73. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/metrics/calculation/count.py +0 -0
  74. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/metrics/calculation/distance.py +0 -0
  75. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/metrics/calculation/lcom.py +0 -0
  76. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/metrics/common/__init__.py +0 -0
  77. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/metrics/common/types.py +0 -0
  78. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/metrics/extraction/__init__.py +0 -0
  79. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/metrics/extraction/extract_class_info.py +0 -0
  80. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/metrics/fluentapi/__init__.py +0 -0
  81. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/metrics/fluentapi/export_utils.py +0 -0
  82. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/metrics/fluentapi/metrics.py +0 -0
  83. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/metrics/projection/__init__.py +0 -0
  84. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/py.typed +0 -0
  85. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/slices/__init__.py +0 -0
  86. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/slices/assertion/__init__.py +0 -0
  87. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/slices/assertion/admissible_edges.py +0 -0
  88. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/slices/fluentapi/__init__.py +0 -0
  89. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/slices/fluentapi/slices.py +0 -0
  90. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/slices/projection/__init__.py +0 -0
  91. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/slices/projection/slicing_projections.py +0 -0
  92. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/slices/uml/__init__.py +0 -0
  93. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/slices/uml/export_diagram.py +0 -0
  94. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/slices/uml/generate_rules.py +0 -0
  95. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/testing/__init__.py +0 -0
  96. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/testing/assertion.py +0 -0
  97. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/testing/common/__init__.py +0 -0
  98. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/testing/common/color_utils.py +0 -0
  99. {archunitpython-1.0.1 → archunitpython-1.1.0}/src/archunitpython/testing/pytest_plugin/__init__.py +0 -0
  100. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/__init__.py +0 -0
  101. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/common/__init__.py +0 -0
  102. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/common/test_cycles.py +0 -0
  103. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/common/test_declaration_detector.py +0 -0
  104. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/common/test_logger.py +0 -0
  105. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/common/test_pattern_matching.py +0 -0
  106. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/files/__init__.py +0 -0
  107. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/files/test_file_assertions.py +0 -0
  108. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/fixtures/metrics_project/service.py +0 -0
  109. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/fixtures/sample_project/__init__.py +0 -0
  110. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/fixtures/sample_project/architecture.puml +0 -0
  111. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/fixtures/sample_project/controllers/__init__.py +0 -0
  112. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/fixtures/sample_project/controllers/controller.py +0 -0
  113. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/fixtures/sample_project/models/__init__.py +0 -0
  114. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/fixtures/sample_project/models/model.py +0 -0
  115. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/fixtures/sample_project/services/__init__.py +0 -0
  116. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/fixtures/sample_project/services/service.py +0 -0
  117. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/fixtures/sample_project/services/service_a.py +0 -0
  118. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/fixtures/sample_project/services/service_b.py +0 -0
  119. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/fixtures/sample_project/utils/__init__.py +0 -0
  120. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/fixtures/sample_project/utils/helpers.py +0 -0
  121. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/integration/__init__.py +0 -0
  122. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/integration/test_e2e.py +0 -0
  123. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/metrics/__init__.py +0 -0
  124. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/metrics/test_export.py +0 -0
  125. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/metrics/test_metrics.py +0 -0
  126. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/metrics/test_metrics_fluentapi.py +0 -0
  127. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/slices/__init__.py +0 -0
  128. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/slices/test_slices.py +0 -0
  129. {archunitpython-1.0.1 → archunitpython-1.1.0}/tests/test_setup.py +0 -0
@@ -1,3 +1,15 @@
1
+ # [1.1.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.0.1...v1.1.0) (2026-04-26)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * linting errors ([bae7613](https://github.com/LukasNiessen/ArchUnitPython/commit/bae761376817a858a622f2320da3a4596823c891))
7
+
8
+
9
+ ### Features
10
+
11
+ * add external dependencies and TYPE_CHECKING ([02b8d01](https://github.com/LukasNiessen/ArchUnitPython/commit/02b8d01b37752e5ddd815ab5c10b36f26814f438))
12
+
1
13
  ## [1.0.1](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.0.0...v1.0.1) (2026-04-02)
2
14
 
3
15
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: archunitpython
3
- Version: 1.0.1
3
+ Version: 1.1.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
@@ -189,7 +189,7 @@ Ensure services/modules don't have forbidden cross-dependencies.
189
189
 
190
190
  Here is a repository with a fully functioning example that uses ArchUnitPython to ensure architectural rules:
191
191
 
192
- - **[RAG Pipeline Example](https://github.com/LukasNiessen/ArchUnitPython-Example-RAG)**: A mock AI/RAG pipeline with layered architecture and intentional violations demonstrating ArchUnitPython catching real problems
192
+ - **[RAG Pipeline Test Showcase](https://github.com/LukasNiessen/ArchUnitPython-TestRepo-RAG)**: A test showcase demonstrating ArchUnitPython's architecture testing capabilities on a RAG pipeline
193
193
 
194
194
  ## 🐣 Features
195
195
 
@@ -157,7 +157,7 @@ Ensure services/modules don't have forbidden cross-dependencies.
157
157
 
158
158
  Here is a repository with a fully functioning example that uses ArchUnitPython to ensure architectural rules:
159
159
 
160
- - **[RAG Pipeline Example](https://github.com/LukasNiessen/ArchUnitPython-Example-RAG)**: A mock AI/RAG pipeline with layered architecture and intentional violations demonstrating ArchUnitPython catching real problems
160
+ - **[RAG Pipeline Test Showcase](https://github.com/LukasNiessen/ArchUnitPython-TestRepo-RAG)**: A test showcase demonstrating ArchUnitPython's architecture testing capabilities on a RAG pipeline
161
161
 
162
162
  ## 🐣 Features
163
163
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "archunitpython"
7
- version = "1.0.1"
7
+ version = "1.1.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"
@@ -67,13 +67,13 @@ python_classes = ["Test*"]
67
67
  python_functions = ["test_*"]
68
68
 
69
69
  [tool.mypy]
70
- python_version = "1.0.1"
70
+ python_version = "1.1.0"
71
71
  strict = true
72
72
  warn_return_any = true
73
73
  warn_unused_configs = true
74
74
 
75
75
  [tool.ruff]
76
- target-version = "1.0.1"
76
+ target-version = "1.1.0"
77
77
  line-length = 100
78
78
 
79
79
  [tool.ruff.lint]
@@ -8,7 +8,9 @@ import os
8
8
  from archunitpython.common.extraction.graph import Edge, Graph, ImportKind
9
9
  from archunitpython.common.fluentapi.checkable import CheckOptions
10
10
 
11
- _graph_cache: dict[str, Graph] = {}
11
+ GraphCacheKey = tuple[str, tuple[str, ...], bool]
12
+
13
+ _graph_cache: dict[GraphCacheKey, Graph] = {}
12
14
 
13
15
  _DEFAULT_EXCLUDE = [
14
16
  "__pycache__",
@@ -56,7 +58,13 @@ def extract_graph(
56
58
  project_path = os.getcwd()
57
59
 
58
60
  project_path = os.path.abspath(project_path)
59
- cache_key = project_path
61
+ excludes = list(exclude_patterns) if exclude_patterns is not None else list(_DEFAULT_EXCLUDE)
62
+ ignore_type_checking_imports = bool(
63
+ options and options.ignore_type_checking_imports
64
+ )
65
+ cache_key = _build_cache_key(
66
+ project_path, excludes, ignore_type_checking_imports
67
+ )
60
68
 
61
69
  if options and options.clear_cache:
62
70
  _graph_cache.pop(cache_key, None)
@@ -64,18 +72,36 @@ def extract_graph(
64
72
  if cache_key in _graph_cache:
65
73
  return _graph_cache[cache_key]
66
74
 
67
- result = _extract_graph_uncached(project_path, exclude_patterns)
75
+ result = _extract_graph_uncached(
76
+ project_path,
77
+ excludes,
78
+ ignore_type_checking_imports=ignore_type_checking_imports,
79
+ )
68
80
  _graph_cache[cache_key] = result
69
81
  return result
70
82
 
71
83
 
84
+ def _build_cache_key(
85
+ project_path: str,
86
+ exclude_patterns: list[str],
87
+ ignore_type_checking_imports: bool,
88
+ ) -> GraphCacheKey:
89
+ """Build a stable cache key for graph extraction options."""
90
+ return (
91
+ project_path,
92
+ tuple(sorted(exclude_patterns)),
93
+ ignore_type_checking_imports,
94
+ )
95
+
96
+
72
97
  def _extract_graph_uncached(
73
98
  project_path: str,
74
- exclude_patterns: list[str] | None = None,
99
+ exclude_patterns: list[str],
100
+ *,
101
+ ignore_type_checking_imports: bool = False,
75
102
  ) -> Graph:
76
103
  """Extract graph without caching."""
77
- excludes = exclude_patterns if exclude_patterns is not None else _DEFAULT_EXCLUDE
78
- py_files = _find_python_files(project_path, excludes)
104
+ py_files = _find_python_files(project_path, exclude_patterns)
79
105
 
80
106
  edges: list[Edge] = []
81
107
  py_files_set = set(py_files)
@@ -93,6 +119,11 @@ def _extract_graph_uncached(
93
119
  # Extract and resolve imports
94
120
  imports = _extract_imports(file_path)
95
121
  for module_name, import_kind in imports:
122
+ if (
123
+ ignore_type_checking_imports
124
+ and import_kind == ImportKind.TYPE_IMPORT
125
+ ):
126
+ continue
96
127
  resolved, is_external = _resolve_import(
97
128
  module_name, file_path, project_path, import_kind
98
129
  )
@@ -16,6 +16,7 @@ class CheckOptions:
16
16
  allow_empty_tests: bool = False
17
17
  logging: LoggingOptions | None = None
18
18
  clear_cache: bool = False
19
+ ignore_type_checking_imports: bool = False
19
20
 
20
21
 
21
22
  class Checkable(Protocol):
@@ -34,3 +34,20 @@ def per_edge() -> MapFunction:
34
34
  return MappedEdge(source_label=edge.source, target_label=edge.target)
35
35
 
36
36
  return mapper
37
+
38
+
39
+ def per_external_edge() -> MapFunction:
40
+ """Create a mapper that only passes external edges.
41
+
42
+ Self-referencing edges are filtered out, though they are not expected for
43
+ external imports.
44
+ """
45
+
46
+ def mapper(edge: Edge) -> MappedEdge | None:
47
+ if not edge.external:
48
+ return None
49
+ if edge.source == edge.target:
50
+ return None
51
+ return MappedEdge(source_label=edge.source, target_label=edge.target)
52
+
53
+ return mapper
@@ -5,6 +5,10 @@ from archunitpython.files.assertion.custom_file_logic import (
5
5
  gather_custom_file_violations,
6
6
  )
7
7
  from archunitpython.files.assertion.cycle_free import ViolatingCycle, gather_cycle_violations
8
+ from archunitpython.files.assertion.depend_on_external_modules import (
9
+ ViolatingExternalModuleDependency,
10
+ gather_depend_on_external_module_violations,
11
+ )
8
12
  from archunitpython.files.assertion.depend_on_files import (
9
13
  ViolatingFileDependency,
10
14
  gather_depend_on_file_violations,
@@ -20,9 +24,11 @@ __all__ = [
20
24
  "FileInfo",
21
25
  "ViolatingCycle",
22
26
  "ViolatingFileDependency",
27
+ "ViolatingExternalModuleDependency",
23
28
  "ViolatingNode",
24
29
  "gather_custom_file_violations",
25
30
  "gather_cycle_violations",
26
31
  "gather_depend_on_file_violations",
32
+ "gather_depend_on_external_module_violations",
27
33
  "gather_regex_matching_violations",
28
34
  ]
@@ -0,0 +1,64 @@
1
+ """Violation gathering for external module dependency rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from archunitpython.common.assertion.violation import Violation
8
+ from archunitpython.common.pattern_matching import matches_pattern
9
+ from archunitpython.common.projection.types import ProjectedEdge
10
+ from archunitpython.common.types import Filter
11
+
12
+
13
+ @dataclass
14
+ class ViolatingExternalModuleDependency(Violation):
15
+ """An external module dependency that violates a rule."""
16
+
17
+ dependency: ProjectedEdge
18
+ is_negated: bool = False
19
+
20
+
21
+ def gather_depend_on_external_module_violations(
22
+ edges: list[ProjectedEdge],
23
+ subject_filters: list[Filter],
24
+ module_filters: list[Filter],
25
+ is_negated: bool,
26
+ ) -> list[Violation]:
27
+ """Check if files depend on forbidden/allowed external modules.
28
+
29
+ Subject filters use AND semantics. Module filters use OR semantics, which
30
+ makes it possible to express useful allowlists or blocklists for external
31
+ module names.
32
+ """
33
+ violations: list[Violation] = []
34
+
35
+ for edge in edges:
36
+ source_matches = all(
37
+ matches_pattern(edge.source_label, filter_)
38
+ for filter_ in subject_filters
39
+ )
40
+ if not source_matches:
41
+ continue
42
+
43
+ target_matches = (
44
+ any(matches_pattern(edge.target_label, filter_) for filter_ in module_filters)
45
+ if module_filters
46
+ else False
47
+ )
48
+
49
+ if is_negated:
50
+ if target_matches:
51
+ violations.append(
52
+ ViolatingExternalModuleDependency(
53
+ dependency=edge, is_negated=True
54
+ )
55
+ )
56
+ else:
57
+ if not target_matches:
58
+ violations.append(
59
+ ViolatingExternalModuleDependency(
60
+ dependency=edge, is_negated=False
61
+ )
62
+ )
63
+
64
+ return violations
@@ -16,7 +16,10 @@ from archunitpython.common.assertion.violation import EmptyTestViolation, Violat
16
16
  from archunitpython.common.extraction.extract_graph import extract_graph
17
17
  from archunitpython.common.fluentapi.checkable import CheckOptions
18
18
  from archunitpython.common.pattern_matching import matches_all_patterns
19
- from archunitpython.common.projection.edge_projections import per_internal_edge
19
+ from archunitpython.common.projection.edge_projections import (
20
+ per_external_edge,
21
+ per_internal_edge,
22
+ )
20
23
  from archunitpython.common.projection.project_cycles import project_cycles
21
24
  from archunitpython.common.projection.project_edges import project_edges
22
25
  from archunitpython.common.projection.project_nodes import project_to_nodes
@@ -28,6 +31,9 @@ from archunitpython.files.assertion.custom_file_logic import (
28
31
  gather_custom_file_violations,
29
32
  )
30
33
  from archunitpython.files.assertion.cycle_free import gather_cycle_violations
34
+ from archunitpython.files.assertion.depend_on_external_modules import (
35
+ gather_depend_on_external_module_violations,
36
+ )
31
37
  from archunitpython.files.assertion.depend_on_files import gather_depend_on_file_violations
32
38
  from archunitpython.files.assertion.matching_files import gather_regex_matching_violations
33
39
 
@@ -133,6 +139,14 @@ class PositiveMatchPatternFileConditionBuilder:
133
139
  self._project_path, self._filters, is_negated=False
134
140
  )
135
141
 
142
+ def depend_on_external_modules(
143
+ self,
144
+ ) -> "DependOnExternalModuleConditionBuilder":
145
+ """Begin external dependency assertion for module names."""
146
+ return DependOnExternalModuleConditionBuilder(
147
+ self._project_path, self._filters, is_negated=False
148
+ )
149
+
136
150
  def be_in_folder(self, folder: Pattern) -> "MatchPatternFileCondition":
137
151
  """Assert that files are in a certain folder."""
138
152
  return MatchPatternFileCondition(
@@ -182,6 +196,14 @@ class NegatedMatchPatternFileConditionBuilder:
182
196
  self._project_path, self._filters, is_negated=True
183
197
  )
184
198
 
199
+ def depend_on_external_modules(
200
+ self,
201
+ ) -> "DependOnExternalModuleConditionBuilder":
202
+ """Begin negative external dependency assertion for module names."""
203
+ return DependOnExternalModuleConditionBuilder(
204
+ self._project_path, self._filters, is_negated=True
205
+ )
206
+
185
207
  def be_in_folder(self, folder: Pattern) -> "MatchPatternFileCondition":
186
208
  """Assert that files are NOT in a certain folder."""
187
209
  return MatchPatternFileCondition(
@@ -260,6 +282,31 @@ class DependOnFileConditionBuilder:
260
282
  )
261
283
 
262
284
 
285
+ class DependOnExternalModuleConditionBuilder:
286
+ """Configure external module dependency target patterns."""
287
+
288
+ def __init__(
289
+ self, project_path: str | None, filters: list[Filter], is_negated: bool
290
+ ) -> None:
291
+ self._project_path = project_path
292
+ self._filters = filters
293
+ self._is_negated = is_negated
294
+ self._module_filters: list[Filter] = []
295
+
296
+ def matching(self, module_name: Pattern) -> "DependOnExternalModuleCondition":
297
+ """Target external modules by dotted module name pattern.
298
+
299
+ Multiple calls are combined with OR semantics.
300
+ """
301
+ self._module_filters.append(RegexFactory.path_matcher(module_name))
302
+ return DependOnExternalModuleCondition(
303
+ self._project_path,
304
+ self._filters,
305
+ list(self._module_filters),
306
+ self._is_negated,
307
+ )
308
+
309
+
263
310
  def _get_filtered_nodes(
264
311
  project_path: str | None,
265
312
  filters: list[Filter],
@@ -346,6 +393,38 @@ class DependOnFileCondition:
346
393
  )
347
394
 
348
395
 
396
+ class DependOnExternalModuleCondition:
397
+ """Checkable that verifies external module dependency rules."""
398
+
399
+ def __init__(
400
+ self,
401
+ project_path: str | None,
402
+ subject_filters: list[Filter],
403
+ module_filters: list[Filter],
404
+ is_negated: bool,
405
+ ) -> None:
406
+ self._project_path = project_path
407
+ self._subject_filters = subject_filters
408
+ self._module_filters = module_filters
409
+ self._is_negated = is_negated
410
+
411
+ def matching(self, module_name: Pattern) -> "DependOnExternalModuleCondition":
412
+ """Add another external module pattern using OR semantics."""
413
+ self._module_filters.append(RegexFactory.path_matcher(module_name))
414
+ return self
415
+
416
+ def check(self, options: CheckOptions | None = None) -> list[Violation]:
417
+ graph = extract_graph(self._project_path, options=options)
418
+ edges = project_edges(graph, per_external_edge())
419
+
420
+ return gather_depend_on_external_module_violations(
421
+ edges,
422
+ self._subject_filters,
423
+ self._module_filters,
424
+ self._is_negated,
425
+ )
426
+
427
+
349
428
  class MatchPatternFileCondition:
350
429
  """Checkable that verifies files match/don't match patterns."""
351
430
 
@@ -7,6 +7,9 @@ from dataclasses import dataclass
7
7
  from archunitpython.common.assertion.violation import EmptyTestViolation, Violation
8
8
  from archunitpython.files.assertion.custom_file_logic import CustomFileViolation
9
9
  from archunitpython.files.assertion.cycle_free import ViolatingCycle
10
+ from archunitpython.files.assertion.depend_on_external_modules import (
11
+ ViolatingExternalModuleDependency,
12
+ )
10
13
  from archunitpython.files.assertion.depend_on_files import ViolatingFileDependency
11
14
  from archunitpython.files.assertion.matching_files import ViolatingNode
12
15
  from archunitpython.metrics.assertion.metric_thresholds import (
@@ -52,6 +55,15 @@ class ViolationFactory:
52
55
  f"'{edge.target_label}'",
53
56
  )
54
57
 
58
+ if isinstance(violation, ViolatingExternalModuleDependency):
59
+ edge = violation.dependency
60
+ return TestViolation(
61
+ message="External module dependency violation",
62
+ details=f"'{edge.source_label}' "
63
+ f"{'depends on' if violation.is_negated else 'does not depend on'} "
64
+ f"external module '{edge.target_label}'",
65
+ )
66
+
55
67
  if isinstance(violation, ViolatingCycle):
56
68
  cycle_str = " -> ".join(
57
69
  e.source_label for e in violation.cycle
@@ -101,6 +101,7 @@ class TestCheckOptions:
101
101
  assert opts.allow_empty_tests is False
102
102
  assert opts.logging is None
103
103
  assert opts.clear_cache is False
104
+ assert opts.ignore_type_checking_imports is False
104
105
 
105
106
  def test_custom(self):
106
107
  logging = LoggingOptions(enabled=True, level="debug")
@@ -108,12 +109,14 @@ class TestCheckOptions:
108
109
  allow_empty_tests=True,
109
110
  logging=logging,
110
111
  clear_cache=True,
112
+ ignore_type_checking_imports=True,
111
113
  )
112
114
  assert opts.allow_empty_tests is True
113
115
  assert opts.logging is not None
114
116
  assert opts.logging.enabled is True
115
117
  assert opts.logging.level == "debug"
116
118
  assert opts.clear_cache is True
119
+ assert opts.ignore_type_checking_imports is True
117
120
 
118
121
 
119
122
  class TestLoggingOptions:
@@ -1,6 +1,9 @@
1
1
  """Tests for graph extraction."""
2
2
 
3
3
  import os
4
+ import shutil
5
+ from pathlib import Path
6
+ from uuid import uuid4
4
7
 
5
8
  import pytest
6
9
 
@@ -12,6 +15,7 @@ from archunitpython.common.extraction.extract_graph import (
12
15
  extract_graph,
13
16
  )
14
17
  from archunitpython.common.extraction.graph import Edge, ImportKind
18
+ from archunitpython.common.fluentapi.checkable import CheckOptions
15
19
 
16
20
  FIXTURES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures")
17
21
  SAMPLE_PROJECT = os.path.join(FIXTURES_DIR, "sample_project")
@@ -116,8 +120,6 @@ class TestExtractGraph:
116
120
  assert graph1 is graph2 # Same object reference (cached)
117
121
 
118
122
  def test_cache_clear(self):
119
- from archunitpython.common.fluentapi.checkable import CheckOptions
120
-
121
123
  graph1 = extract_graph(SAMPLE_PROJECT)
122
124
  graph2 = extract_graph(
123
125
  SAMPLE_PROJECT, options=CheckOptions(clear_cache=True)
@@ -130,6 +132,100 @@ class TestExtractGraph:
130
132
  assert len(edges_with_kinds) > 0
131
133
 
132
134
 
135
+ class TestTypeCheckingImportHandling:
136
+ def setup_method(self):
137
+ clear_graph_cache()
138
+
139
+ def _build_type_checking_project(self) -> str:
140
+ temp_root = Path(__file__).resolve().parent / ".tmp"
141
+ temp_root.mkdir(exist_ok=True)
142
+ project_root = temp_root / f"project_{uuid4().hex}"
143
+ project_root.mkdir()
144
+
145
+ package_dir = project_root / "sample_project"
146
+ package_dir.mkdir(parents=True, exist_ok=True)
147
+
148
+ (package_dir / "__init__.py").write_text("", encoding="utf-8")
149
+ (package_dir / "models.py").write_text(
150
+ "class User:\n pass\n",
151
+ encoding="utf-8",
152
+ )
153
+ (package_dir / "service.py").write_text(
154
+ "\n".join(
155
+ [
156
+ "from typing import TYPE_CHECKING",
157
+ "",
158
+ "if TYPE_CHECKING:",
159
+ " from sample_project.models import User",
160
+ "",
161
+ "def get_user() -> str:",
162
+ ' return "ok"',
163
+ "",
164
+ ]
165
+ ),
166
+ encoding="utf-8",
167
+ )
168
+ self._temp_dir = project_root
169
+ return str(project_root)
170
+
171
+ def teardown_method(self):
172
+ temp_dir = getattr(self, "_temp_dir", None)
173
+ if temp_dir is not None:
174
+ shutil.rmtree(temp_dir, ignore_errors=True)
175
+
176
+ def test_type_checking_imports_included_by_default(self):
177
+ project_root = self._build_type_checking_project()
178
+
179
+ graph = extract_graph(project_root)
180
+ models_path = os.path.abspath(
181
+ os.path.join(project_root, "sample_project", "models.py")
182
+ ).replace("\\", "/")
183
+ service_path = os.path.abspath(
184
+ os.path.join(project_root, "sample_project", "service.py")
185
+ ).replace("\\", "/")
186
+
187
+ edges = [
188
+ edge
189
+ for edge in graph
190
+ if edge.source == service_path and edge.target == models_path
191
+ ]
192
+ assert len(edges) == 1
193
+ assert ImportKind.TYPE_IMPORT in edges[0].import_kinds
194
+
195
+ def test_type_checking_imports_can_be_ignored(self):
196
+ project_root = self._build_type_checking_project()
197
+
198
+ graph = extract_graph(
199
+ project_root,
200
+ options=CheckOptions(ignore_type_checking_imports=True),
201
+ )
202
+ models_path = os.path.abspath(
203
+ os.path.join(project_root, "sample_project", "models.py")
204
+ ).replace("\\", "/")
205
+ service_path = os.path.abspath(
206
+ os.path.join(project_root, "sample_project", "service.py")
207
+ ).replace("\\", "/")
208
+
209
+ edges = [
210
+ edge
211
+ for edge in graph
212
+ if edge.source == service_path and edge.target == models_path
213
+ ]
214
+ assert edges == []
215
+
216
+ def test_cache_key_includes_type_checking_option(self):
217
+ project_root = self._build_type_checking_project()
218
+
219
+ default_graph = extract_graph(project_root)
220
+ filtered_graph = extract_graph(
221
+ project_root,
222
+ options=CheckOptions(ignore_type_checking_imports=True),
223
+ )
224
+
225
+ assert default_graph is not filtered_graph
226
+ assert len(default_graph) > len(filtered_graph)
227
+
228
+
133
229
  class TestEdgeModel:
134
230
  def test_edge_frozen(self):
135
231
  edge = Edge(source="a.py", target="b.py", external=False)
@@ -1,7 +1,11 @@
1
1
  """Tests for graph projection functions."""
2
2
 
3
3
  from archunitpython.common.extraction.graph import Edge, ImportKind
4
- from archunitpython.common.projection.edge_projections import per_edge, per_internal_edge
4
+ from archunitpython.common.projection.edge_projections import (
5
+ per_edge,
6
+ per_external_edge,
7
+ per_internal_edge,
8
+ )
5
9
  from archunitpython.common.projection.project_edges import project_edges
6
10
  from archunitpython.common.projection.project_nodes import project_to_nodes
7
11
  from archunitpython.common.projection.types import MappedEdge
@@ -149,3 +153,20 @@ class TestPerEdge:
149
153
  mapper = per_edge()
150
154
  result = mapper(_make_edge("a", "b"))
151
155
  assert result is not None
156
+
157
+
158
+ class TestPerExternalEdge:
159
+ def test_filters_internal(self):
160
+ mapper = per_external_edge()
161
+ assert mapper(_make_edge("a", "b")) is None
162
+
163
+ def test_filters_self_edge(self):
164
+ mapper = per_external_edge()
165
+ assert mapper(_make_edge("a", "a", external=True)) is None
166
+
167
+ def test_passes_external(self):
168
+ mapper = per_external_edge()
169
+ result = mapper(_make_edge("a.py", "json", external=True))
170
+ assert result is not None
171
+ assert result.source_label == "a.py"
172
+ assert result.target_label == "json"