archunitpython 1.5.0__tar.gz → 1.6.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.5.0 → archunitpython-1.6.0}/BACKLOG.md +5 -5
- {archunitpython-1.5.0 → archunitpython-1.6.0}/CHANGELOG.md +13 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/PKG-INFO +81 -12
- {archunitpython-1.5.0 → archunitpython-1.6.0}/README.md +79 -10
- {archunitpython-1.5.0 → archunitpython-1.6.0}/pyproject.toml +1 -1
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/__init__.py +1 -1
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/extraction/extract_graph.py +152 -20
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/extraction/graph.py +1 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/files/assertion/custom_file_logic.py +3 -3
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/files/assertion/depend_on_files.py +3 -3
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/files/assertion/matching_files.py +3 -3
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/slices/assertion/admissible_edges.py +1 -1
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/common/test_extract_graph.py +376 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.editorconfig +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.gitattributes +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.github/CODEOWNERS +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.github/FUNDING.yml +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.github/ISSUE_TEMPLATE/documentation.md +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.github/ISSUE_TEMPLATE/question.md +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.github/PAGES.md +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.github/dependabot.yml +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.github/pull_request_template.md +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.github/workflows/docs.yaml +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.github/workflows/integrate.yaml +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.github/workflows/stale.yaml +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.gitignore +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/.releaserc.json +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/CONTRIBUTING.md +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/LICENSE +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/assets/logo-rounded.png +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/research/product-direction/architecture-testing-landscape.md +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/scripts/bump_release_version.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/scripts/check_release_metadata.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/assertion/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/assertion/violation.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/error/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/error/errors.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/extraction/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/fluentapi/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/fluentapi/checkable.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/logging/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/logging/types.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/pattern_matching.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/projection/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/projection/cycles/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/projection/cycles/cycle_utils.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/projection/cycles/cycles.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/projection/cycles/johnsons_apsp.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/projection/cycles/model.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/projection/cycles/tarjan_scc.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/projection/edge_projections.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/projection/project_cycles.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/projection/project_edges.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/projection/project_nodes.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/projection/types.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/regex_factory.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/types.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/util/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/util/declaration_detector.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/util/logger.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/config/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/config/loader.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/files/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/files/assertion/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/files/assertion/cycle_free.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/files/assertion/depend_on_external_modules.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/files/fluentapi/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/files/fluentapi/files.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/graph/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/graph/graph_reporter.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/layers/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/layers/assertion/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/layers/assertion/layer_dependencies.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/layers/fluentapi/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/layers/fluentapi/layers.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/metrics/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/metrics/assertion/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/metrics/assertion/metric_thresholds.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/metrics/calculation/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/metrics/calculation/count.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/metrics/calculation/distance.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/metrics/calculation/lcom.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/metrics/common/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/metrics/common/types.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/metrics/extraction/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/metrics/extraction/extract_class_info.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/metrics/fluentapi/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/metrics/fluentapi/export_utils.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/metrics/fluentapi/metrics.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/metrics/projection/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/py.typed +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/slices/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/slices/assertion/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/slices/fluentapi/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/slices/fluentapi/slices.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/slices/projection/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/slices/projection/slicing_projections.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/slices/uml/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/slices/uml/export_diagram.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/slices/uml/generate_rules.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/testing/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/testing/assertion.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/testing/common/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/testing/common/color_utils.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/testing/common/violation_factory.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/testing/pytest_plugin/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/common/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/common/test_core_types.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/common/test_cycles.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/common/test_declaration_detector.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/common/test_logger.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/common/test_pattern_matching.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/common/test_projection.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/config/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/config/test_config_loader.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/files/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/files/test_file_assertions.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/files/test_files_fluentapi.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/fixtures/metrics_project/service.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/fixtures/sample_project/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/fixtures/sample_project/architecture.puml +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/fixtures/sample_project/controllers/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/fixtures/sample_project/controllers/controller.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/fixtures/sample_project/models/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/fixtures/sample_project/models/model.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/fixtures/sample_project/services/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/fixtures/sample_project/services/service.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/fixtures/sample_project/services/service_a.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/fixtures/sample_project/services/service_b.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/fixtures/sample_project/utils/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/fixtures/sample_project/utils/helpers.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/graph/test_graph_reporter.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/integration/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/integration/test_e2e.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/layers/test_layers.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/metrics/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/metrics/test_export.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/metrics/test_metrics.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/metrics/test_metrics_fluentapi.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/slices/__init__.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/slices/test_slices.py +0 -0
- {archunitpython-1.5.0 → archunitpython-1.6.0}/tests/test_setup.py +0 -0
|
@@ -19,8 +19,8 @@ This backlog collects product and maintenance ideas from project research.
|
|
|
19
19
|
## P1 - Python Import Semantics
|
|
20
20
|
|
|
21
21
|
- Add support for namespace packages that do not contain `__init__.py`.
|
|
22
|
-
- Detect dynamic imports such as `importlib.import_module()` and `__import__()`.
|
|
23
|
-
- Detect conditional imports such as `try/except ImportError`.
|
|
22
|
+
- [x] Detect dynamic imports such as `importlib.import_module()` and `__import__()`.
|
|
23
|
+
- [x] Detect conditional imports such as `try/except ImportError`.
|
|
24
24
|
- Add better `TYPE_CHECKING` import handling, including options to ignore, include, or report type-only imports separately.
|
|
25
25
|
- Improve external dependency rules so users can express allowed and forbidden third-party packages at module or slice level.
|
|
26
26
|
- Consider a public-interface rule inspired by Tach, where modules may only import through declared package APIs.
|
|
@@ -31,11 +31,11 @@ This backlog collects product and maintenance ideas from project research.
|
|
|
31
31
|
- Auto-generate architecture documentation based on tests and rule rationales.
|
|
32
32
|
- Make logged paths clickable in IDEs and common terminal integrations.
|
|
33
33
|
- Add PlantUML or Mermaid export for discovered architecture graphs.
|
|
34
|
-
- Improve metric export examples and document how metric thresholds should be selected.
|
|
34
|
+
- [x] Improve metric export examples and document how metric thresholds should be selected.
|
|
35
35
|
|
|
36
36
|
## P2 - Rule Surface
|
|
37
37
|
|
|
38
|
-
- Add first-class layered architecture helpers so common clean/hexagonal/layered rules require less boilerplate.
|
|
38
|
+
- [x] Add first-class layered architecture helpers so common clean/hexagonal/layered rules require less boilerplate.
|
|
39
39
|
- Add slice isolation helpers for bounded contexts and modular monoliths.
|
|
40
40
|
- Add richer custom rule hooks for dependency edges, files, classes, and metrics.
|
|
41
41
|
- Add transitive dependency checks, especially for "domain must not transitively reach infrastructure" style rules.
|
|
@@ -56,7 +56,7 @@ This backlog collects product and maintenance ideas from project research.
|
|
|
56
56
|
|
|
57
57
|
## P3 - Packaging And Docs
|
|
58
58
|
|
|
59
|
-
- Publish to PyPI as part of the release pipeline if this is not already automated.
|
|
59
|
+
- [x] Publish to PyPI as part of the release pipeline if this is not already automated.
|
|
60
60
|
- Add a Sphinx or MkDocs documentation site.
|
|
61
61
|
- Add a complete example repository or examples folder covering pytest, unittest, PlantUML, metrics, and CI.
|
|
62
62
|
- Add contribution guidance for new rule types and metric implementations.
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
# [1.6.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.5.0...v1.6.0) (2026-09-12)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* preserve package targets for relative imports ([09c9d19](https://github.com/LukasNiessen/ArchUnitPython/commit/09c9d199dec095aaa60be227bfd7b5c2b7ffb107))
|
|
7
|
+
* preserve relative conditional import targets ([0ae22bb](https://github.com/LukasNiessen/ArchUnitPython/commit/0ae22bbed008c39f39b80ac6428fa1207115a394))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* classify conditional imports ([5f0f600](https://github.com/LukasNiessen/ArchUnitPython/commit/5f0f600e02d82249e0735738b43cc75317eff790))
|
|
13
|
+
|
|
1
14
|
# [1.5.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.4.0...v1.5.0) (2026-07-18)
|
|
2
15
|
|
|
3
16
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: archunitpython
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.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,7 +41,6 @@ Description-Content-Type: text/markdown
|
|
|
41
41
|
|
|
42
42
|
[](https://opensource.org/licenses/MIT) [](https://github.com/LukasNiessen/ArchUnitPython/actions/workflows/integrate.yaml) [](https://github.com/LukasNiessen/ArchUnitPython)<br>
|
|
43
43
|
[](https://pepy.tech/project/archunitpython) [](https://pepy.tech/project/archunitpython)
|
|
44
|
-
<!-- [](https://pypi.org/project/archunitpython/) -->
|
|
45
44
|
|
|
46
45
|
</div>
|
|
47
46
|
|
|
@@ -51,7 +50,7 @@ The #1 ArchUnit-style architecture testing library for Python, measured by GitHu
|
|
|
51
50
|
|
|
52
51
|
_Inspired by the amazing ArchUnit library but we are not affiliated with ArchUnit._
|
|
53
52
|
|
|
54
|
-
[Setup](#-setup) • [Use Cases](#-use-cases) • [Features](#-features) • [Why ArchUnitPython?](#-library-comparison) • [Sponsor](https://github.com/sponsors/LukasNiessen) • [Contributing](CONTRIBUTING.md)
|
|
53
|
+
[Setup](#-setup) • [Use Cases](#-use-cases) • [Features](#-features) • [Why ArchUnitPython?](#-library-comparison) • [Sponsor](https://github.com/sponsors/LukasNiessen) • [Contributing](CONTRIBUTING.md) • [Documentation](https://lukasniessen.github.io/ArchUnitPython/)
|
|
55
54
|
|
|
56
55
|
## ⚡ 5 min Quickstart
|
|
57
56
|
|
|
@@ -378,6 +377,17 @@ ArchUnitPython detects string-based dynamic imports such as `importlib.import_mo
|
|
|
378
377
|
from my_app.adapters.sql import Repository # archunit: ignore
|
|
379
378
|
```
|
|
380
379
|
|
|
380
|
+
### Conditional Imports
|
|
381
|
+
|
|
382
|
+
Imports inside `try` blocks that handle `ImportError` or
|
|
383
|
+
`ModuleNotFoundError` are marked as conditional dependencies. This helps graph
|
|
384
|
+
reports distinguish optional imports and fallback implementations from regular
|
|
385
|
+
runtime imports. Conditional dependencies remain part of architecture checks;
|
|
386
|
+
relative and dynamic imports also retain their original import kind in graph
|
|
387
|
+
reports. An edge may therefore have multiple kinds: CSV reports use
|
|
388
|
+
pipe-delimited values such as `relative|conditional`, while HTML reports list
|
|
389
|
+
both values separately.
|
|
390
|
+
|
|
381
391
|
### Naming Conventions
|
|
382
392
|
|
|
383
393
|
```python
|
|
@@ -393,6 +403,10 @@ def test_naming_patterns():
|
|
|
393
403
|
|
|
394
404
|
### Code Metrics
|
|
395
405
|
|
|
406
|
+
Metric rules evaluate every matching file or class independently. Use filters such as
|
|
407
|
+
`in_folder()`, `with_name()`, and `for_classes_matching()` when different parts of the
|
|
408
|
+
project need different limits.
|
|
409
|
+
|
|
396
410
|
```python
|
|
397
411
|
def test_no_large_files():
|
|
398
412
|
rule = metrics("src/").count().lines_of_code().should_be_below(1000)
|
|
@@ -403,7 +417,7 @@ def test_high_class_cohesion():
|
|
|
403
417
|
assert_passes(rule)
|
|
404
418
|
|
|
405
419
|
def test_method_count():
|
|
406
|
-
rule = metrics("src/").count().method_count().
|
|
420
|
+
rule = metrics("src/").count().method_count().should_be_below_or_equal(20)
|
|
407
421
|
assert_passes(rule)
|
|
408
422
|
|
|
409
423
|
def test_field_count_for_data_classes():
|
|
@@ -417,6 +431,45 @@ def test_field_count_for_data_classes():
|
|
|
417
431
|
assert_passes(rule)
|
|
418
432
|
```
|
|
419
433
|
|
|
434
|
+
#### Comparison Semantics
|
|
435
|
+
|
|
436
|
+
Metric comparisons are exact. In particular, `should_be_below(20)` means `< 20`, so
|
|
437
|
+
a value of exactly `20` is a violation. Use the inclusive form when the limit itself
|
|
438
|
+
should be accepted.
|
|
439
|
+
|
|
440
|
+
| Method | Passing values |
|
|
441
|
+
| --- | --- |
|
|
442
|
+
| `should_be_below(n)` | `< n` |
|
|
443
|
+
| `should_be_below_or_equal(n)` | `<= n` |
|
|
444
|
+
| `should_be_above(n)` | `> n` |
|
|
445
|
+
| `should_be_above_or_equal(n)` | `>= n` |
|
|
446
|
+
| `should_be(n)` | exactly `n` |
|
|
447
|
+
|
|
448
|
+
Not every metric builder exposes every comparison. The available methods are shown
|
|
449
|
+
by the fluent API after selecting a metric.
|
|
450
|
+
|
|
451
|
+
#### Choosing Thresholds
|
|
452
|
+
|
|
453
|
+
There is no universal correct limit for every codebase. Treat thresholds as
|
|
454
|
+
architecture decisions that should reflect the role and maturity of the code:
|
|
455
|
+
|
|
456
|
+
1. Measure the current project before enabling a new rule.
|
|
457
|
+
2. Start at the current maximum, or slightly above it, to prevent further regression.
|
|
458
|
+
3. Use narrower filters when generated code, data classes, or adapters need different limits.
|
|
459
|
+
4. Lower the threshold gradually as existing violations are removed.
|
|
460
|
+
5. Record the reason with `.because(...)` so future maintainers understand the limit.
|
|
461
|
+
|
|
462
|
+
| Metric | Interpretation | Useful starting point |
|
|
463
|
+
| --- | --- | --- |
|
|
464
|
+
| Lines of code | File size and review burden | Current maximum for hand-written files |
|
|
465
|
+
| Method or field count | Class responsibility and size | Current maximum, split by class role |
|
|
466
|
+
| LCOM | Lack of class cohesion; lower is generally better | Baseline one LCOM variant and keep it consistent |
|
|
467
|
+
| Instability | Dependence on outgoing versus incoming dependencies | Compare files with similar architectural roles |
|
|
468
|
+
| Distance from main sequence | Balance between abstractness and instability; closer to zero is better | Observe the current distribution before tightening |
|
|
469
|
+
|
|
470
|
+
Thresholds are guardrails, not quality scores. A metric violation is a prompt to inspect
|
|
471
|
+
the design; it does not automatically mean the code is incorrect.
|
|
472
|
+
|
|
420
473
|
### Distance Metrics
|
|
421
474
|
|
|
422
475
|
```python
|
|
@@ -512,13 +565,13 @@ from archunitpython import project_graph
|
|
|
512
565
|
def test_export_dependency_graph_reports():
|
|
513
566
|
graph = project_graph("src/requests").titled("Application Architecture")
|
|
514
567
|
|
|
515
|
-
graph.collapse_to_folder_depth(2).export_as_mermaid("reports/dependencies.
|
|
568
|
+
graph.collapse_to_folder_depth(2).export_as_mermaid("reports/dependencies.mmd")
|
|
516
569
|
|
|
517
570
|
if __name__ == "__main__":
|
|
518
571
|
test_export_dependency_graph_reports()
|
|
519
572
|
```
|
|
520
|
-
**Exported
|
|
521
|
-
```
|
|
573
|
+
**Exported Mermaid diagram**
|
|
574
|
+
```mermaid
|
|
522
575
|
flowchart LR
|
|
523
576
|
n0["__init__.py"]
|
|
524
577
|
n1["__version__.py"]
|
|
@@ -636,20 +689,36 @@ When you create reports through `project_graph("src/")`, internal file paths are
|
|
|
636
689
|
|
|
637
690
|
### Reports
|
|
638
691
|
|
|
639
|
-
Generate HTML
|
|
692
|
+
Generate an HTML report from metric values collected by your tests or build tooling.
|
|
693
|
+
`MetricsExporter` formats the supplied dictionary; it does not execute metric rules or
|
|
694
|
+
calculate the values itself. _This feature is in beta._
|
|
640
695
|
|
|
641
696
|
```python
|
|
642
697
|
from archunitpython.metrics.fluentapi.export_utils import MetricsExporter, ExportOptions
|
|
643
698
|
|
|
644
|
-
|
|
645
|
-
|
|
699
|
+
metric_summary = {
|
|
700
|
+
"Maximum method count": "18 (limit: <= 20)",
|
|
701
|
+
"Maximum field count": "9 (limit: <= 10)",
|
|
702
|
+
"Maximum lines of code": "420 (limit: < 500)",
|
|
703
|
+
"Highest LCOM96b": "0.24 (limit: < 0.30)",
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
html = MetricsExporter.export_as_html(
|
|
707
|
+
metric_summary,
|
|
646
708
|
ExportOptions(
|
|
647
709
|
output_path="reports/metrics.html",
|
|
648
710
|
title="Architecture Metrics Dashboard",
|
|
711
|
+
include_timestamp=False,
|
|
649
712
|
),
|
|
650
713
|
)
|
|
714
|
+
|
|
715
|
+
assert "Maximum method count" in html
|
|
651
716
|
```
|
|
652
717
|
|
|
718
|
+
Keep labels and units stable if these reports are stored as CI artifacts and compared
|
|
719
|
+
between builds. Continue using executable metric rules with `assert_passes()` as the
|
|
720
|
+
enforcement mechanism.
|
|
721
|
+
|
|
653
722
|
## 🔎 Pattern Matching System
|
|
654
723
|
|
|
655
724
|
We offer three targeting options for pattern matching across all modules:
|
|
@@ -984,7 +1053,7 @@ If ArchUnitPython helps your project, please consider:
|
|
|
984
1053
|
|
|
985
1054
|
### Star History
|
|
986
1055
|
|
|
987
|
-
[](https://star-history.dera.page/#LukasNiessen/ArchUnitPython&Date)
|
|
988
1057
|
|
|
989
1058
|
## 📄 License
|
|
990
1059
|
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
|
|
9
9
|
[](https://opensource.org/licenses/MIT) [](https://github.com/LukasNiessen/ArchUnitPython/actions/workflows/integrate.yaml) [](https://github.com/LukasNiessen/ArchUnitPython)<br>
|
|
10
10
|
[](https://pepy.tech/project/archunitpython) [](https://pepy.tech/project/archunitpython)
|
|
11
|
-
<!-- [](https://pypi.org/project/archunitpython/) -->
|
|
12
11
|
|
|
13
12
|
</div>
|
|
14
13
|
|
|
@@ -18,7 +17,7 @@ The #1 ArchUnit-style architecture testing library for Python, measured by GitHu
|
|
|
18
17
|
|
|
19
18
|
_Inspired by the amazing ArchUnit library but we are not affiliated with ArchUnit._
|
|
20
19
|
|
|
21
|
-
[Setup](#-setup) • [Use Cases](#-use-cases) • [Features](#-features) • [Why ArchUnitPython?](#-library-comparison) • [Sponsor](https://github.com/sponsors/LukasNiessen) • [Contributing](CONTRIBUTING.md)
|
|
20
|
+
[Setup](#-setup) • [Use Cases](#-use-cases) • [Features](#-features) • [Why ArchUnitPython?](#-library-comparison) • [Sponsor](https://github.com/sponsors/LukasNiessen) • [Contributing](CONTRIBUTING.md) • [Documentation](https://lukasniessen.github.io/ArchUnitPython/)
|
|
22
21
|
|
|
23
22
|
## ⚡ 5 min Quickstart
|
|
24
23
|
|
|
@@ -345,6 +344,17 @@ ArchUnitPython detects string-based dynamic imports such as `importlib.import_mo
|
|
|
345
344
|
from my_app.adapters.sql import Repository # archunit: ignore
|
|
346
345
|
```
|
|
347
346
|
|
|
347
|
+
### Conditional Imports
|
|
348
|
+
|
|
349
|
+
Imports inside `try` blocks that handle `ImportError` or
|
|
350
|
+
`ModuleNotFoundError` are marked as conditional dependencies. This helps graph
|
|
351
|
+
reports distinguish optional imports and fallback implementations from regular
|
|
352
|
+
runtime imports. Conditional dependencies remain part of architecture checks;
|
|
353
|
+
relative and dynamic imports also retain their original import kind in graph
|
|
354
|
+
reports. An edge may therefore have multiple kinds: CSV reports use
|
|
355
|
+
pipe-delimited values such as `relative|conditional`, while HTML reports list
|
|
356
|
+
both values separately.
|
|
357
|
+
|
|
348
358
|
### Naming Conventions
|
|
349
359
|
|
|
350
360
|
```python
|
|
@@ -360,6 +370,10 @@ def test_naming_patterns():
|
|
|
360
370
|
|
|
361
371
|
### Code Metrics
|
|
362
372
|
|
|
373
|
+
Metric rules evaluate every matching file or class independently. Use filters such as
|
|
374
|
+
`in_folder()`, `with_name()`, and `for_classes_matching()` when different parts of the
|
|
375
|
+
project need different limits.
|
|
376
|
+
|
|
363
377
|
```python
|
|
364
378
|
def test_no_large_files():
|
|
365
379
|
rule = metrics("src/").count().lines_of_code().should_be_below(1000)
|
|
@@ -370,7 +384,7 @@ def test_high_class_cohesion():
|
|
|
370
384
|
assert_passes(rule)
|
|
371
385
|
|
|
372
386
|
def test_method_count():
|
|
373
|
-
rule = metrics("src/").count().method_count().
|
|
387
|
+
rule = metrics("src/").count().method_count().should_be_below_or_equal(20)
|
|
374
388
|
assert_passes(rule)
|
|
375
389
|
|
|
376
390
|
def test_field_count_for_data_classes():
|
|
@@ -384,6 +398,45 @@ def test_field_count_for_data_classes():
|
|
|
384
398
|
assert_passes(rule)
|
|
385
399
|
```
|
|
386
400
|
|
|
401
|
+
#### Comparison Semantics
|
|
402
|
+
|
|
403
|
+
Metric comparisons are exact. In particular, `should_be_below(20)` means `< 20`, so
|
|
404
|
+
a value of exactly `20` is a violation. Use the inclusive form when the limit itself
|
|
405
|
+
should be accepted.
|
|
406
|
+
|
|
407
|
+
| Method | Passing values |
|
|
408
|
+
| --- | --- |
|
|
409
|
+
| `should_be_below(n)` | `< n` |
|
|
410
|
+
| `should_be_below_or_equal(n)` | `<= n` |
|
|
411
|
+
| `should_be_above(n)` | `> n` |
|
|
412
|
+
| `should_be_above_or_equal(n)` | `>= n` |
|
|
413
|
+
| `should_be(n)` | exactly `n` |
|
|
414
|
+
|
|
415
|
+
Not every metric builder exposes every comparison. The available methods are shown
|
|
416
|
+
by the fluent API after selecting a metric.
|
|
417
|
+
|
|
418
|
+
#### Choosing Thresholds
|
|
419
|
+
|
|
420
|
+
There is no universal correct limit for every codebase. Treat thresholds as
|
|
421
|
+
architecture decisions that should reflect the role and maturity of the code:
|
|
422
|
+
|
|
423
|
+
1. Measure the current project before enabling a new rule.
|
|
424
|
+
2. Start at the current maximum, or slightly above it, to prevent further regression.
|
|
425
|
+
3. Use narrower filters when generated code, data classes, or adapters need different limits.
|
|
426
|
+
4. Lower the threshold gradually as existing violations are removed.
|
|
427
|
+
5. Record the reason with `.because(...)` so future maintainers understand the limit.
|
|
428
|
+
|
|
429
|
+
| Metric | Interpretation | Useful starting point |
|
|
430
|
+
| --- | --- | --- |
|
|
431
|
+
| Lines of code | File size and review burden | Current maximum for hand-written files |
|
|
432
|
+
| Method or field count | Class responsibility and size | Current maximum, split by class role |
|
|
433
|
+
| LCOM | Lack of class cohesion; lower is generally better | Baseline one LCOM variant and keep it consistent |
|
|
434
|
+
| Instability | Dependence on outgoing versus incoming dependencies | Compare files with similar architectural roles |
|
|
435
|
+
| Distance from main sequence | Balance between abstractness and instability; closer to zero is better | Observe the current distribution before tightening |
|
|
436
|
+
|
|
437
|
+
Thresholds are guardrails, not quality scores. A metric violation is a prompt to inspect
|
|
438
|
+
the design; it does not automatically mean the code is incorrect.
|
|
439
|
+
|
|
387
440
|
### Distance Metrics
|
|
388
441
|
|
|
389
442
|
```python
|
|
@@ -479,13 +532,13 @@ from archunitpython import project_graph
|
|
|
479
532
|
def test_export_dependency_graph_reports():
|
|
480
533
|
graph = project_graph("src/requests").titled("Application Architecture")
|
|
481
534
|
|
|
482
|
-
graph.collapse_to_folder_depth(2).export_as_mermaid("reports/dependencies.
|
|
535
|
+
graph.collapse_to_folder_depth(2).export_as_mermaid("reports/dependencies.mmd")
|
|
483
536
|
|
|
484
537
|
if __name__ == "__main__":
|
|
485
538
|
test_export_dependency_graph_reports()
|
|
486
539
|
```
|
|
487
|
-
**Exported
|
|
488
|
-
```
|
|
540
|
+
**Exported Mermaid diagram**
|
|
541
|
+
```mermaid
|
|
489
542
|
flowchart LR
|
|
490
543
|
n0["__init__.py"]
|
|
491
544
|
n1["__version__.py"]
|
|
@@ -603,20 +656,36 @@ When you create reports through `project_graph("src/")`, internal file paths are
|
|
|
603
656
|
|
|
604
657
|
### Reports
|
|
605
658
|
|
|
606
|
-
Generate HTML
|
|
659
|
+
Generate an HTML report from metric values collected by your tests or build tooling.
|
|
660
|
+
`MetricsExporter` formats the supplied dictionary; it does not execute metric rules or
|
|
661
|
+
calculate the values itself. _This feature is in beta._
|
|
607
662
|
|
|
608
663
|
```python
|
|
609
664
|
from archunitpython.metrics.fluentapi.export_utils import MetricsExporter, ExportOptions
|
|
610
665
|
|
|
611
|
-
|
|
612
|
-
|
|
666
|
+
metric_summary = {
|
|
667
|
+
"Maximum method count": "18 (limit: <= 20)",
|
|
668
|
+
"Maximum field count": "9 (limit: <= 10)",
|
|
669
|
+
"Maximum lines of code": "420 (limit: < 500)",
|
|
670
|
+
"Highest LCOM96b": "0.24 (limit: < 0.30)",
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
html = MetricsExporter.export_as_html(
|
|
674
|
+
metric_summary,
|
|
613
675
|
ExportOptions(
|
|
614
676
|
output_path="reports/metrics.html",
|
|
615
677
|
title="Architecture Metrics Dashboard",
|
|
678
|
+
include_timestamp=False,
|
|
616
679
|
),
|
|
617
680
|
)
|
|
681
|
+
|
|
682
|
+
assert "Maximum method count" in html
|
|
618
683
|
```
|
|
619
684
|
|
|
685
|
+
Keep labels and units stable if these reports are stored as CI artifacts and compared
|
|
686
|
+
between builds. Continue using executable metric rules with `assert_passes()` as the
|
|
687
|
+
enforcement mechanism.
|
|
688
|
+
|
|
620
689
|
## 🔎 Pattern Matching System
|
|
621
690
|
|
|
622
691
|
We offer three targeting options for pattern matching across all modules:
|
|
@@ -951,7 +1020,7 @@ If ArchUnitPython helps your project, please consider:
|
|
|
951
1020
|
|
|
952
1021
|
### Star History
|
|
953
1022
|
|
|
954
|
-
[](https://star-history.dera.page/#LukasNiessen/ArchUnitPython&Date)
|
|
955
1024
|
|
|
956
1025
|
## 📄 License
|
|
957
1026
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "archunitpython"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.6.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"
|
{archunitpython-1.5.0 → archunitpython-1.6.0}/src/archunitpython/common/extraction/extract_graph.py
RENAMED
|
@@ -43,6 +43,8 @@ class _LocatedImport:
|
|
|
43
43
|
module_name: str
|
|
44
44
|
import_kind: ImportKind
|
|
45
45
|
line_number: int
|
|
46
|
+
resolution_kind: ImportKind | None = None
|
|
47
|
+
fallback_module_name: str | None = None
|
|
46
48
|
|
|
47
49
|
|
|
48
50
|
@dataclass(frozen=True)
|
|
@@ -182,9 +184,19 @@ def _extract_graph_uncached(
|
|
|
182
184
|
and import_kind == ImportKind.TYPE_IMPORT
|
|
183
185
|
):
|
|
184
186
|
continue
|
|
187
|
+
resolution_kind = located_import.resolution_kind or import_kind
|
|
185
188
|
resolved, is_external = _resolve_import(
|
|
186
|
-
module_name, file_path, project_path,
|
|
189
|
+
module_name, file_path, project_path, resolution_kind
|
|
187
190
|
)
|
|
191
|
+
if is_external and located_import.fallback_module_name is not None:
|
|
192
|
+
fallback, fallback_is_external = _resolve_import(
|
|
193
|
+
located_import.fallback_module_name,
|
|
194
|
+
file_path,
|
|
195
|
+
project_path,
|
|
196
|
+
resolution_kind,
|
|
197
|
+
)
|
|
198
|
+
if fallback and not fallback_is_external:
|
|
199
|
+
resolved, is_external = fallback, False
|
|
188
200
|
if resolved and resolved != _normalize(file_path):
|
|
189
201
|
# Check if the resolved path is in our project
|
|
190
202
|
if not is_external and resolved not in normalized_py_file_set:
|
|
@@ -195,7 +207,7 @@ def _extract_graph_uncached(
|
|
|
195
207
|
source=_normalize(file_path),
|
|
196
208
|
target=resolved,
|
|
197
209
|
external=is_external,
|
|
198
|
-
import_kinds=(
|
|
210
|
+
import_kinds=_edge_import_kinds(located_import),
|
|
199
211
|
)
|
|
200
212
|
)
|
|
201
213
|
|
|
@@ -299,32 +311,60 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
|
|
|
299
311
|
imports: list[_LocatedImport] = []
|
|
300
312
|
ignore_directives = _find_ignore_directives(source)
|
|
301
313
|
type_checking_ranges = _find_type_checking_ranges(tree)
|
|
314
|
+
conditional_import_ranges = _find_conditional_import_ranges(tree)
|
|
302
315
|
|
|
303
316
|
for node in ast.walk(tree):
|
|
304
317
|
if isinstance(node, ast.Import):
|
|
305
|
-
|
|
306
|
-
kind =
|
|
318
|
+
syntax_kind = ImportKind.IMPORT
|
|
319
|
+
kind = _classify_import(
|
|
320
|
+
node,
|
|
321
|
+
syntax_kind,
|
|
322
|
+
type_checking_ranges,
|
|
323
|
+
conditional_import_ranges,
|
|
324
|
+
)
|
|
307
325
|
for alias in node.names:
|
|
308
|
-
imports.append(
|
|
326
|
+
imports.append(
|
|
327
|
+
_LocatedImport(alias.name, kind, node.lineno, syntax_kind)
|
|
328
|
+
)
|
|
309
329
|
|
|
310
330
|
elif isinstance(node, ast.ImportFrom):
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
331
|
+
syntax_kind = (
|
|
332
|
+
ImportKind.RELATIVE_IMPORT
|
|
333
|
+
if node.level and node.level > 0
|
|
334
|
+
else ImportKind.FROM_IMPORT
|
|
335
|
+
)
|
|
336
|
+
kind = _classify_import(
|
|
337
|
+
node,
|
|
338
|
+
syntax_kind,
|
|
339
|
+
type_checking_ranges,
|
|
340
|
+
conditional_import_ranges,
|
|
341
|
+
)
|
|
342
|
+
fallback_module_name = (
|
|
343
|
+
"." * node.level if node.level and node.module is None else None
|
|
344
|
+
)
|
|
345
|
+
for module_name in _import_from_module_names(node):
|
|
346
|
+
imports.append(
|
|
347
|
+
_LocatedImport(
|
|
348
|
+
module_name,
|
|
349
|
+
kind,
|
|
350
|
+
node.lineno,
|
|
351
|
+
syntax_kind,
|
|
352
|
+
fallback_module_name,
|
|
353
|
+
)
|
|
354
|
+
)
|
|
322
355
|
|
|
323
356
|
elif isinstance(node, ast.Call):
|
|
324
|
-
|
|
325
|
-
kind =
|
|
357
|
+
syntax_kind = ImportKind.DYNAMIC_IMPORT
|
|
358
|
+
kind = _classify_import(
|
|
359
|
+
node,
|
|
360
|
+
syntax_kind,
|
|
361
|
+
type_checking_ranges,
|
|
362
|
+
conditional_import_ranges,
|
|
363
|
+
)
|
|
326
364
|
for module_name in _extract_dynamic_import_names(node):
|
|
327
|
-
imports.append(
|
|
365
|
+
imports.append(
|
|
366
|
+
_LocatedImport(module_name, kind, node.lineno, syntax_kind)
|
|
367
|
+
)
|
|
328
368
|
|
|
329
369
|
return [
|
|
330
370
|
import_
|
|
@@ -389,6 +429,43 @@ def _extract_dynamic_import_names(node: ast.Call) -> list[str]:
|
|
|
389
429
|
return []
|
|
390
430
|
|
|
391
431
|
|
|
432
|
+
def _import_from_module_names(node: ast.ImportFrom) -> tuple[str, ...]:
|
|
433
|
+
"""Return resolvable module names for a from-import statement."""
|
|
434
|
+
dots = "." * (node.level or 0)
|
|
435
|
+
if node.module:
|
|
436
|
+
return (f"{dots}{node.module}",)
|
|
437
|
+
|
|
438
|
+
aliases = tuple(alias.name for alias in node.names if alias.name != "*")
|
|
439
|
+
if dots and aliases:
|
|
440
|
+
return tuple(f"{dots}{alias}" for alias in aliases)
|
|
441
|
+
return (dots,) if dots else ()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _edge_import_kinds(import_: _LocatedImport) -> tuple[ImportKind, ...]:
|
|
445
|
+
"""Return graph labels without losing syntax for conditional imports."""
|
|
446
|
+
resolution_kind = import_.resolution_kind or import_.import_kind
|
|
447
|
+
if (
|
|
448
|
+
import_.import_kind == ImportKind.CONDITIONAL_IMPORT
|
|
449
|
+
and resolution_kind != import_.import_kind
|
|
450
|
+
):
|
|
451
|
+
return (resolution_kind, import_.import_kind)
|
|
452
|
+
return (import_.import_kind,)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _classify_import(
|
|
456
|
+
node: ast.AST,
|
|
457
|
+
default_kind: ImportKind,
|
|
458
|
+
type_checking_ranges: list[tuple[int, int]],
|
|
459
|
+
conditional_import_ranges: list[tuple[int, int]],
|
|
460
|
+
) -> ImportKind:
|
|
461
|
+
"""Classify an import node by special context before syntax kind."""
|
|
462
|
+
if _in_type_checking(node, type_checking_ranges):
|
|
463
|
+
return ImportKind.TYPE_IMPORT
|
|
464
|
+
if _in_conditional_import(node, conditional_import_ranges):
|
|
465
|
+
return ImportKind.CONDITIONAL_IMPORT
|
|
466
|
+
return default_kind
|
|
467
|
+
|
|
468
|
+
|
|
392
469
|
def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
|
|
393
470
|
"""Find line ranges of TYPE_CHECKING blocks."""
|
|
394
471
|
ranges: list[tuple[int, int]] = []
|
|
@@ -414,6 +491,46 @@ def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
|
|
|
414
491
|
return sorted(ranges, key=lambda ele: ele[0])
|
|
415
492
|
|
|
416
493
|
|
|
494
|
+
def _find_conditional_import_ranges(tree: ast.Module) -> list[tuple[int, int]]:
|
|
495
|
+
"""Find try/except ImportError ranges that contain optional imports."""
|
|
496
|
+
ranges: list[tuple[int, int]] = []
|
|
497
|
+
|
|
498
|
+
for node in ast.walk(tree):
|
|
499
|
+
if not isinstance(node, ast.Try):
|
|
500
|
+
continue
|
|
501
|
+
if not any(_handles_import_error(handler.type) for handler in node.handlers):
|
|
502
|
+
continue
|
|
503
|
+
|
|
504
|
+
ranges.extend(_statement_ranges(node.body))
|
|
505
|
+
for handler in node.handlers:
|
|
506
|
+
if _handles_import_error(handler.type):
|
|
507
|
+
ranges.extend(_statement_ranges(handler.body))
|
|
508
|
+
|
|
509
|
+
return sorted(ranges, key=lambda ele: ele[0])
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _handles_import_error(node: ast.expr | None) -> bool:
|
|
513
|
+
"""Return True if an except handler catches import-related errors."""
|
|
514
|
+
if node is None:
|
|
515
|
+
return False
|
|
516
|
+
if isinstance(node, ast.Name):
|
|
517
|
+
return node.id in {"ImportError", "ModuleNotFoundError"}
|
|
518
|
+
if isinstance(node, ast.Attribute):
|
|
519
|
+
return node.attr in {"ImportError", "ModuleNotFoundError"}
|
|
520
|
+
if isinstance(node, ast.Tuple):
|
|
521
|
+
return any(_handles_import_error(elt) for elt in node.elts)
|
|
522
|
+
return False
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _statement_ranges(statements: list[ast.stmt]) -> list[tuple[int, int]]:
|
|
526
|
+
"""Return line ranges covered by statement blocks."""
|
|
527
|
+
if not statements:
|
|
528
|
+
return []
|
|
529
|
+
start = statements[0].lineno
|
|
530
|
+
end = max(getattr(statement, "end_lineno", statement.lineno) for statement in statements)
|
|
531
|
+
return [(start, end)]
|
|
532
|
+
|
|
533
|
+
|
|
417
534
|
def _in_type_checking(node: ast.AST, ranges: list[tuple[int, int]]) -> bool:
|
|
418
535
|
"""Check if a node is inside a TYPE_CHECKING block."""
|
|
419
536
|
if not hasattr(node, "lineno"):
|
|
@@ -422,6 +539,14 @@ def _in_type_checking(node: ast.AST, ranges: list[tuple[int, int]]) -> bool:
|
|
|
422
539
|
return any(start <= lineno <= end for start, end in ranges)
|
|
423
540
|
|
|
424
541
|
|
|
542
|
+
def _in_conditional_import(node: ast.AST, ranges: list[tuple[int, int]]) -> bool:
|
|
543
|
+
"""Check if a node is inside a try/except ImportError block."""
|
|
544
|
+
if not hasattr(node, "lineno"):
|
|
545
|
+
return False
|
|
546
|
+
lineno = node.lineno
|
|
547
|
+
return any(start <= lineno <= end for start, end in ranges)
|
|
548
|
+
|
|
549
|
+
|
|
425
550
|
def _resolve_import(
|
|
426
551
|
import_name: str,
|
|
427
552
|
source_file: str,
|
|
@@ -433,7 +558,14 @@ def _resolve_import(
|
|
|
433
558
|
Returns (resolved_path, is_external).
|
|
434
559
|
The path is normalized with forward slashes.
|
|
435
560
|
"""
|
|
436
|
-
if
|
|
561
|
+
if (
|
|
562
|
+
kind
|
|
563
|
+
in (
|
|
564
|
+
ImportKind.RELATIVE_IMPORT,
|
|
565
|
+
ImportKind.TYPE_IMPORT,
|
|
566
|
+
)
|
|
567
|
+
and import_name.startswith(".")
|
|
568
|
+
):
|
|
437
569
|
# Relative import
|
|
438
570
|
return _resolve_relative_import(import_name, source_file, project_root)
|
|
439
571
|
|
|
@@ -14,6 +14,7 @@ class ImportKind(Enum):
|
|
|
14
14
|
RELATIVE_IMPORT = "relative" # from . import bar / from ..foo import bar
|
|
15
15
|
DYNAMIC_IMPORT = "dynamic" # __import__('foo') / importlib.import_module()
|
|
16
16
|
TYPE_IMPORT = "type" # inside TYPE_CHECKING block
|
|
17
|
+
CONDITIONAL_IMPORT = "conditional" # inside try/except ImportError
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
@dataclass(frozen=True)
|