Skip to content

Commit 5f0f600

Browse files
committed
feat: classify conditional imports
1 parent 7c76178 commit 5f0f600

4 files changed

Lines changed: 200 additions & 8 deletions

File tree

README.md

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

275+
### Conditional Imports
276+
277+
Imports inside `try` blocks that handle `ImportError` or
278+
`ModuleNotFoundError` are marked as conditional dependencies. This helps graph
279+
reports distinguish optional imports and fallback implementations from regular
280+
runtime imports.
281+
275282
### Naming Conventions
276283

277284
```python

src/archunitpython/common/extraction/extract_graph.py

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -231,30 +231,48 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
231231
imports: list[_LocatedImport] = []
232232
ignore_directives = _find_ignore_directives(source)
233233
type_checking_ranges = _find_type_checking_ranges(tree)
234+
conditional_import_ranges = _find_conditional_import_ranges(tree)
234235

235236
for node in ast.walk(tree):
236237
if isinstance(node, ast.Import):
237-
is_type = _in_type_checking(node, type_checking_ranges)
238-
kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.IMPORT
238+
kind = _classify_import(
239+
node,
240+
ImportKind.IMPORT,
241+
type_checking_ranges,
242+
conditional_import_ranges,
243+
)
239244
for alias in node.names:
240245
imports.append(_LocatedImport(alias.name, kind, node.lineno))
241246

242247
elif isinstance(node, ast.ImportFrom):
243-
is_type = _in_type_checking(node, type_checking_ranges)
244248
if node.level and node.level > 0:
245249
# Relative import
246-
kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.RELATIVE_IMPORT
250+
kind = _classify_import(
251+
node,
252+
ImportKind.RELATIVE_IMPORT,
253+
type_checking_ranges,
254+
conditional_import_ranges,
255+
)
247256
module = node.module or ""
248257
dots = "." * node.level
249258
imports.append(_LocatedImport(f"{dots}{module}", kind, node.lineno))
250259
else:
251-
kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.FROM_IMPORT
260+
kind = _classify_import(
261+
node,
262+
ImportKind.FROM_IMPORT,
263+
type_checking_ranges,
264+
conditional_import_ranges,
265+
)
252266
if node.module:
253267
imports.append(_LocatedImport(node.module, kind, node.lineno))
254268

255269
elif isinstance(node, ast.Call):
256-
is_type = _in_type_checking(node, type_checking_ranges)
257-
kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.DYNAMIC_IMPORT
270+
kind = _classify_import(
271+
node,
272+
ImportKind.DYNAMIC_IMPORT,
273+
type_checking_ranges,
274+
conditional_import_ranges,
275+
)
258276
for module_name in _extract_dynamic_import_names(node):
259277
imports.append(_LocatedImport(module_name, kind, node.lineno))
260278

@@ -321,6 +339,20 @@ def _extract_dynamic_import_names(node: ast.Call) -> list[str]:
321339
return []
322340

323341

342+
def _classify_import(
343+
node: ast.AST,
344+
default_kind: ImportKind,
345+
type_checking_ranges: list[tuple[int, int]],
346+
conditional_import_ranges: list[tuple[int, int]],
347+
) -> ImportKind:
348+
"""Classify an import node by special context before syntax kind."""
349+
if _in_type_checking(node, type_checking_ranges):
350+
return ImportKind.TYPE_IMPORT
351+
if _in_conditional_import(node, conditional_import_ranges):
352+
return ImportKind.CONDITIONAL_IMPORT
353+
return default_kind
354+
355+
324356
def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
325357
"""Find line ranges of TYPE_CHECKING blocks."""
326358
ranges: list[tuple[int, int]] = []
@@ -346,6 +378,46 @@ def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
346378
return sorted(ranges, key=lambda ele: ele[0])
347379

348380

381+
def _find_conditional_import_ranges(tree: ast.Module) -> list[tuple[int, int]]:
382+
"""Find try/except ImportError ranges that contain optional imports."""
383+
ranges: list[tuple[int, int]] = []
384+
385+
for node in ast.walk(tree):
386+
if not isinstance(node, ast.Try):
387+
continue
388+
if not any(_handles_import_error(handler.type) for handler in node.handlers):
389+
continue
390+
391+
ranges.extend(_statement_ranges(node.body))
392+
for handler in node.handlers:
393+
if _handles_import_error(handler.type):
394+
ranges.extend(_statement_ranges(handler.body))
395+
396+
return sorted(ranges, key=lambda ele: ele[0])
397+
398+
399+
def _handles_import_error(node: ast.expr | None) -> bool:
400+
"""Return True if an except handler catches import-related errors."""
401+
if node is None:
402+
return False
403+
if isinstance(node, ast.Name):
404+
return node.id in {"ImportError", "ModuleNotFoundError"}
405+
if isinstance(node, ast.Attribute):
406+
return node.attr in {"ImportError", "ModuleNotFoundError"}
407+
if isinstance(node, ast.Tuple):
408+
return any(_handles_import_error(elt) for elt in node.elts)
409+
return False
410+
411+
412+
def _statement_ranges(statements: list[ast.stmt]) -> list[tuple[int, int]]:
413+
"""Return line ranges covered by statement blocks."""
414+
if not statements:
415+
return []
416+
start = statements[0].lineno
417+
end = max(getattr(statement, "end_lineno", statement.lineno) for statement in statements)
418+
return [(start, end)]
419+
420+
349421
def _in_type_checking(node: ast.AST, ranges: list[tuple[int, int]]) -> bool:
350422
"""Check if a node is inside a TYPE_CHECKING block."""
351423
if not hasattr(node, "lineno"):
@@ -354,6 +426,14 @@ def _in_type_checking(node: ast.AST, ranges: list[tuple[int, int]]) -> bool:
354426
return any(start <= lineno <= end for start, end in ranges)
355427

356428

429+
def _in_conditional_import(node: ast.AST, ranges: list[tuple[int, int]]) -> bool:
430+
"""Check if a node is inside a try/except ImportError block."""
431+
if not hasattr(node, "lineno"):
432+
return False
433+
lineno = node.lineno
434+
return any(start <= lineno <= end for start, end in ranges)
435+
436+
357437
def _resolve_import(
358438
import_name: str,
359439
source_file: str,
@@ -365,7 +445,15 @@ def _resolve_import(
365445
Returns (resolved_path, is_external).
366446
The path is normalized with forward slashes.
367447
"""
368-
if kind in (ImportKind.RELATIVE_IMPORT, ImportKind.TYPE_IMPORT) and import_name.startswith("."):
448+
if (
449+
kind
450+
in (
451+
ImportKind.RELATIVE_IMPORT,
452+
ImportKind.TYPE_IMPORT,
453+
ImportKind.CONDITIONAL_IMPORT,
454+
)
455+
and import_name.startswith(".")
456+
):
369457
# Relative import
370458
return _resolve_relative_import(import_name, source_file, project_root)
371459

src/archunitpython/common/extraction/graph.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class ImportKind(Enum):
1414
RELATIVE_IMPORT = "relative" # from . import bar / from ..foo import bar
1515
DYNAMIC_IMPORT = "dynamic" # __import__('foo') / importlib.import_module()
1616
TYPE_IMPORT = "type" # inside TYPE_CHECKING block
17+
CONDITIONAL_IMPORT = "conditional" # inside try/except ImportError
1718

1819

1920
@dataclass(frozen=True)

tests/common/test_extract_graph.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,32 @@ def test_importlib_import_module(self):
105105
finally:
106106
shutil.rmtree(project_root, ignore_errors=True)
107107

108+
def test_conditional_import(self):
109+
temp_root = Path(__file__).resolve().parent / ".tmp"
110+
temp_root.mkdir(exist_ok=True)
111+
project_root = temp_root / f"project_{uuid4().hex}"
112+
project_root.mkdir()
113+
file_path = project_root / "loader.py"
114+
file_path.write_text(
115+
"\n".join(
116+
[
117+
"try:",
118+
" import orjson",
119+
"except ImportError:",
120+
" import json",
121+
"",
122+
]
123+
),
124+
encoding="utf-8",
125+
)
126+
127+
try:
128+
imports = _extract_imports(str(file_path))
129+
assert ("orjson", ImportKind.CONDITIONAL_IMPORT) in imports
130+
assert ("json", ImportKind.CONDITIONAL_IMPORT) in imports
131+
finally:
132+
shutil.rmtree(project_root, ignore_errors=True)
133+
108134

109135
class TestExtractGraph:
110136
def setup_method(self):
@@ -310,6 +336,75 @@ def test_dynamic_import_resolves_to_internal_edge(self):
310336
assert ImportKind.DYNAMIC_IMPORT in edges[0].import_kinds
311337

312338

339+
class TestConditionalImportGraphHandling:
340+
def setup_method(self):
341+
clear_graph_cache()
342+
343+
def _build_conditional_project(self) -> str:
344+
temp_root = Path(__file__).resolve().parent / ".tmp"
345+
temp_root.mkdir(exist_ok=True)
346+
project_root = temp_root / f"project_{uuid4().hex}"
347+
project_root.mkdir()
348+
349+
package_dir = project_root / "sample_project"
350+
package_dir.mkdir(parents=True, exist_ok=True)
351+
352+
(package_dir / "__init__.py").write_text("", encoding="utf-8")
353+
(package_dir / "fast_model.py").write_text(
354+
"class FastUser:\n pass\n",
355+
encoding="utf-8",
356+
)
357+
(package_dir / "fallback_model.py").write_text(
358+
"class FallbackUser:\n pass\n",
359+
encoding="utf-8",
360+
)
361+
(package_dir / "service.py").write_text(
362+
"\n".join(
363+
[
364+
"try:",
365+
" from sample_project.fast_model import FastUser",
366+
"except ImportError:",
367+
" from sample_project.fallback_model import FallbackUser",
368+
"",
369+
]
370+
),
371+
encoding="utf-8",
372+
)
373+
self._temp_dir = project_root
374+
return str(project_root)
375+
376+
def teardown_method(self):
377+
temp_dir = getattr(self, "_temp_dir", None)
378+
if temp_dir is not None:
379+
shutil.rmtree(temp_dir, ignore_errors=True)
380+
381+
def test_import_error_fallback_imports_are_marked_conditional(self):
382+
project_root = self._build_conditional_project()
383+
384+
graph = extract_graph(project_root)
385+
service_path = os.path.abspath(
386+
os.path.join(project_root, "sample_project", "service.py")
387+
).replace("\\", "/")
388+
target_paths = {
389+
os.path.abspath(
390+
os.path.join(project_root, "sample_project", "fast_model.py")
391+
).replace("\\", "/"),
392+
os.path.abspath(
393+
os.path.join(project_root, "sample_project", "fallback_model.py")
394+
).replace("\\", "/"),
395+
}
396+
397+
edges = [
398+
edge
399+
for edge in graph
400+
if edge.source == service_path and edge.target in target_paths
401+
]
402+
403+
assert len(edges) == 2
404+
assert all(edge.external is False for edge in edges)
405+
assert all(ImportKind.CONDITIONAL_IMPORT in edge.import_kinds for edge in edges)
406+
407+
313408
class TestIgnoreDirectives:
314409
def setup_method(self):
315410
clear_graph_cache()
@@ -401,3 +496,4 @@ def test_all_kinds_exist(self):
401496
assert ImportKind.RELATIVE_IMPORT.value == "relative"
402497
assert ImportKind.DYNAMIC_IMPORT.value == "dynamic"
403498
assert ImportKind.TYPE_IMPORT.value == "type"
499+
assert ImportKind.CONDITIONAL_IMPORT.value == "conditional"

0 commit comments

Comments
 (0)