archunitpython 1.6.1__tar.gz → 1.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. {archunitpython-1.6.1 → archunitpython-1.7.0}/BACKLOG.md +1 -1
  2. {archunitpython-1.6.1 → archunitpython-1.7.0}/CHANGELOG.md +12 -0
  3. {archunitpython-1.6.1 → archunitpython-1.7.0}/PKG-INFO +7 -1
  4. {archunitpython-1.6.1 → archunitpython-1.7.0}/README.md +6 -0
  5. {archunitpython-1.6.1 → archunitpython-1.7.0}/pyproject.toml +1 -1
  6. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/__init__.py +1 -1
  7. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/extraction/extract_graph.py +80 -31
  8. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/common/test_extract_graph.py +156 -0
  9. {archunitpython-1.6.1 → archunitpython-1.7.0}/.editorconfig +0 -0
  10. {archunitpython-1.6.1 → archunitpython-1.7.0}/.gitattributes +0 -0
  11. {archunitpython-1.6.1 → archunitpython-1.7.0}/.github/CODEOWNERS +0 -0
  12. {archunitpython-1.6.1 → archunitpython-1.7.0}/.github/FUNDING.yml +0 -0
  13. {archunitpython-1.6.1 → archunitpython-1.7.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  14. {archunitpython-1.6.1 → archunitpython-1.7.0}/.github/ISSUE_TEMPLATE/documentation.md +0 -0
  15. {archunitpython-1.6.1 → archunitpython-1.7.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  16. {archunitpython-1.6.1 → archunitpython-1.7.0}/.github/ISSUE_TEMPLATE/question.md +0 -0
  17. {archunitpython-1.6.1 → archunitpython-1.7.0}/.github/PAGES.md +0 -0
  18. {archunitpython-1.6.1 → archunitpython-1.7.0}/.github/dependabot.yml +0 -0
  19. {archunitpython-1.6.1 → archunitpython-1.7.0}/.github/pull_request_template.md +0 -0
  20. {archunitpython-1.6.1 → archunitpython-1.7.0}/.github/workflows/docs.yaml +0 -0
  21. {archunitpython-1.6.1 → archunitpython-1.7.0}/.github/workflows/integrate.yaml +0 -0
  22. {archunitpython-1.6.1 → archunitpython-1.7.0}/.github/workflows/stale.yaml +0 -0
  23. {archunitpython-1.6.1 → archunitpython-1.7.0}/.gitignore +0 -0
  24. {archunitpython-1.6.1 → archunitpython-1.7.0}/.releaserc.json +0 -0
  25. {archunitpython-1.6.1 → archunitpython-1.7.0}/CONTRIBUTING.md +0 -0
  26. {archunitpython-1.6.1 → archunitpython-1.7.0}/LICENSE +0 -0
  27. {archunitpython-1.6.1 → archunitpython-1.7.0}/assets/logo-rounded.png +0 -0
  28. {archunitpython-1.6.1 → archunitpython-1.7.0}/research/product-direction/architecture-testing-landscape.md +0 -0
  29. {archunitpython-1.6.1 → archunitpython-1.7.0}/scripts/bump_release_version.py +0 -0
  30. {archunitpython-1.6.1 → archunitpython-1.7.0}/scripts/check_release_metadata.py +0 -0
  31. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/__init__.py +0 -0
  32. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/assertion/__init__.py +0 -0
  33. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/assertion/violation.py +0 -0
  34. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/error/__init__.py +0 -0
  35. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/error/errors.py +0 -0
  36. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/extraction/__init__.py +0 -0
  37. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/extraction/graph.py +0 -0
  38. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/fluentapi/__init__.py +0 -0
  39. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/fluentapi/checkable.py +0 -0
  40. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/logging/__init__.py +0 -0
  41. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/logging/types.py +0 -0
  42. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/pattern_matching.py +0 -0
  43. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/projection/__init__.py +0 -0
  44. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/projection/cycles/__init__.py +0 -0
  45. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/projection/cycles/cycle_utils.py +0 -0
  46. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/projection/cycles/cycles.py +0 -0
  47. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/projection/cycles/johnsons_apsp.py +0 -0
  48. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/projection/cycles/model.py +0 -0
  49. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/projection/cycles/tarjan_scc.py +0 -0
  50. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/projection/edge_projections.py +0 -0
  51. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/projection/project_cycles.py +0 -0
  52. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/projection/project_edges.py +0 -0
  53. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/projection/project_nodes.py +0 -0
  54. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/projection/types.py +0 -0
  55. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/regex_factory.py +0 -0
  56. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/types.py +0 -0
  57. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/util/__init__.py +0 -0
  58. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/util/declaration_detector.py +0 -0
  59. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/common/util/logger.py +0 -0
  60. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/config/__init__.py +0 -0
  61. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/config/loader.py +0 -0
  62. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/files/__init__.py +0 -0
  63. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/files/assertion/__init__.py +0 -0
  64. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/files/assertion/custom_file_logic.py +0 -0
  65. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/files/assertion/cycle_free.py +0 -0
  66. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/files/assertion/depend_on_external_modules.py +0 -0
  67. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/files/assertion/depend_on_files.py +0 -0
  68. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/files/assertion/matching_files.py +0 -0
  69. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/files/fluentapi/__init__.py +0 -0
  70. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/files/fluentapi/files.py +0 -0
  71. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/graph/__init__.py +0 -0
  72. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/graph/graph_reporter.py +0 -0
  73. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/layers/__init__.py +0 -0
  74. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/layers/assertion/__init__.py +0 -0
  75. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/layers/assertion/layer_dependencies.py +0 -0
  76. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/layers/fluentapi/__init__.py +0 -0
  77. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/layers/fluentapi/layers.py +0 -0
  78. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/metrics/__init__.py +0 -0
  79. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/metrics/assertion/__init__.py +0 -0
  80. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/metrics/assertion/metric_thresholds.py +0 -0
  81. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/metrics/calculation/__init__.py +0 -0
  82. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/metrics/calculation/count.py +0 -0
  83. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/metrics/calculation/distance.py +0 -0
  84. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/metrics/calculation/lcom.py +0 -0
  85. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/metrics/common/__init__.py +0 -0
  86. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/metrics/common/types.py +0 -0
  87. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/metrics/extraction/__init__.py +0 -0
  88. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/metrics/extraction/extract_class_info.py +0 -0
  89. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/metrics/fluentapi/__init__.py +0 -0
  90. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/metrics/fluentapi/export_utils.py +0 -0
  91. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/metrics/fluentapi/metrics.py +0 -0
  92. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/metrics/projection/__init__.py +0 -0
  93. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/py.typed +0 -0
  94. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/slices/__init__.py +0 -0
  95. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/slices/assertion/__init__.py +0 -0
  96. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/slices/assertion/admissible_edges.py +0 -0
  97. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/slices/fluentapi/__init__.py +0 -0
  98. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/slices/fluentapi/slices.py +0 -0
  99. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/slices/projection/__init__.py +0 -0
  100. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/slices/projection/slicing_projections.py +0 -0
  101. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/slices/uml/__init__.py +0 -0
  102. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/slices/uml/export_diagram.py +0 -0
  103. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/slices/uml/generate_rules.py +0 -0
  104. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/testing/__init__.py +0 -0
  105. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/testing/assertion.py +0 -0
  106. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/testing/common/__init__.py +0 -0
  107. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/testing/common/color_utils.py +0 -0
  108. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/testing/common/violation_factory.py +0 -0
  109. {archunitpython-1.6.1 → archunitpython-1.7.0}/src/archunitpython/testing/pytest_plugin/__init__.py +0 -0
  110. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/__init__.py +0 -0
  111. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/common/__init__.py +0 -0
  112. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/common/test_core_types.py +0 -0
  113. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/common/test_cycles.py +0 -0
  114. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/common/test_declaration_detector.py +0 -0
  115. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/common/test_logger.py +0 -0
  116. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/common/test_pattern_matching.py +0 -0
  117. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/common/test_projection.py +0 -0
  118. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/config/__init__.py +0 -0
  119. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/config/test_config_loader.py +0 -0
  120. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/files/__init__.py +0 -0
  121. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/files/test_file_assertions.py +0 -0
  122. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/files/test_files_fluentapi.py +0 -0
  123. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/fixtures/metrics_project/service.py +0 -0
  124. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/fixtures/sample_project/__init__.py +0 -0
  125. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/fixtures/sample_project/architecture.puml +0 -0
  126. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/fixtures/sample_project/controllers/__init__.py +0 -0
  127. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/fixtures/sample_project/controllers/controller.py +0 -0
  128. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/fixtures/sample_project/models/__init__.py +0 -0
  129. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/fixtures/sample_project/models/model.py +0 -0
  130. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/fixtures/sample_project/services/__init__.py +0 -0
  131. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/fixtures/sample_project/services/service.py +0 -0
  132. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/fixtures/sample_project/services/service_a.py +0 -0
  133. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/fixtures/sample_project/services/service_b.py +0 -0
  134. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/fixtures/sample_project/utils/__init__.py +0 -0
  135. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/fixtures/sample_project/utils/helpers.py +0 -0
  136. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/graph/test_graph_reporter.py +0 -0
  137. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/integration/__init__.py +0 -0
  138. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/integration/test_e2e.py +0 -0
  139. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/layers/test_layers.py +0 -0
  140. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/metrics/__init__.py +0 -0
  141. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/metrics/test_export.py +0 -0
  142. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/metrics/test_metrics.py +0 -0
  143. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/metrics/test_metrics_fluentapi.py +0 -0
  144. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/slices/__init__.py +0 -0
  145. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/slices/test_slices.py +0 -0
  146. {archunitpython-1.6.1 → archunitpython-1.7.0}/tests/test_setup.py +0 -0
@@ -18,7 +18,7 @@ This backlog collects product and maintenance ideas from project research.
18
18
 
19
19
  ## P1 - Python Import Semantics
20
20
 
21
- - Add support for namespace packages that do not contain `__init__.py`.
21
+ - [x] Add support for namespace packages that do not contain `__init__.py`.
22
22
  - [x] Detect dynamic imports such as `importlib.import_module()` and `__import__()`.
23
23
  - [x] Detect conditional imports such as `try/except ImportError`.
24
24
  - Add better `TYPE_CHECKING` import handling, including options to ignore, include, or report type-only imports separately.
@@ -1,3 +1,15 @@
1
+ # [1.7.0](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.6.1...v1.7.0) (2026-09-13)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * preserve mixed namespace import targets ([489624b](https://github.com/LukasNiessen/ArchUnitPython/commit/489624b4de5465847f6e83106cbe0a398a7c1e8e))
7
+
8
+
9
+ ### Features
10
+
11
+ * resolve namespace package submodule imports ([86c53e6](https://github.com/LukasNiessen/ArchUnitPython/commit/86c53e619ad25dfcdfb109de4593967d3d5603a6))
12
+
1
13
  ## [1.6.1](https://github.com/LukasNiessen/ArchUnitPython/compare/v1.6.0...v1.6.1) (2026-09-13)
2
14
 
3
15
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: archunitpython
3
- Version: 1.6.1
3
+ Version: 1.7.0
4
4
  Summary: Architecture testing library for Python projects. Enforce dependency rules, detect cycles, validate metrics.
5
5
  Project-URL: Homepage, https://github.com/LukasNiessen/ArchUnitPython
6
6
  Project-URL: Repository, https://github.com/LukasNiessen/ArchUnitPython.git
@@ -377,6 +377,12 @@ ArchUnitPython detects string-based dynamic imports such as `importlib.import_mo
377
377
  from my_app.adapters.sql import Repository # archunit: ignore
378
378
  ```
379
379
 
380
+ ### Namespace Packages
381
+
382
+ ArchUnitPython resolves imports from namespace packages that do not contain
383
+ `__init__.py` files. For example, `from my_app.domain import model` is resolved
384
+ to `my_app/domain/model.py` when `my_app/domain/` is a namespace package.
385
+
380
386
  ### Conditional Imports
381
387
 
382
388
  Imports inside `try` blocks that handle `ImportError` or
@@ -344,6 +344,12 @@ ArchUnitPython detects string-based dynamic imports such as `importlib.import_mo
344
344
  from my_app.adapters.sql import Repository # archunit: ignore
345
345
  ```
346
346
 
347
+ ### Namespace Packages
348
+
349
+ ArchUnitPython resolves imports from namespace packages that do not contain
350
+ `__init__.py` files. For example, `from my_app.domain import model` is resolved
351
+ to `my_app/domain/model.py` when `my_app/domain/` is a namespace package.
352
+
347
353
  ### Conditional Imports
348
354
 
349
355
  Imports inside `try` blocks that handle `ImportError` or
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "archunitpython"
7
- version = "1.6.1"
7
+ version = "1.7.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.6.1"
3
+ __version__ = "1.7.0"
4
4
 
5
5
  # Files API
6
6
  # Common
@@ -45,6 +45,7 @@ class _LocatedImport:
45
45
  line_number: int
46
46
  resolution_kind: ImportKind | None = None
47
47
  fallback_module_name: str | None = None
48
+ aliases: tuple[str, ...] = ()
48
49
 
49
50
 
50
51
  @dataclass(frozen=True)
@@ -177,39 +178,28 @@ def _extract_graph_uncached(
177
178
 
178
179
  imports = _extract_located_imports(file_path)
179
180
  for located_import in imports:
180
- module_name = located_import.module_name
181
181
  import_kind = located_import.import_kind
182
182
  if (
183
183
  ignore_type_checking_imports
184
184
  and import_kind == ImportKind.TYPE_IMPORT
185
185
  ):
186
186
  continue
187
- resolution_kind = located_import.resolution_kind or import_kind
188
- resolved, is_external = _resolve_import(
189
- module_name, file_path, project_path, resolution_kind
190
- )
191
- if is_external and located_import.fallback_module_name is not None:
192
- fallback, fallback_is_external = _resolve_import(
193
- located_import.fallback_module_name,
194
- file_path,
195
- project_path,
196
- resolution_kind,
197
- )
198
- if fallback and not fallback_is_external:
199
- resolved, is_external = fallback, False
200
- if resolved and resolved != _normalize(file_path):
201
- # Check if the resolved path is in our project
202
- if not is_external and resolved not in normalized_py_file_set:
203
- continue
204
-
205
- edges.append(
206
- Edge(
207
- source=_normalize(file_path),
208
- target=resolved,
209
- external=is_external,
210
- import_kinds=_edge_import_kinds(located_import),
187
+ for resolved, is_external in _resolve_import_targets(
188
+ located_import, file_path, project_path
189
+ ):
190
+ if resolved and resolved != _normalize(file_path):
191
+ # Check if the resolved path is in our project
192
+ if not is_external and resolved not in normalized_py_file_set:
193
+ continue
194
+
195
+ edges.append(
196
+ Edge(
197
+ source=_normalize(file_path),
198
+ target=resolved,
199
+ external=is_external,
200
+ import_kinds=_edge_import_kinds(located_import),
201
+ )
211
202
  )
212
- )
213
203
 
214
204
  return _merge_edges(edges)
215
205
 
@@ -342,14 +332,16 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
342
332
  fallback_module_name = (
343
333
  "." * node.level if node.level and node.module is None else None
344
334
  )
335
+ aliases = _module_aliases(node) if node.module else ()
345
336
  for module_name in _import_from_module_names(node):
346
337
  imports.append(
347
338
  _LocatedImport(
348
- module_name,
349
- kind,
350
- node.lineno,
351
- syntax_kind,
352
- fallback_module_name,
339
+ module_name=module_name,
340
+ import_kind=kind,
341
+ line_number=node.lineno,
342
+ resolution_kind=syntax_kind,
343
+ fallback_module_name=fallback_module_name,
344
+ aliases=aliases,
353
345
  )
354
346
  )
355
347
 
@@ -429,6 +421,11 @@ def _extract_dynamic_import_names(node: ast.Call) -> list[str]:
429
421
  return []
430
422
 
431
423
 
424
+ def _module_aliases(node: ast.ImportFrom) -> tuple[str, ...]:
425
+ """Return aliases that may refer to imported submodules."""
426
+ return tuple(alias.name for alias in node.names if alias.name != "*")
427
+
428
+
432
429
  def _import_from_module_names(node: ast.ImportFrom) -> tuple[str, ...]:
433
430
  """Return resolvable module names for a from-import statement."""
434
431
  dots = "." * (node.level or 0)
@@ -573,6 +570,58 @@ def _resolve_import(
573
570
  return _resolve_absolute_import(import_name, project_root)
574
571
 
575
572
 
573
+ def _resolve_import_targets(
574
+ import_: _LocatedImport,
575
+ source_file: str,
576
+ project_root: str,
577
+ ) -> list[tuple[str, bool]]:
578
+ """Resolve an import, including namespace-package submodule aliases."""
579
+ resolution_kind = import_.resolution_kind or import_.import_kind
580
+ resolved, is_external = _resolve_import(
581
+ import_.module_name,
582
+ source_file,
583
+ project_root,
584
+ resolution_kind,
585
+ )
586
+ if is_external and import_.fallback_module_name is not None:
587
+ fallback, fallback_is_external = _resolve_import(
588
+ import_.fallback_module_name,
589
+ source_file,
590
+ project_root,
591
+ resolution_kind,
592
+ )
593
+ if fallback and not fallback_is_external:
594
+ resolved, is_external = fallback, False
595
+ if not is_external or not import_.aliases:
596
+ return [(resolved, is_external)]
597
+
598
+ alias_targets: list[tuple[str, bool]] = []
599
+ found_internal_alias = False
600
+ for alias in import_.aliases:
601
+ alias_module = _join_import_alias(import_.module_name, alias)
602
+ alias_resolved, alias_is_external = _resolve_import(
603
+ alias_module,
604
+ source_file,
605
+ project_root,
606
+ resolution_kind,
607
+ )
608
+ alias_targets.append((alias_resolved, alias_is_external))
609
+ found_internal_alias = found_internal_alias or not alias_is_external
610
+
611
+ if found_internal_alias:
612
+ return alias_targets
613
+ return [(resolved, is_external)]
614
+
615
+
616
+ def _join_import_alias(module_name: str, alias: str) -> str:
617
+ """Join a from-import module name with a candidate submodule alias."""
618
+ if not module_name:
619
+ return alias
620
+ if set(module_name) == {"."}:
621
+ return f"{module_name}{alias}"
622
+ return f"{module_name}.{alias}"
623
+
624
+
576
625
  def _resolve_relative_import(
577
626
  import_name: str,
578
627
  source_file: str,
@@ -404,6 +404,162 @@ class TestDynamicImportGraphHandling:
404
404
  assert ImportKind.DYNAMIC_IMPORT in edges[0].import_kinds
405
405
 
406
406
 
407
+ class TestNamespacePackageGraphHandling:
408
+ def setup_method(self):
409
+ clear_graph_cache()
410
+
411
+ def _build_namespace_project(
412
+ self,
413
+ service_source: str,
414
+ *,
415
+ domain_modules: tuple[str, ...] = ("model",),
416
+ ) -> str:
417
+ temp_root = Path(__file__).resolve().parent / ".tmp"
418
+ temp_root.mkdir(exist_ok=True)
419
+ project_root = temp_root / f"project_{uuid4().hex}"
420
+
421
+ domain_dir = project_root / "namespace_pkg" / "domain"
422
+ services_dir = project_root / "namespace_pkg" / "services"
423
+ domain_dir.mkdir(parents=True)
424
+ services_dir.mkdir(parents=True)
425
+
426
+ for module in domain_modules:
427
+ (domain_dir / f"{module}.py").write_text(
428
+ "class User:\n pass\n",
429
+ encoding="utf-8",
430
+ )
431
+ (services_dir / "service.py").write_text(service_source, encoding="utf-8")
432
+
433
+ self._temp_dir = project_root
434
+ return str(project_root)
435
+
436
+ def teardown_method(self):
437
+ temp_dir = getattr(self, "_temp_dir", None)
438
+ if temp_dir is not None:
439
+ shutil.rmtree(temp_dir, ignore_errors=True)
440
+
441
+ def _service_to_model_edges(self, project_root: str) -> list[Edge]:
442
+ graph = extract_graph(project_root)
443
+ model_path = os.path.abspath(
444
+ os.path.join(project_root, "namespace_pkg", "domain", "model.py")
445
+ ).replace("\\", "/")
446
+ service_path = os.path.abspath(
447
+ os.path.join(project_root, "namespace_pkg", "services", "service.py")
448
+ ).replace("\\", "/")
449
+ return [
450
+ edge for edge in graph if edge.source == service_path and edge.target == model_path
451
+ ]
452
+
453
+ def _service_edges(self, project_root: str) -> list[Edge]:
454
+ graph = extract_graph(project_root)
455
+ service_path = os.path.abspath(
456
+ os.path.join(project_root, "namespace_pkg", "services", "service.py")
457
+ ).replace("\\", "/")
458
+ return [
459
+ edge
460
+ for edge in graph
461
+ if edge.source == service_path and edge.target != service_path
462
+ ]
463
+
464
+ def test_absolute_from_import_resolves_namespace_package_submodule(self):
465
+ project_root = self._build_namespace_project(
466
+ "from namespace_pkg.domain import model\n"
467
+ )
468
+
469
+ edges = self._service_to_model_edges(project_root)
470
+
471
+ assert len(edges) == 1
472
+ assert edges[0].external is False
473
+ assert ImportKind.FROM_IMPORT in edges[0].import_kinds
474
+
475
+ def test_relative_from_import_resolves_namespace_package_submodule(self):
476
+ project_root = self._build_namespace_project("from ..domain import model\n")
477
+
478
+ edges = self._service_to_model_edges(project_root)
479
+
480
+ assert len(edges) == 1
481
+ assert edges[0].external is False
482
+ assert ImportKind.RELATIVE_IMPORT in edges[0].import_kinds
483
+
484
+ def test_mixed_aliases_preserve_internal_and_external_edges(self):
485
+ project_root = self._build_namespace_project(
486
+ "from namespace_pkg.domain import model, remote_model\n"
487
+ )
488
+
489
+ edges = self._service_edges(project_root)
490
+ model_path = os.path.abspath(
491
+ os.path.join(project_root, "namespace_pkg", "domain", "model.py")
492
+ ).replace("\\", "/")
493
+
494
+ assert any(edge.target == model_path and not edge.external for edge in edges)
495
+ assert any(
496
+ edge.target == "namespace_pkg.domain.remote_model" and edge.external
497
+ for edge in edges
498
+ )
499
+
500
+ def test_multiple_internal_aliases_resolve_to_each_submodule(self):
501
+ project_root = self._build_namespace_project(
502
+ "from namespace_pkg.domain import model, audit_model\n",
503
+ domain_modules=("model", "audit_model"),
504
+ )
505
+
506
+ internal_targets = {
507
+ edge.target for edge in self._service_edges(project_root) if not edge.external
508
+ }
509
+ expected_targets = {
510
+ os.path.abspath(
511
+ os.path.join(project_root, "namespace_pkg", "domain", f"{module}.py")
512
+ ).replace("\\", "/")
513
+ for module in ("model", "audit_model")
514
+ }
515
+
516
+ assert internal_targets == expected_targets
517
+
518
+ def test_all_external_aliases_keep_original_base_edge(self):
519
+ project_root = self._build_namespace_project(
520
+ "from vendor_sdk import Client, Config\n"
521
+ )
522
+
523
+ external_targets = {
524
+ edge.target for edge in self._service_edges(project_root) if edge.external
525
+ }
526
+
527
+ assert external_targets == {"vendor_sdk"}
528
+
529
+ def test_as_alias_uses_original_submodule_name(self):
530
+ project_root = self._build_namespace_project(
531
+ "from namespace_pkg.domain import model as domain_model\n"
532
+ )
533
+
534
+ assert len(self._service_to_model_edges(project_root)) == 1
535
+
536
+ def test_relative_mixed_aliases_preserve_unresolved_edge(self):
537
+ project_root = self._build_namespace_project(
538
+ "from ..domain import model, remote_model\n"
539
+ )
540
+
541
+ edges = self._service_edges(project_root)
542
+ unresolved_path = os.path.abspath(
543
+ os.path.join(project_root, "namespace_pkg", "domain", "remote_model.py")
544
+ ).replace("\\", "/")
545
+
546
+ assert len(self._service_to_model_edges(project_root)) == 1
547
+ assert any(
548
+ edge.target == unresolved_path and edge.external for edge in edges
549
+ )
550
+
551
+ def test_archignore_suppresses_resolved_namespace_target(self):
552
+ project_root = self._build_namespace_project(
553
+ "from namespace_pkg.domain import model\n"
554
+ )
555
+ Path(project_root, ".archignore").write_text(
556
+ "namespace_pkg/domain/model.py\n",
557
+ encoding="utf-8",
558
+ )
559
+
560
+ assert self._service_to_model_edges(project_root) == []
561
+
562
+
407
563
  class TestConditionalImportGraphHandling:
408
564
  def setup_method(self):
409
565
  clear_graph_cache()
File without changes