archunitpython 1.1.1__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.1"
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."""
@@ -58,7 +88,9 @@ def extract_graph(
58
88
  project_path = os.getcwd()
59
89
 
60
90
  project_path = os.path.abspath(project_path)
61
- excludes = list(exclude_patterns) if exclude_patterns is not None else list(_DEFAULT_EXCLUDE)
91
+ excludes = (
92
+ list(set(exclude_patterns)) if exclude_patterns is not None else list(_DEFAULT_EXCLUDE)
93
+ )
62
94
  ignore_type_checking_imports = bool(
63
95
  options and options.ignore_type_checking_imports
64
96
  )
@@ -117,8 +149,10 @@ def _extract_graph_uncached(
117
149
  )
118
150
 
119
151
  # Extract and resolve imports
120
- imports = _extract_imports(file_path)
121
- 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
122
156
  if (
123
157
  ignore_type_checking_imports
124
158
  and import_kind == ImportKind.TYPE_IMPORT
@@ -185,6 +219,14 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
185
219
 
186
220
  Returns list of (module_name, import_kind) tuples.
187
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."""
188
230
  try:
189
231
  with open(file_path, "r", encoding="utf-8", errors="replace") as f:
190
232
  source = f.read()
@@ -196,7 +238,8 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
196
238
  except SyntaxError:
197
239
  return []
198
240
 
199
- imports: list[tuple[str, ImportKind]] = []
241
+ imports: list[_LocatedImport] = []
242
+ ignore_directives = _find_ignore_directives(source)
200
243
  type_checking_ranges = _find_type_checking_ranges(tree)
201
244
 
202
245
  for node in ast.walk(tree):
@@ -204,7 +247,7 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
204
247
  is_type = _in_type_checking(node, type_checking_ranges)
205
248
  kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.IMPORT
206
249
  for alias in node.names:
207
- imports.append((alias.name, kind))
250
+ imports.append(_LocatedImport(alias.name, kind, node.lineno))
208
251
 
209
252
  elif isinstance(node, ast.ImportFrom):
210
253
  is_type = _in_type_checking(node, type_checking_ranges)
@@ -213,13 +256,79 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
213
256
  kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.RELATIVE_IMPORT
214
257
  module = node.module or ""
215
258
  dots = "." * node.level
216
- imports.append((f"{dots}{module}", kind))
259
+ imports.append(_LocatedImport(f"{dots}{module}", kind, node.lineno))
217
260
  else:
218
261
  kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.FROM_IMPORT
219
262
  if node.module:
220
- 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 []
221
316
 
222
- 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 []
223
332
 
224
333
 
225
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
+ ]