Skip to content

Commit 86c53e6

Browse files
committed
feat: resolve namespace package submodule imports
1 parent 7c76178 commit 86c53e6

3 files changed

Lines changed: 143 additions & 18 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,12 @@ ArchUnitPython detects string-based dynamic imports such as `importlib.import_mo
272272
from my_app.adapters.sql import Repository # archunit: ignore
273273
```
274274

275+
### Namespace Packages
276+
277+
ArchUnitPython resolves imports from namespace packages that do not contain
278+
`__init__.py` files. For example, `from my_app.domain import model` is resolved
279+
to `my_app/domain/model.py` when `my_app/domain/` is a namespace package.
280+
275281
### Naming Conventions
276282

277283
```python

src/archunitpython/common/extraction/extract_graph.py

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class _LocatedImport:
4141
module_name: str
4242
import_kind: ImportKind
4343
line_number: int
44+
aliases: tuple[str, ...] = ()
4445

4546

4647
@dataclass(frozen=True)
@@ -147,29 +148,28 @@ def _extract_graph_uncached(
147148

148149
imports = _extract_located_imports(file_path)
149150
for located_import in imports:
150-
module_name = located_import.module_name
151151
import_kind = located_import.import_kind
152152
if (
153153
ignore_type_checking_imports
154154
and import_kind == ImportKind.TYPE_IMPORT
155155
):
156156
continue
157-
resolved, is_external = _resolve_import(
158-
module_name, file_path, project_path, import_kind
159-
)
160-
if resolved and resolved != _normalize(file_path):
161-
# Check if the resolved path is in our project
162-
if not is_external and resolved not in normalized_py_file_set:
163-
is_external = True
164-
165-
edges.append(
166-
Edge(
167-
source=_normalize(file_path),
168-
target=resolved,
169-
external=is_external,
170-
import_kinds=(import_kind,),
157+
for resolved, is_external in _resolve_import_targets(
158+
located_import, file_path, project_path
159+
):
160+
if resolved and resolved != _normalize(file_path):
161+
# Check if the resolved path is in our project
162+
if not is_external and resolved not in normalized_py_file_set:
163+
is_external = True
164+
165+
edges.append(
166+
Edge(
167+
source=_normalize(file_path),
168+
target=resolved,
169+
external=is_external,
170+
import_kinds=(import_kind,),
171+
)
171172
)
172-
)
173173

174174
return _merge_edges(edges)
175175

@@ -246,11 +246,25 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
246246
kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.RELATIVE_IMPORT
247247
module = node.module or ""
248248
dots = "." * node.level
249-
imports.append(_LocatedImport(f"{dots}{module}", kind, node.lineno))
249+
imports.append(
250+
_LocatedImport(
251+
f"{dots}{module}",
252+
kind,
253+
node.lineno,
254+
_module_aliases(node),
255+
)
256+
)
250257
else:
251258
kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.FROM_IMPORT
252259
if node.module:
253-
imports.append(_LocatedImport(node.module, kind, node.lineno))
260+
imports.append(
261+
_LocatedImport(
262+
node.module,
263+
kind,
264+
node.lineno,
265+
_module_aliases(node),
266+
)
267+
)
254268

255269
elif isinstance(node, ast.Call):
256270
is_type = _in_type_checking(node, type_checking_ranges)
@@ -321,6 +335,11 @@ def _extract_dynamic_import_names(node: ast.Call) -> list[str]:
321335
return []
322336

323337

338+
def _module_aliases(node: ast.ImportFrom) -> tuple[str, ...]:
339+
"""Return aliases that may refer to imported submodules."""
340+
return tuple(alias.name for alias in node.names if alias.name != "*")
341+
342+
324343
def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
325344
"""Find line ranges of TYPE_CHECKING blocks."""
326345
ranges: list[tuple[int, int]] = []
@@ -373,6 +392,45 @@ def _resolve_import(
373392
return _resolve_absolute_import(import_name, project_root)
374393

375394

395+
def _resolve_import_targets(
396+
import_: _LocatedImport,
397+
source_file: str,
398+
project_root: str,
399+
) -> list[tuple[str, bool]]:
400+
"""Resolve an import, including namespace-package submodule aliases."""
401+
resolved, is_external = _resolve_import(
402+
import_.module_name,
403+
source_file,
404+
project_root,
405+
import_.import_kind,
406+
)
407+
if not is_external or not import_.aliases:
408+
return [(resolved, is_external)]
409+
410+
alias_targets: list[tuple[str, bool]] = []
411+
for alias in import_.aliases:
412+
alias_module = _join_import_alias(import_.module_name, alias)
413+
alias_resolved, alias_is_external = _resolve_import(
414+
alias_module,
415+
source_file,
416+
project_root,
417+
import_.import_kind,
418+
)
419+
if not alias_is_external:
420+
alias_targets.append((alias_resolved, alias_is_external))
421+
422+
return alias_targets or [(resolved, is_external)]
423+
424+
425+
def _join_import_alias(module_name: str, alias: str) -> str:
426+
"""Join a from-import module name with a candidate submodule alias."""
427+
if not module_name:
428+
return alias
429+
if set(module_name) == {"."}:
430+
return f"{module_name}{alias}"
431+
return f"{module_name}.{alias}"
432+
433+
376434
def _resolve_relative_import(
377435
import_name: str,
378436
source_file: str,

tests/common/test_extract_graph.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,67 @@ def test_dynamic_import_resolves_to_internal_edge(self):
310310
assert ImportKind.DYNAMIC_IMPORT in edges[0].import_kinds
311311

312312

313+
class TestNamespacePackageGraphHandling:
314+
def setup_method(self):
315+
clear_graph_cache()
316+
317+
def _build_namespace_project(self, service_source: str) -> str:
318+
temp_root = Path(__file__).resolve().parent / ".tmp"
319+
temp_root.mkdir(exist_ok=True)
320+
project_root = temp_root / f"project_{uuid4().hex}"
321+
322+
domain_dir = project_root / "namespace_pkg" / "domain"
323+
services_dir = project_root / "namespace_pkg" / "services"
324+
domain_dir.mkdir(parents=True)
325+
services_dir.mkdir(parents=True)
326+
327+
(domain_dir / "model.py").write_text(
328+
"class User:\n pass\n",
329+
encoding="utf-8",
330+
)
331+
(services_dir / "service.py").write_text(service_source, encoding="utf-8")
332+
333+
self._temp_dir = project_root
334+
return str(project_root)
335+
336+
def teardown_method(self):
337+
temp_dir = getattr(self, "_temp_dir", None)
338+
if temp_dir is not None:
339+
shutil.rmtree(temp_dir, ignore_errors=True)
340+
341+
def _service_to_model_edges(self, project_root: str) -> list[Edge]:
342+
graph = extract_graph(project_root)
343+
model_path = os.path.abspath(
344+
os.path.join(project_root, "namespace_pkg", "domain", "model.py")
345+
).replace("\\", "/")
346+
service_path = os.path.abspath(
347+
os.path.join(project_root, "namespace_pkg", "services", "service.py")
348+
).replace("\\", "/")
349+
return [
350+
edge for edge in graph if edge.source == service_path and edge.target == model_path
351+
]
352+
353+
def test_absolute_from_import_resolves_namespace_package_submodule(self):
354+
project_root = self._build_namespace_project(
355+
"from namespace_pkg.domain import model\n"
356+
)
357+
358+
edges = self._service_to_model_edges(project_root)
359+
360+
assert len(edges) == 1
361+
assert edges[0].external is False
362+
assert ImportKind.FROM_IMPORT in edges[0].import_kinds
363+
364+
def test_relative_from_import_resolves_namespace_package_submodule(self):
365+
project_root = self._build_namespace_project("from ..domain import model\n")
366+
367+
edges = self._service_to_model_edges(project_root)
368+
369+
assert len(edges) == 1
370+
assert edges[0].external is False
371+
assert ImportKind.RELATIVE_IMPORT in edges[0].import_kinds
372+
373+
313374
class TestIgnoreDirectives:
314375
def setup_method(self):
315376
clear_graph_cache()

0 commit comments

Comments
 (0)