archunitpython 1.1.2__py3-none-any.whl → 1.2.0__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  """ArchUnitPython - Architecture testing library for Python projects."""
2
2
 
3
- __version__ = "1.1.2"
3
+ __version__ = "1.2.0"
4
4
 
5
5
  # Files API
6
6
  # Common
@@ -13,6 +13,8 @@ from archunitpython.common import (
13
13
  )
14
14
  from archunitpython.common.extraction import clear_graph_cache, extract_graph
15
15
  from archunitpython.files import files, project_files
16
+ from archunitpython.graph import dependency_graph, project_graph
17
+ from archunitpython.layers import layers, project_layers
16
18
 
17
19
  # Metrics API
18
20
  from archunitpython.metrics import metrics
@@ -27,6 +29,12 @@ __all__ = [
27
29
  # Files
28
30
  "project_files",
29
31
  "files",
32
+ # Graph
33
+ "project_graph",
34
+ "dependency_graph",
35
+ # Layers
36
+ "project_layers",
37
+ "layers",
30
38
  # Slices
31
39
  "project_slices",
32
40
  # Metrics
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  import ast
6
6
  import os
7
+ import re
8
+ from dataclasses import dataclass
7
9
 
8
10
  from archunitpython.common.extraction.graph import Edge, Graph, ImportKind
9
11
  from archunitpython.common.fluentapi.checkable import CheckOptions
@@ -27,6 +29,34 @@ _DEFAULT_EXCLUDE = [
27
29
  "*.egg-info",
28
30
  ]
29
31
 
32
+ _IGNORE_DIRECTIVE_REGEX = re.compile(
33
+ r"#\s*archunit(?::|-)\s*ignore"
34
+ r"(?:\([^)]*\))?"
35
+ r"(?P<modules>(?:\s+[\w.]+)*)\s*$"
36
+ )
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class _LocatedImport:
41
+ module_name: str
42
+ import_kind: ImportKind
43
+ line_number: int
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class _IgnoreDirective:
48
+ line_number: int
49
+ modules: tuple[str, ...] = ()
50
+
51
+ def matches(self, import_: _LocatedImport) -> bool:
52
+ if not self.modules:
53
+ return True
54
+ return any(
55
+ import_.module_name == module
56
+ or import_.module_name.startswith(f"{module}.")
57
+ for module in self.modules
58
+ )
59
+
30
60
 
31
61
  def clear_graph_cache(options: CheckOptions | None = None) -> None:
32
62
  """Clear the cached dependency graphs."""
@@ -119,8 +149,10 @@ def _extract_graph_uncached(
119
149
  )
120
150
 
121
151
  # Extract and resolve imports
122
- imports = _extract_imports(file_path)
123
- for module_name, import_kind in imports:
152
+ imports = _extract_located_imports(file_path)
153
+ for located_import in imports:
154
+ module_name = located_import.module_name
155
+ import_kind = located_import.import_kind
124
156
  if (
125
157
  ignore_type_checking_imports
126
158
  and import_kind == ImportKind.TYPE_IMPORT
@@ -187,6 +219,14 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
187
219
 
188
220
  Returns list of (module_name, import_kind) tuples.
189
221
  """
222
+ return [
223
+ (import_.module_name, import_.import_kind)
224
+ for import_ in _extract_located_imports(file_path)
225
+ ]
226
+
227
+
228
+ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
229
+ """Parse a Python file and extract imports with line numbers."""
190
230
  try:
191
231
  with open(file_path, "r", encoding="utf-8", errors="replace") as f:
192
232
  source = f.read()
@@ -198,7 +238,8 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
198
238
  except SyntaxError:
199
239
  return []
200
240
 
201
- imports: list[tuple[str, ImportKind]] = []
241
+ imports: list[_LocatedImport] = []
242
+ ignore_directives = _find_ignore_directives(source)
202
243
  type_checking_ranges = _find_type_checking_ranges(tree)
203
244
 
204
245
  for node in ast.walk(tree):
@@ -206,7 +247,7 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
206
247
  is_type = _in_type_checking(node, type_checking_ranges)
207
248
  kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.IMPORT
208
249
  for alias in node.names:
209
- imports.append((alias.name, kind))
250
+ imports.append(_LocatedImport(alias.name, kind, node.lineno))
210
251
 
211
252
  elif isinstance(node, ast.ImportFrom):
212
253
  is_type = _in_type_checking(node, type_checking_ranges)
@@ -215,13 +256,79 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
215
256
  kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.RELATIVE_IMPORT
216
257
  module = node.module or ""
217
258
  dots = "." * node.level
218
- imports.append((f"{dots}{module}", kind))
259
+ imports.append(_LocatedImport(f"{dots}{module}", kind, node.lineno))
219
260
  else:
220
261
  kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.FROM_IMPORT
221
262
  if node.module:
222
- imports.append((node.module, kind))
263
+ imports.append(_LocatedImport(node.module, kind, node.lineno))
264
+
265
+ elif isinstance(node, ast.Call):
266
+ is_type = _in_type_checking(node, type_checking_ranges)
267
+ kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.DYNAMIC_IMPORT
268
+ for module_name in _extract_dynamic_import_names(node):
269
+ imports.append(_LocatedImport(module_name, kind, node.lineno))
270
+
271
+ return [
272
+ import_
273
+ for import_ in imports
274
+ if not _is_ignored_import(import_, ignore_directives)
275
+ ]
276
+
277
+
278
+ def _find_ignore_directives(source: str) -> dict[int, _IgnoreDirective]:
279
+ """Find architecture-ignore directives.
280
+
281
+ Supports inline directives on an import line and standalone directives that
282
+ apply to the following line, for example:
283
+
284
+ - from x import y # archunit: ignore
285
+ - # archunit: ignore
286
+ from x import y
287
+ """
288
+ directives: dict[int, _IgnoreDirective] = {}
289
+ for index, line in enumerate(source.splitlines(), start=1):
290
+ match = _IGNORE_DIRECTIVE_REGEX.search(line)
291
+ if match is None:
292
+ continue
293
+
294
+ modules = tuple(match.group("modules").split())
295
+ target_line = index + 1 if line.strip().startswith("#") else index
296
+ directives[target_line] = _IgnoreDirective(target_line, modules)
297
+ return directives
298
+
299
+
300
+ def _is_ignored_import(
301
+ import_: _LocatedImport,
302
+ directives: dict[int, _IgnoreDirective],
303
+ ) -> bool:
304
+ directive = directives.get(import_.line_number)
305
+ return directive is not None and directive.matches(import_)
306
+
307
+
308
+ def _extract_dynamic_import_names(node: ast.Call) -> list[str]:
309
+ """Extract literal module names from common dynamic import calls."""
310
+ if not node.args:
311
+ return []
312
+
313
+ first_arg = node.args[0]
314
+ if not isinstance(first_arg, ast.Constant) or not isinstance(first_arg.value, str):
315
+ return []
223
316
 
224
- return imports
317
+ if isinstance(node.func, ast.Name) and node.func.id in {
318
+ "__import__",
319
+ "import_module",
320
+ }:
321
+ return [first_arg.value]
322
+
323
+ if (
324
+ isinstance(node.func, ast.Attribute)
325
+ and node.func.attr == "import_module"
326
+ and isinstance(node.func.value, ast.Name)
327
+ and node.func.value.id == "importlib"
328
+ ):
329
+ return [first_arg.value]
330
+
331
+ return []
225
332
 
226
333
 
227
334
  def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
@@ -0,0 +1,35 @@
1
+ """Dependency graph reports."""
2
+
3
+ from archunitpython.graph.graph_reporter import (
4
+ DEFAULT_TITLE,
5
+ FolderDepthCollapse,
6
+ GraphCollapseStrategy,
7
+ GraphQueryOptions,
8
+ GraphReportEdge,
9
+ GraphReporter,
10
+ GraphReportFormat,
11
+ GraphReportNode,
12
+ GraphReportSnapshot,
13
+ GraphReportSummary,
14
+ PatternCollapse,
15
+ ProjectGraphBuilder,
16
+ dependency_graph,
17
+ project_graph,
18
+ )
19
+
20
+ __all__ = [
21
+ "DEFAULT_TITLE",
22
+ "FolderDepthCollapse",
23
+ "GraphCollapseStrategy",
24
+ "GraphQueryOptions",
25
+ "GraphReportEdge",
26
+ "GraphReportFormat",
27
+ "GraphReportNode",
28
+ "GraphReportSnapshot",
29
+ "GraphReportSummary",
30
+ "GraphReporter",
31
+ "PatternCollapse",
32
+ "ProjectGraphBuilder",
33
+ "dependency_graph",
34
+ "project_graph",
35
+ ]