archunitpython 1.1.2__tar.gz → 1.2.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.
- archunitpython-1.2.0/.github/FUNDING.yml +1 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/CHANGELOG.md +7 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/PKG-INFO +160 -3
- {archunitpython-1.1.2 → archunitpython-1.2.0}/README.md +159 -2
- {archunitpython-1.1.2 → archunitpython-1.2.0}/pyproject.toml +1 -1
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/__init__.py +9 -1
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/extraction/extract_graph.py +114 -7
- archunitpython-1.2.0/src/archunitpython/graph/__init__.py +35 -0
- archunitpython-1.2.0/src/archunitpython/graph/graph_reporter.py +795 -0
- archunitpython-1.2.0/src/archunitpython/layers/__init__.py +8 -0
- archunitpython-1.2.0/src/archunitpython/layers/assertion/__init__.py +9 -0
- archunitpython-1.2.0/src/archunitpython/layers/assertion/layer_dependencies.py +83 -0
- archunitpython-1.2.0/src/archunitpython/layers/fluentapi/__init__.py +6 -0
- archunitpython-1.2.0/src/archunitpython/layers/fluentapi/layers.py +116 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/testing/common/violation_factory.py +10 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/common/test_extract_graph.py +161 -0
- archunitpython-1.2.0/tests/graph/test_graph_reporter.py +293 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/integration/test_e2e.py +8 -0
- archunitpython-1.2.0/tests/layers/test_layers.py +86 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/.editorconfig +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/.gitattributes +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/.github/CODEOWNERS +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/.github/ISSUE_TEMPLATE/documentation.md +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/.github/ISSUE_TEMPLATE/question.md +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/.github/PAGES.md +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/.github/dependabot.yml +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/.github/pull_request_template.md +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/.github/workflows/docs.yaml +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/.github/workflows/integrate.yaml +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/.github/workflows/stale.yaml +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/.gitignore +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/.releaserc.json +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/BACKLOG.md +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/CONTRIBUTING.md +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/LICENSE +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/assets/logo-rounded.png +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/research/product-direction/architecture-testing-landscape.md +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/scripts/bump_release_version.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/scripts/check_release_metadata.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/assertion/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/assertion/violation.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/error/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/error/errors.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/extraction/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/extraction/graph.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/fluentapi/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/fluentapi/checkable.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/logging/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/logging/types.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/pattern_matching.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/projection/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/projection/cycles/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/projection/cycles/cycle_utils.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/projection/cycles/cycles.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/projection/cycles/johnsons_apsp.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/projection/cycles/model.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/projection/cycles/tarjan_scc.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/projection/edge_projections.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/projection/project_cycles.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/projection/project_edges.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/projection/project_nodes.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/projection/types.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/regex_factory.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/types.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/util/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/util/declaration_detector.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/util/logger.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/files/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/files/assertion/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/files/assertion/custom_file_logic.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/files/assertion/cycle_free.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/files/assertion/depend_on_external_modules.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/files/assertion/depend_on_files.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/files/assertion/matching_files.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/files/fluentapi/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/files/fluentapi/files.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/metrics/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/metrics/assertion/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/metrics/assertion/metric_thresholds.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/metrics/calculation/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/metrics/calculation/count.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/metrics/calculation/distance.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/metrics/calculation/lcom.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/metrics/common/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/metrics/common/types.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/metrics/extraction/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/metrics/extraction/extract_class_info.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/metrics/fluentapi/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/metrics/fluentapi/export_utils.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/metrics/fluentapi/metrics.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/metrics/projection/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/py.typed +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/slices/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/slices/assertion/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/slices/assertion/admissible_edges.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/slices/fluentapi/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/slices/fluentapi/slices.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/slices/projection/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/slices/projection/slicing_projections.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/slices/uml/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/slices/uml/export_diagram.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/slices/uml/generate_rules.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/testing/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/testing/assertion.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/testing/common/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/testing/common/color_utils.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/testing/pytest_plugin/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/common/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/common/test_core_types.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/common/test_cycles.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/common/test_declaration_detector.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/common/test_logger.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/common/test_pattern_matching.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/common/test_projection.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/files/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/files/test_file_assertions.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/files/test_files_fluentapi.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/fixtures/metrics_project/service.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/fixtures/sample_project/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/fixtures/sample_project/architecture.puml +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/fixtures/sample_project/controllers/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/fixtures/sample_project/controllers/controller.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/fixtures/sample_project/models/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/fixtures/sample_project/models/model.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/fixtures/sample_project/services/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/fixtures/sample_project/services/service.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/fixtures/sample_project/services/service_a.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/fixtures/sample_project/services/service_b.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/fixtures/sample_project/utils/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/fixtures/sample_project/utils/helpers.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/integration/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/metrics/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/metrics/test_export.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/metrics/test_metrics.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/metrics/test_metrics_fluentapi.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/slices/__init__.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/slices/test_slices.py +0 -0
- {archunitpython-1.1.2 → archunitpython-1.2.0}/tests/test_setup.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
github: [LukasNiessen]
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [1.2.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.1.2...v1.2.0) (2026-06-27)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* add graph reporting and sponsorship links ([6d5eabc](https://github.com/LukasNiessen/ArchUnitPython/commit/6d5eabc1c1395c5bb4cf18e222ac472d33c02eb4))
|
|
7
|
+
|
|
1
8
|
## [1.1.2](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.1.1...v1.1.2) (2026-06-23)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: archunitpython
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.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
|
|
@@ -41,16 +41,19 @@ Description-Content-Type: text/markdown
|
|
|
41
41
|
|
|
42
42
|
[](https://opensource.org/licenses/MIT)
|
|
43
43
|
[](https://pypi.org/project/archunitpython/)
|
|
44
|
-
[](https://pepy.tech/project/archunitpython)
|
|
45
|
+
[](https://pepy.tech/project/archunitpython)
|
|
45
46
|
[](https://github.com/LukasNiessen/ArchUnitPython)
|
|
46
47
|
|
|
47
48
|
</div>
|
|
48
49
|
|
|
49
50
|
Enforce architecture rules in Python projects. Check for dependency directions, detect circular dependencies, enforce coding standards and much more. Integrates with pytest and any other testing framework. Very simple setup and pipeline integration. Zero runtime dependencies.
|
|
50
51
|
|
|
52
|
+
The #1 ArchUnit-style architecture testing library for Python, measured by GitHub stars.
|
|
53
|
+
|
|
51
54
|
_Inspired by the amazing ArchUnit library but we are not affiliated with ArchUnit._
|
|
52
55
|
|
|
53
|
-
[Setup](#-setup) • [Use Cases](#-use-cases) • [Features](#-features) • [Contributing](CONTRIBUTING.md)
|
|
56
|
+
[Setup](#-setup) • [Use Cases](#-use-cases) • [Features](#-features) • [Why ArchUnitPython?](#-library-comparison) • [Contributing](CONTRIBUTING.md)
|
|
54
57
|
|
|
55
58
|
## ⚡ 5 min Quickstart
|
|
56
59
|
|
|
@@ -121,6 +124,22 @@ These tests run automatically in your testing setup, for example in your CI pipe
|
|
|
121
124
|
run: pytest tests/test_architecture.py -v
|
|
122
125
|
```
|
|
123
126
|
|
|
127
|
+
You can also export dependency graph reports as CI artifacts:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from archunitpython import project_graph
|
|
131
|
+
|
|
132
|
+
def test_generate_dependency_graph_reports():
|
|
133
|
+
graph = project_graph("src/").titled("Application Architecture")
|
|
134
|
+
|
|
135
|
+
graph.collapse_to_folder_depth(2).export_as_html(
|
|
136
|
+
"reports/dependency-graph.html"
|
|
137
|
+
)
|
|
138
|
+
graph.export_as_mermaid("reports/dependency-graph.mmd")
|
|
139
|
+
|
|
140
|
+
assert graph.snapshot().summary.node_count >= 0
|
|
141
|
+
```
|
|
142
|
+
|
|
124
143
|
## 🚐 Setup
|
|
125
144
|
|
|
126
145
|
Installation:
|
|
@@ -165,6 +184,7 @@ from archunitpython import CheckOptions
|
|
|
165
184
|
options = CheckOptions(
|
|
166
185
|
allow_empty_tests=True, # Don't fail when no files match
|
|
167
186
|
clear_cache=True, # Clear the graph cache
|
|
187
|
+
ignore_type_checking_imports=True, # Ignore imports inside if TYPE_CHECKING
|
|
168
188
|
)
|
|
169
189
|
|
|
170
190
|
violations = rule.check(options)
|
|
@@ -228,6 +248,65 @@ def test_business_not_depend_on_presentation():
|
|
|
228
248
|
assert_passes(rule)
|
|
229
249
|
```
|
|
230
250
|
|
|
251
|
+
### Named Layer Rules
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from archunitpython import project_layers
|
|
255
|
+
|
|
256
|
+
def test_clean_architecture_layers():
|
|
257
|
+
rule = (
|
|
258
|
+
project_layers("src/")
|
|
259
|
+
.layer("presentation").defined_by_folder("**/presentation/**")
|
|
260
|
+
.layer("business").defined_by_folder("**/business/**")
|
|
261
|
+
.layer("database").defined_by_folder("**/database/**")
|
|
262
|
+
.where_layer("presentation")
|
|
263
|
+
.may_only_depend_on_layers("business")
|
|
264
|
+
.where_layer("business")
|
|
265
|
+
.may_only_depend_on_layers()
|
|
266
|
+
.where_layer("database")
|
|
267
|
+
.may_only_depend_on_layers()
|
|
268
|
+
)
|
|
269
|
+
assert_passes(rule)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### External Dependencies
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
def test_domain_does_not_import_requests():
|
|
276
|
+
rule = (
|
|
277
|
+
project_files("src/")
|
|
278
|
+
.in_folder("**/domain/**")
|
|
279
|
+
.should_not()
|
|
280
|
+
.depend_on_external_modules()
|
|
281
|
+
.matching("requests")
|
|
282
|
+
)
|
|
283
|
+
assert_passes(rule)
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### TYPE_CHECKING-aware Analysis
|
|
287
|
+
|
|
288
|
+
```python
|
|
289
|
+
from archunitpython import CheckOptions
|
|
290
|
+
|
|
291
|
+
def test_type_only_dependencies_do_not_count_as_runtime_coupling():
|
|
292
|
+
rule = (
|
|
293
|
+
project_files("src/")
|
|
294
|
+
.in_folder("**/api/**")
|
|
295
|
+
.should_not()
|
|
296
|
+
.depend_on_files()
|
|
297
|
+
.in_folder("**/infrastructure/**")
|
|
298
|
+
)
|
|
299
|
+
assert_passes(rule, CheckOptions(ignore_type_checking_imports=True))
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Dynamic Imports and Ignore Directives
|
|
303
|
+
|
|
304
|
+
ArchUnitPython detects string-based dynamic imports such as `importlib.import_module("my_app.adapters.sql")` and `__import__("my_app.adapters.sql")`. For known migration shims, you can suppress one import edge locally:
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
from my_app.adapters.sql import Repository # archunit: ignore
|
|
308
|
+
```
|
|
309
|
+
|
|
231
310
|
### Naming Conventions
|
|
232
311
|
|
|
233
312
|
```python
|
|
@@ -350,6 +429,48 @@ def test_no_forbidden_dependency():
|
|
|
350
429
|
assert_passes(rule)
|
|
351
430
|
```
|
|
352
431
|
|
|
432
|
+
### Dependency Graph Reports
|
|
433
|
+
|
|
434
|
+
Generate dependency graph reports in multiple formats and narrow them to the part of the codebase you want to inspect.
|
|
435
|
+
|
|
436
|
+
```python
|
|
437
|
+
from archunitpython import project_graph
|
|
438
|
+
|
|
439
|
+
def test_export_dependency_graph_reports():
|
|
440
|
+
graph = project_graph("src/").titled("Application Architecture")
|
|
441
|
+
|
|
442
|
+
graph.collapse_to_folder_depth(2).export_as_mermaid(
|
|
443
|
+
"reports/dependencies.mmd"
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
graph.focus_on("**/domain/**", 1).export_as_html(
|
|
447
|
+
"reports/domain-dependencies.html"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
assert graph.snapshot().summary.node_count >= 0
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Supported formats:
|
|
454
|
+
|
|
455
|
+
- DOT (`export_as_dot`, `to_dot`)
|
|
456
|
+
- Mermaid (`export_as_mermaid`, `to_mermaid`)
|
|
457
|
+
- D2 (`export_as_d2`, `to_d2`)
|
|
458
|
+
- CSV (`export_as_csv`, `to_csv`)
|
|
459
|
+
- JSON (`export_as_json`, `to_json`)
|
|
460
|
+
- HTML (`export_as_html`, `to_html`)
|
|
461
|
+
|
|
462
|
+
Graph exploration options:
|
|
463
|
+
|
|
464
|
+
- `focus_on(pattern, depth)` keeps matching files and their neighbors.
|
|
465
|
+
- `reachable_from(pattern)` keeps matching files and their transitive dependencies.
|
|
466
|
+
- `dependents_of(pattern)` keeps files that transitively depend on the matching files.
|
|
467
|
+
- `collapse_to_folder_depth(depth)` aggregates files to folder-level graph nodes.
|
|
468
|
+
- `collapse_by_pattern(pattern, replacement)` maps files to custom graph nodes.
|
|
469
|
+
- `include_external_dependencies()` includes imports to external modules such as `requests` or `sqlalchemy`.
|
|
470
|
+
- `include_self_dependencies()` keeps self edges that are normally hidden in reports.
|
|
471
|
+
|
|
472
|
+
When you create reports through `project_graph("src/")`, internal file paths are displayed relative to that project root so the output stays readable.
|
|
473
|
+
|
|
353
474
|
### Reports
|
|
354
475
|
|
|
355
476
|
Generate HTML reports for your metrics. _Note that this feature is in beta._
|
|
@@ -529,6 +650,40 @@ def test_from_file():
|
|
|
529
650
|
assert_passes(rule)
|
|
530
651
|
```
|
|
531
652
|
|
|
653
|
+
## 📊 Library Comparison
|
|
654
|
+
|
|
655
|
+
Here's how ArchUnitPython compares to other Python architecture-enforcement libraries.
|
|
656
|
+
|
|
657
|
+
ArchUnitPython is optimized for **architecture rules as tests**: rules live next to your normal unit tests, run in pytest/unittest/CI, and fail with test-style violation messages. Broader CLI-first tools such as [Tach](https://github.com/tach-org/tach) and [Import Linter](https://github.com/seddonym/import-linter) are excellent adjacent tools, but they solve the problem through separate configuration and commands rather than a test-native ArchUnit-style API.
|
|
658
|
+
|
|
659
|
+
| Feature | **ArchUnitPython** | **Tach** | **Import Linter** | **PyTestArch** |
|
|
660
|
+
| ------- | ------------------ | -------- | ----------------- | -------------- |
|
|
661
|
+
| **Primary workflow** | ✅ Architecture rules as unit tests | ⚠️ CLI + `tach.toml` | ⚠️ CLI + contracts config | ⚠️ pytest-oriented evaluable architecture |
|
|
662
|
+
| **ArchUnit-style fluent API** | ✅ Yes | ❌ No | ❌ No | ⚠️ Partial |
|
|
663
|
+
| **Testing framework integration** | ✅ pytest, unittest, any runner | ⚠️ CI/pre-commit CLI | ⚠️ CI/pre-commit CLI | ⚠️ pytest-focused |
|
|
664
|
+
| **Zero runtime dependencies** | ✅ Standard library only | ⚠️ No app runtime impact, Rust-backed tool | ❌ Tool dependencies | ❌ Tool dependencies |
|
|
665
|
+
| **Circular dependency detection** | ✅ First-class | ✅ First-class | ⚠️ Contract/graph based | ⚠️ Import-rule based |
|
|
666
|
+
| **File/folder dependency rules** | ✅ Glob + regex | ✅ Module config | ✅ Import contracts | ✅ Module rules |
|
|
667
|
+
| **Named layer rules** | ✅ `project_layers()` | ✅ Supported | ✅ Supported | ✅ Supported |
|
|
668
|
+
| **External dependency rules** | ✅ `depend_on_external_modules()` | ⚠️ Internal module focus | ⚠️ Import contract focus | ⚠️ Internal import focus |
|
|
669
|
+
| **TYPE_CHECKING-aware analysis** | ✅ Configurable | ⚠️ Not the core API | ⚠️ Not the core API | ⚠️ Not the core API |
|
|
670
|
+
| **Dynamic import detection** | ✅ `importlib` + `__import__` string calls | ⚠️ Not the core workflow | ⚠️ Not the core workflow | ⚠️ Import analysis focused |
|
|
671
|
+
| **Inline ignore directives** | ✅ `# archunit: ignore` | ✅ Supported | ⚠️ Config-based ignores | ⚠️ Rule/exclusion based |
|
|
672
|
+
| **Naming convention checks** | ✅ Files and paths | ❌ No | ❌ No | ⚠️ Module-name oriented |
|
|
673
|
+
| **Code metrics** | ✅ Counts, LCOM, distance metrics | ❌ No | ❌ No | ❌ No |
|
|
674
|
+
| **Custom rules and metrics** | ✅ Full support | ❌ No | ⚠️ Custom contracts | ⚠️ Limited custom rule composition |
|
|
675
|
+
| **PlantUML diagram validation** | ✅ Supported | ❌ No | ❌ No | ❌ No |
|
|
676
|
+
| **Empty test protection** | ✅ Fails by default | ⚠️ Config validation | ⚠️ Contract validation | ⚠️ Not the main focus |
|
|
677
|
+
| **Graph/reporting** | ✅ DOT, Mermaid, D2, CSV, JSON, HTML graph reports + metrics HTML | ✅ DOT, JSON, web graph | ✅ Browser UI | ⚠️ Optional graph visualization |
|
|
678
|
+
| **Best fit** | Architecture tests, CI fitness functions, metrics, diagrams | Modular monolith dependency governance | Config-driven import contracts | pytest import-boundary checks |
|
|
679
|
+
|
|
680
|
+
The most important differences:
|
|
681
|
+
|
|
682
|
+
- **Test-native by design**: ArchUnitPython rules are just Python tests, so architecture decisions are reviewed, run, and debugged in the same workflow as the rest of your test suite.
|
|
683
|
+
- **Broader rule surface**: dependency direction, cycles, layer policies, external modules, type-only imports, dynamic imports, naming, metrics, custom rules, and PlantUML validation live in one API.
|
|
684
|
+
- **False-positive protection**: empty checks fail by default, which helps catch typos in file and folder patterns before they silently make your architecture tests meaningless.
|
|
685
|
+
- **Quality beyond imports**: ArchUnitPython can enforce code metrics such as LCOM cohesion, field/method counts, abstractness, instability, and distance from the main sequence.
|
|
686
|
+
|
|
532
687
|
## 📢 Informative Error Messages
|
|
533
688
|
|
|
534
689
|
When tests fail, you get helpful output with file paths and violation details:
|
|
@@ -590,6 +745,7 @@ The features of ArchUnitPython can very well be used as architectural fitness fu
|
|
|
590
745
|
| **Files** | File and folder based rules | Stable |
|
|
591
746
|
| **Metrics** | Code quality metrics | Stable |
|
|
592
747
|
| **Slices** | Architecture slicing | Stable |
|
|
748
|
+
| **Graph** | Dependency graph reports | Experimental |
|
|
593
749
|
| **Testing** | Test framework integration | Stable |
|
|
594
750
|
| **Common** | Shared utilities | Stable |
|
|
595
751
|
| **Reports** | Generate HTML reports | Experimental |
|
|
@@ -659,6 +815,7 @@ Found a bug? Want to discuss features?
|
|
|
659
815
|
If ArchUnitPython helps your project, please consider:
|
|
660
816
|
|
|
661
817
|
- Starring the repository 💚
|
|
818
|
+
- Sponsoring development via [GitHub Sponsors](https://github.com/sponsors/LukasNiessen)
|
|
662
819
|
- Suggesting new features 💭
|
|
663
820
|
- Contributing code or documentation ⌨️
|
|
664
821
|
|
|
@@ -8,16 +8,19 @@
|
|
|
8
8
|
|
|
9
9
|
[](https://opensource.org/licenses/MIT)
|
|
10
10
|
[](https://pypi.org/project/archunitpython/)
|
|
11
|
-
[](https://pepy.tech/project/archunitpython)
|
|
12
|
+
[](https://pepy.tech/project/archunitpython)
|
|
12
13
|
[](https://github.com/LukasNiessen/ArchUnitPython)
|
|
13
14
|
|
|
14
15
|
</div>
|
|
15
16
|
|
|
16
17
|
Enforce architecture rules in Python projects. Check for dependency directions, detect circular dependencies, enforce coding standards and much more. Integrates with pytest and any other testing framework. Very simple setup and pipeline integration. Zero runtime dependencies.
|
|
17
18
|
|
|
19
|
+
The #1 ArchUnit-style architecture testing library for Python, measured by GitHub stars.
|
|
20
|
+
|
|
18
21
|
_Inspired by the amazing ArchUnit library but we are not affiliated with ArchUnit._
|
|
19
22
|
|
|
20
|
-
[Setup](#-setup) • [Use Cases](#-use-cases) • [Features](#-features) • [Contributing](CONTRIBUTING.md)
|
|
23
|
+
[Setup](#-setup) • [Use Cases](#-use-cases) • [Features](#-features) • [Why ArchUnitPython?](#-library-comparison) • [Contributing](CONTRIBUTING.md)
|
|
21
24
|
|
|
22
25
|
## ⚡ 5 min Quickstart
|
|
23
26
|
|
|
@@ -88,6 +91,22 @@ These tests run automatically in your testing setup, for example in your CI pipe
|
|
|
88
91
|
run: pytest tests/test_architecture.py -v
|
|
89
92
|
```
|
|
90
93
|
|
|
94
|
+
You can also export dependency graph reports as CI artifacts:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from archunitpython import project_graph
|
|
98
|
+
|
|
99
|
+
def test_generate_dependency_graph_reports():
|
|
100
|
+
graph = project_graph("src/").titled("Application Architecture")
|
|
101
|
+
|
|
102
|
+
graph.collapse_to_folder_depth(2).export_as_html(
|
|
103
|
+
"reports/dependency-graph.html"
|
|
104
|
+
)
|
|
105
|
+
graph.export_as_mermaid("reports/dependency-graph.mmd")
|
|
106
|
+
|
|
107
|
+
assert graph.snapshot().summary.node_count >= 0
|
|
108
|
+
```
|
|
109
|
+
|
|
91
110
|
## 🚐 Setup
|
|
92
111
|
|
|
93
112
|
Installation:
|
|
@@ -132,6 +151,7 @@ from archunitpython import CheckOptions
|
|
|
132
151
|
options = CheckOptions(
|
|
133
152
|
allow_empty_tests=True, # Don't fail when no files match
|
|
134
153
|
clear_cache=True, # Clear the graph cache
|
|
154
|
+
ignore_type_checking_imports=True, # Ignore imports inside if TYPE_CHECKING
|
|
135
155
|
)
|
|
136
156
|
|
|
137
157
|
violations = rule.check(options)
|
|
@@ -195,6 +215,65 @@ def test_business_not_depend_on_presentation():
|
|
|
195
215
|
assert_passes(rule)
|
|
196
216
|
```
|
|
197
217
|
|
|
218
|
+
### Named Layer Rules
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
from archunitpython import project_layers
|
|
222
|
+
|
|
223
|
+
def test_clean_architecture_layers():
|
|
224
|
+
rule = (
|
|
225
|
+
project_layers("src/")
|
|
226
|
+
.layer("presentation").defined_by_folder("**/presentation/**")
|
|
227
|
+
.layer("business").defined_by_folder("**/business/**")
|
|
228
|
+
.layer("database").defined_by_folder("**/database/**")
|
|
229
|
+
.where_layer("presentation")
|
|
230
|
+
.may_only_depend_on_layers("business")
|
|
231
|
+
.where_layer("business")
|
|
232
|
+
.may_only_depend_on_layers()
|
|
233
|
+
.where_layer("database")
|
|
234
|
+
.may_only_depend_on_layers()
|
|
235
|
+
)
|
|
236
|
+
assert_passes(rule)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### External Dependencies
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
def test_domain_does_not_import_requests():
|
|
243
|
+
rule = (
|
|
244
|
+
project_files("src/")
|
|
245
|
+
.in_folder("**/domain/**")
|
|
246
|
+
.should_not()
|
|
247
|
+
.depend_on_external_modules()
|
|
248
|
+
.matching("requests")
|
|
249
|
+
)
|
|
250
|
+
assert_passes(rule)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### TYPE_CHECKING-aware Analysis
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
from archunitpython import CheckOptions
|
|
257
|
+
|
|
258
|
+
def test_type_only_dependencies_do_not_count_as_runtime_coupling():
|
|
259
|
+
rule = (
|
|
260
|
+
project_files("src/")
|
|
261
|
+
.in_folder("**/api/**")
|
|
262
|
+
.should_not()
|
|
263
|
+
.depend_on_files()
|
|
264
|
+
.in_folder("**/infrastructure/**")
|
|
265
|
+
)
|
|
266
|
+
assert_passes(rule, CheckOptions(ignore_type_checking_imports=True))
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Dynamic Imports and Ignore Directives
|
|
270
|
+
|
|
271
|
+
ArchUnitPython detects string-based dynamic imports such as `importlib.import_module("my_app.adapters.sql")` and `__import__("my_app.adapters.sql")`. For known migration shims, you can suppress one import edge locally:
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
from my_app.adapters.sql import Repository # archunit: ignore
|
|
275
|
+
```
|
|
276
|
+
|
|
198
277
|
### Naming Conventions
|
|
199
278
|
|
|
200
279
|
```python
|
|
@@ -317,6 +396,48 @@ def test_no_forbidden_dependency():
|
|
|
317
396
|
assert_passes(rule)
|
|
318
397
|
```
|
|
319
398
|
|
|
399
|
+
### Dependency Graph Reports
|
|
400
|
+
|
|
401
|
+
Generate dependency graph reports in multiple formats and narrow them to the part of the codebase you want to inspect.
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
from archunitpython import project_graph
|
|
405
|
+
|
|
406
|
+
def test_export_dependency_graph_reports():
|
|
407
|
+
graph = project_graph("src/").titled("Application Architecture")
|
|
408
|
+
|
|
409
|
+
graph.collapse_to_folder_depth(2).export_as_mermaid(
|
|
410
|
+
"reports/dependencies.mmd"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
graph.focus_on("**/domain/**", 1).export_as_html(
|
|
414
|
+
"reports/domain-dependencies.html"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
assert graph.snapshot().summary.node_count >= 0
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Supported formats:
|
|
421
|
+
|
|
422
|
+
- DOT (`export_as_dot`, `to_dot`)
|
|
423
|
+
- Mermaid (`export_as_mermaid`, `to_mermaid`)
|
|
424
|
+
- D2 (`export_as_d2`, `to_d2`)
|
|
425
|
+
- CSV (`export_as_csv`, `to_csv`)
|
|
426
|
+
- JSON (`export_as_json`, `to_json`)
|
|
427
|
+
- HTML (`export_as_html`, `to_html`)
|
|
428
|
+
|
|
429
|
+
Graph exploration options:
|
|
430
|
+
|
|
431
|
+
- `focus_on(pattern, depth)` keeps matching files and their neighbors.
|
|
432
|
+
- `reachable_from(pattern)` keeps matching files and their transitive dependencies.
|
|
433
|
+
- `dependents_of(pattern)` keeps files that transitively depend on the matching files.
|
|
434
|
+
- `collapse_to_folder_depth(depth)` aggregates files to folder-level graph nodes.
|
|
435
|
+
- `collapse_by_pattern(pattern, replacement)` maps files to custom graph nodes.
|
|
436
|
+
- `include_external_dependencies()` includes imports to external modules such as `requests` or `sqlalchemy`.
|
|
437
|
+
- `include_self_dependencies()` keeps self edges that are normally hidden in reports.
|
|
438
|
+
|
|
439
|
+
When you create reports through `project_graph("src/")`, internal file paths are displayed relative to that project root so the output stays readable.
|
|
440
|
+
|
|
320
441
|
### Reports
|
|
321
442
|
|
|
322
443
|
Generate HTML reports for your metrics. _Note that this feature is in beta._
|
|
@@ -496,6 +617,40 @@ def test_from_file():
|
|
|
496
617
|
assert_passes(rule)
|
|
497
618
|
```
|
|
498
619
|
|
|
620
|
+
## 📊 Library Comparison
|
|
621
|
+
|
|
622
|
+
Here's how ArchUnitPython compares to other Python architecture-enforcement libraries.
|
|
623
|
+
|
|
624
|
+
ArchUnitPython is optimized for **architecture rules as tests**: rules live next to your normal unit tests, run in pytest/unittest/CI, and fail with test-style violation messages. Broader CLI-first tools such as [Tach](https://github.com/tach-org/tach) and [Import Linter](https://github.com/seddonym/import-linter) are excellent adjacent tools, but they solve the problem through separate configuration and commands rather than a test-native ArchUnit-style API.
|
|
625
|
+
|
|
626
|
+
| Feature | **ArchUnitPython** | **Tach** | **Import Linter** | **PyTestArch** |
|
|
627
|
+
| ------- | ------------------ | -------- | ----------------- | -------------- |
|
|
628
|
+
| **Primary workflow** | ✅ Architecture rules as unit tests | ⚠️ CLI + `tach.toml` | ⚠️ CLI + contracts config | ⚠️ pytest-oriented evaluable architecture |
|
|
629
|
+
| **ArchUnit-style fluent API** | ✅ Yes | ❌ No | ❌ No | ⚠️ Partial |
|
|
630
|
+
| **Testing framework integration** | ✅ pytest, unittest, any runner | ⚠️ CI/pre-commit CLI | ⚠️ CI/pre-commit CLI | ⚠️ pytest-focused |
|
|
631
|
+
| **Zero runtime dependencies** | ✅ Standard library only | ⚠️ No app runtime impact, Rust-backed tool | ❌ Tool dependencies | ❌ Tool dependencies |
|
|
632
|
+
| **Circular dependency detection** | ✅ First-class | ✅ First-class | ⚠️ Contract/graph based | ⚠️ Import-rule based |
|
|
633
|
+
| **File/folder dependency rules** | ✅ Glob + regex | ✅ Module config | ✅ Import contracts | ✅ Module rules |
|
|
634
|
+
| **Named layer rules** | ✅ `project_layers()` | ✅ Supported | ✅ Supported | ✅ Supported |
|
|
635
|
+
| **External dependency rules** | ✅ `depend_on_external_modules()` | ⚠️ Internal module focus | ⚠️ Import contract focus | ⚠️ Internal import focus |
|
|
636
|
+
| **TYPE_CHECKING-aware analysis** | ✅ Configurable | ⚠️ Not the core API | ⚠️ Not the core API | ⚠️ Not the core API |
|
|
637
|
+
| **Dynamic import detection** | ✅ `importlib` + `__import__` string calls | ⚠️ Not the core workflow | ⚠️ Not the core workflow | ⚠️ Import analysis focused |
|
|
638
|
+
| **Inline ignore directives** | ✅ `# archunit: ignore` | ✅ Supported | ⚠️ Config-based ignores | ⚠️ Rule/exclusion based |
|
|
639
|
+
| **Naming convention checks** | ✅ Files and paths | ❌ No | ❌ No | ⚠️ Module-name oriented |
|
|
640
|
+
| **Code metrics** | ✅ Counts, LCOM, distance metrics | ❌ No | ❌ No | ❌ No |
|
|
641
|
+
| **Custom rules and metrics** | ✅ Full support | ❌ No | ⚠️ Custom contracts | ⚠️ Limited custom rule composition |
|
|
642
|
+
| **PlantUML diagram validation** | ✅ Supported | ❌ No | ❌ No | ❌ No |
|
|
643
|
+
| **Empty test protection** | ✅ Fails by default | ⚠️ Config validation | ⚠️ Contract validation | ⚠️ Not the main focus |
|
|
644
|
+
| **Graph/reporting** | ✅ DOT, Mermaid, D2, CSV, JSON, HTML graph reports + metrics HTML | ✅ DOT, JSON, web graph | ✅ Browser UI | ⚠️ Optional graph visualization |
|
|
645
|
+
| **Best fit** | Architecture tests, CI fitness functions, metrics, diagrams | Modular monolith dependency governance | Config-driven import contracts | pytest import-boundary checks |
|
|
646
|
+
|
|
647
|
+
The most important differences:
|
|
648
|
+
|
|
649
|
+
- **Test-native by design**: ArchUnitPython rules are just Python tests, so architecture decisions are reviewed, run, and debugged in the same workflow as the rest of your test suite.
|
|
650
|
+
- **Broader rule surface**: dependency direction, cycles, layer policies, external modules, type-only imports, dynamic imports, naming, metrics, custom rules, and PlantUML validation live in one API.
|
|
651
|
+
- **False-positive protection**: empty checks fail by default, which helps catch typos in file and folder patterns before they silently make your architecture tests meaningless.
|
|
652
|
+
- **Quality beyond imports**: ArchUnitPython can enforce code metrics such as LCOM cohesion, field/method counts, abstractness, instability, and distance from the main sequence.
|
|
653
|
+
|
|
499
654
|
## 📢 Informative Error Messages
|
|
500
655
|
|
|
501
656
|
When tests fail, you get helpful output with file paths and violation details:
|
|
@@ -557,6 +712,7 @@ The features of ArchUnitPython can very well be used as architectural fitness fu
|
|
|
557
712
|
| **Files** | File and folder based rules | Stable |
|
|
558
713
|
| **Metrics** | Code quality metrics | Stable |
|
|
559
714
|
| **Slices** | Architecture slicing | Stable |
|
|
715
|
+
| **Graph** | Dependency graph reports | Experimental |
|
|
560
716
|
| **Testing** | Test framework integration | Stable |
|
|
561
717
|
| **Common** | Shared utilities | Stable |
|
|
562
718
|
| **Reports** | Generate HTML reports | Experimental |
|
|
@@ -626,6 +782,7 @@ Found a bug? Want to discuss features?
|
|
|
626
782
|
If ArchUnitPython helps your project, please consider:
|
|
627
783
|
|
|
628
784
|
- Starring the repository 💚
|
|
785
|
+
- Sponsoring development via [GitHub Sponsors](https://github.com/sponsors/LukasNiessen)
|
|
629
786
|
- Suggesting new features 💭
|
|
630
787
|
- Contributing code or documentation ⌨️
|
|
631
788
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "archunitpython"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.2.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,6 +1,6 @@
|
|
|
1
1
|
"""ArchUnitPython - Architecture testing library for Python projects."""
|
|
2
2
|
|
|
3
|
-
__version__ = "1.
|
|
3
|
+
__version__ = "1.2.0"
|
|
4
4
|
|
|
5
5
|
# Files API
|
|
6
6
|
# Common
|
|
@@ -13,6 +13,8 @@ from archunitpython.common import (
|
|
|
13
13
|
)
|
|
14
14
|
from archunitpython.common.extraction import clear_graph_cache, extract_graph
|
|
15
15
|
from archunitpython.files import files, project_files
|
|
16
|
+
from archunitpython.graph import dependency_graph, project_graph
|
|
17
|
+
from archunitpython.layers import layers, project_layers
|
|
16
18
|
|
|
17
19
|
# Metrics API
|
|
18
20
|
from archunitpython.metrics import metrics
|
|
@@ -27,6 +29,12 @@ __all__ = [
|
|
|
27
29
|
# Files
|
|
28
30
|
"project_files",
|
|
29
31
|
"files",
|
|
32
|
+
# Graph
|
|
33
|
+
"project_graph",
|
|
34
|
+
"dependency_graph",
|
|
35
|
+
# Layers
|
|
36
|
+
"project_layers",
|
|
37
|
+
"layers",
|
|
30
38
|
# Slices
|
|
31
39
|
"project_slices",
|
|
32
40
|
# Metrics
|
{archunitpython-1.1.2 → archunitpython-1.2.0}/src/archunitpython/common/extraction/extract_graph.py
RENAMED
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import ast
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
7
9
|
|
|
8
10
|
from archunitpython.common.extraction.graph import Edge, Graph, ImportKind
|
|
9
11
|
from archunitpython.common.fluentapi.checkable import CheckOptions
|
|
@@ -27,6 +29,34 @@ _DEFAULT_EXCLUDE = [
|
|
|
27
29
|
"*.egg-info",
|
|
28
30
|
]
|
|
29
31
|
|
|
32
|
+
_IGNORE_DIRECTIVE_REGEX = re.compile(
|
|
33
|
+
r"#\s*archunit(?::|-)\s*ignore"
|
|
34
|
+
r"(?:\([^)]*\))?"
|
|
35
|
+
r"(?P<modules>(?:\s+[\w.]+)*)\s*$"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class _LocatedImport:
|
|
41
|
+
module_name: str
|
|
42
|
+
import_kind: ImportKind
|
|
43
|
+
line_number: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class _IgnoreDirective:
|
|
48
|
+
line_number: int
|
|
49
|
+
modules: tuple[str, ...] = ()
|
|
50
|
+
|
|
51
|
+
def matches(self, import_: _LocatedImport) -> bool:
|
|
52
|
+
if not self.modules:
|
|
53
|
+
return True
|
|
54
|
+
return any(
|
|
55
|
+
import_.module_name == module
|
|
56
|
+
or import_.module_name.startswith(f"{module}.")
|
|
57
|
+
for module in self.modules
|
|
58
|
+
)
|
|
59
|
+
|
|
30
60
|
|
|
31
61
|
def clear_graph_cache(options: CheckOptions | None = None) -> None:
|
|
32
62
|
"""Clear the cached dependency graphs."""
|
|
@@ -119,8 +149,10 @@ def _extract_graph_uncached(
|
|
|
119
149
|
)
|
|
120
150
|
|
|
121
151
|
# Extract and resolve imports
|
|
122
|
-
imports =
|
|
123
|
-
for
|
|
152
|
+
imports = _extract_located_imports(file_path)
|
|
153
|
+
for located_import in imports:
|
|
154
|
+
module_name = located_import.module_name
|
|
155
|
+
import_kind = located_import.import_kind
|
|
124
156
|
if (
|
|
125
157
|
ignore_type_checking_imports
|
|
126
158
|
and import_kind == ImportKind.TYPE_IMPORT
|
|
@@ -187,6 +219,14 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
|
|
|
187
219
|
|
|
188
220
|
Returns list of (module_name, import_kind) tuples.
|
|
189
221
|
"""
|
|
222
|
+
return [
|
|
223
|
+
(import_.module_name, import_.import_kind)
|
|
224
|
+
for import_ in _extract_located_imports(file_path)
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
|
|
229
|
+
"""Parse a Python file and extract imports with line numbers."""
|
|
190
230
|
try:
|
|
191
231
|
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
192
232
|
source = f.read()
|
|
@@ -198,7 +238,8 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
|
|
|
198
238
|
except SyntaxError:
|
|
199
239
|
return []
|
|
200
240
|
|
|
201
|
-
imports: list[
|
|
241
|
+
imports: list[_LocatedImport] = []
|
|
242
|
+
ignore_directives = _find_ignore_directives(source)
|
|
202
243
|
type_checking_ranges = _find_type_checking_ranges(tree)
|
|
203
244
|
|
|
204
245
|
for node in ast.walk(tree):
|
|
@@ -206,7 +247,7 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
|
|
|
206
247
|
is_type = _in_type_checking(node, type_checking_ranges)
|
|
207
248
|
kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.IMPORT
|
|
208
249
|
for alias in node.names:
|
|
209
|
-
imports.append((alias.name, kind))
|
|
250
|
+
imports.append(_LocatedImport(alias.name, kind, node.lineno))
|
|
210
251
|
|
|
211
252
|
elif isinstance(node, ast.ImportFrom):
|
|
212
253
|
is_type = _in_type_checking(node, type_checking_ranges)
|
|
@@ -215,13 +256,79 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
|
|
|
215
256
|
kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.RELATIVE_IMPORT
|
|
216
257
|
module = node.module or ""
|
|
217
258
|
dots = "." * node.level
|
|
218
|
-
imports.append((f"{dots}{module}", kind))
|
|
259
|
+
imports.append(_LocatedImport(f"{dots}{module}", kind, node.lineno))
|
|
219
260
|
else:
|
|
220
261
|
kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.FROM_IMPORT
|
|
221
262
|
if node.module:
|
|
222
|
-
imports.append((node.module, kind))
|
|
263
|
+
imports.append(_LocatedImport(node.module, kind, node.lineno))
|
|
264
|
+
|
|
265
|
+
elif isinstance(node, ast.Call):
|
|
266
|
+
is_type = _in_type_checking(node, type_checking_ranges)
|
|
267
|
+
kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.DYNAMIC_IMPORT
|
|
268
|
+
for module_name in _extract_dynamic_import_names(node):
|
|
269
|
+
imports.append(_LocatedImport(module_name, kind, node.lineno))
|
|
270
|
+
|
|
271
|
+
return [
|
|
272
|
+
import_
|
|
273
|
+
for import_ in imports
|
|
274
|
+
if not _is_ignored_import(import_, ignore_directives)
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _find_ignore_directives(source: str) -> dict[int, _IgnoreDirective]:
|
|
279
|
+
"""Find architecture-ignore directives.
|
|
280
|
+
|
|
281
|
+
Supports inline directives on an import line and standalone directives that
|
|
282
|
+
apply to the following line, for example:
|
|
283
|
+
|
|
284
|
+
- from x import y # archunit: ignore
|
|
285
|
+
- # archunit: ignore
|
|
286
|
+
from x import y
|
|
287
|
+
"""
|
|
288
|
+
directives: dict[int, _IgnoreDirective] = {}
|
|
289
|
+
for index, line in enumerate(source.splitlines(), start=1):
|
|
290
|
+
match = _IGNORE_DIRECTIVE_REGEX.search(line)
|
|
291
|
+
if match is None:
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
modules = tuple(match.group("modules").split())
|
|
295
|
+
target_line = index + 1 if line.strip().startswith("#") else index
|
|
296
|
+
directives[target_line] = _IgnoreDirective(target_line, modules)
|
|
297
|
+
return directives
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _is_ignored_import(
|
|
301
|
+
import_: _LocatedImport,
|
|
302
|
+
directives: dict[int, _IgnoreDirective],
|
|
303
|
+
) -> bool:
|
|
304
|
+
directive = directives.get(import_.line_number)
|
|
305
|
+
return directive is not None and directive.matches(import_)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _extract_dynamic_import_names(node: ast.Call) -> list[str]:
|
|
309
|
+
"""Extract literal module names from common dynamic import calls."""
|
|
310
|
+
if not node.args:
|
|
311
|
+
return []
|
|
312
|
+
|
|
313
|
+
first_arg = node.args[0]
|
|
314
|
+
if not isinstance(first_arg, ast.Constant) or not isinstance(first_arg.value, str):
|
|
315
|
+
return []
|
|
223
316
|
|
|
224
|
-
|
|
317
|
+
if isinstance(node.func, ast.Name) and node.func.id in {
|
|
318
|
+
"__import__",
|
|
319
|
+
"import_module",
|
|
320
|
+
}:
|
|
321
|
+
return [first_arg.value]
|
|
322
|
+
|
|
323
|
+
if (
|
|
324
|
+
isinstance(node.func, ast.Attribute)
|
|
325
|
+
and node.func.attr == "import_module"
|
|
326
|
+
and isinstance(node.func.value, ast.Name)
|
|
327
|
+
and node.func.value.id == "importlib"
|
|
328
|
+
):
|
|
329
|
+
return [first_arg.value]
|
|
330
|
+
|
|
331
|
+
return []
|
|
225
332
|
|
|
226
333
|
|
|
227
334
|
def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
|