archunitpython 1.6.0__py3-none-any.whl → 1.8.1__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,12 +1,13 @@
1
1
  """ArchUnitPython - Architecture testing library for Python projects."""
2
2
 
3
- __version__ = "1.6.0"
3
+ __version__ = "1.8.1"
4
4
 
5
5
  # Files API
6
6
  # Common
7
7
  from archunitpython.common import (
8
8
  CheckOptions,
9
9
  EmptyTestViolation,
10
+ LoggingOptions,
10
11
  TechnicalError,
11
12
  UserError,
12
13
  Violation,
@@ -50,6 +51,7 @@ __all__ = [
50
51
  "Violation",
51
52
  "EmptyTestViolation",
52
53
  "CheckOptions",
54
+ "LoggingOptions",
53
55
  "TechnicalError",
54
56
  "UserError",
55
57
  "extract_graph",
@@ -5,10 +5,13 @@ from __future__ import annotations
5
5
  import ast
6
6
  import os
7
7
  import re
8
- from dataclasses import dataclass
8
+ from dataclasses import dataclass, field
9
9
 
10
10
  from archunitpython.common.extraction.graph import Edge, Graph, ImportKind
11
11
  from archunitpython.common.fluentapi.checkable import CheckOptions
12
+ from archunitpython.common.logging.inspection import debug, debug_enabled
13
+ from archunitpython.common.pattern_matching import matches_all_patterns
14
+ from archunitpython.common.types import Filter
12
15
 
13
16
  GraphCacheKey = tuple[str, tuple[str, ...], bool]
14
17
 
@@ -45,6 +48,7 @@ class _LocatedImport:
45
48
  line_number: int
46
49
  resolution_kind: ImportKind | None = None
47
50
  fallback_module_name: str | None = None
51
+ aliases: tuple[str, ...] = ()
48
52
 
49
53
 
50
54
  @dataclass(frozen=True)
@@ -56,15 +60,28 @@ class _IgnoreDirective:
56
60
  if not self.modules:
57
61
  return True
58
62
  return any(
59
- import_.module_name == module
60
- or import_.module_name.startswith(f"{module}.")
63
+ import_.module_name == module or import_.module_name.startswith(f"{module}.")
61
64
  for module in self.modules
62
65
  )
63
66
 
64
67
 
68
+ @dataclass
69
+ class _ExtractionSession:
70
+ project_path: str
71
+ py_files: list[str]
72
+ normalized_py_file_set: set[str]
73
+ ignore_type_checking_imports: bool
74
+ edges_by_file: dict[str, list[Edge]] = field(default_factory=dict)
75
+ selected_files: dict[tuple[Filter, ...], list[str]] = field(default_factory=dict)
76
+
77
+
78
+ _extraction_sessions: dict[GraphCacheKey, _ExtractionSession] = {}
79
+
80
+
65
81
  def clear_graph_cache(options: CheckOptions | None = None) -> None:
66
82
  """Clear the cached dependency graphs."""
67
83
  _graph_cache.clear()
84
+ _extraction_sessions.clear()
68
85
 
69
86
 
70
87
  def extract_graph(
@@ -96,21 +113,128 @@ def extract_graph(
96
113
  ignore_type_checking_imports = bool(options and options.ignore_type_checking_imports)
97
114
  cache_key = _build_cache_key(project_path, excludes, ignore_type_checking_imports)
98
115
 
116
+ debug(
117
+ "Extraction root: %s; excludes: %s; ignore type-only imports: %s",
118
+ project_path,
119
+ excludes,
120
+ ignore_type_checking_imports,
121
+ )
99
122
  if options and options.clear_cache:
123
+ debug("Clearing graph and extraction-session caches for %s", project_path)
100
124
  _graph_cache.pop(cache_key, None)
125
+ _extraction_sessions.pop(cache_key, None)
101
126
 
102
127
  if cache_key in _graph_cache:
128
+ debug("Graph cache hit: %d edges", len(_graph_cache[cache_key]))
129
+ _inspect_graph(_graph_cache[cache_key])
103
130
  return _graph_cache[cache_key]
131
+ debug("Graph cache miss")
132
+
133
+ session = _get_extraction_session(
134
+ cache_key, project_path, excludes, ignore_type_checking_imports
135
+ )
136
+ result = _extract_graph_from_files(session, session.py_files)
137
+ _inspect_graph(result)
138
+ _graph_cache[cache_key] = result
139
+ return result
140
+
141
+
142
+ def extract_graph_for_sources(
143
+ project_path: str | None,
144
+ source_filters: list[Filter],
145
+ *,
146
+ options: CheckOptions | None = None,
147
+ ) -> Graph:
148
+ """Extract edges from files selected by a direct file-dependency rule.
104
149
 
105
- result = _extract_graph_uncached(
150
+ All project files are inventoried to classify imported targets correctly.
151
+ Parsed edges are reused by later rules and full-graph checks in this process.
152
+ """
153
+ if not source_filters:
154
+ return extract_graph(project_path, options=options)
155
+
156
+ if project_path is None:
157
+ project_path = os.getcwd()
158
+ project_path = os.path.abspath(project_path)
159
+ excludes = _resolve_exclude_patterns(project_path, None)
160
+ ignore_type_checking_imports = bool(options and options.ignore_type_checking_imports)
161
+ cache_key = _build_cache_key(project_path, excludes, ignore_type_checking_imports)
162
+ debug(
163
+ "Extraction root: %s; excludes: %s; ignore type-only imports: %s",
106
164
  project_path,
107
165
  excludes,
108
- ignore_type_checking_imports=ignore_type_checking_imports,
166
+ ignore_type_checking_imports,
109
167
  )
110
- _graph_cache[cache_key] = result
168
+ if options and options.clear_cache:
169
+ debug("Clearing graph and extraction-session caches for %s", project_path)
170
+ _graph_cache.pop(cache_key, None)
171
+ _extraction_sessions.pop(cache_key, None)
172
+
173
+ session = _get_extraction_session(
174
+ cache_key, project_path, excludes, ignore_type_checking_imports
175
+ )
176
+ filter_key = tuple(source_filters)
177
+ if filter_key not in session.selected_files:
178
+ session.selected_files[filter_key] = [
179
+ path for path in session.py_files if matches_all_patterns(path, source_filters)
180
+ ]
181
+ selected_files = session.selected_files[filter_key]
182
+ if debug_enabled():
183
+ debug(
184
+ "Selected-source extraction: %d of %d files", len(selected_files), len(session.py_files)
185
+ )
186
+ if selected_files:
187
+ cached_files = sum(path in session.edges_by_file for path in selected_files)
188
+ if cached_files == len(selected_files):
189
+ cache_status = "hit"
190
+ elif cached_files:
191
+ cache_status = "partial hit"
192
+ else:
193
+ cache_status = "miss"
194
+ debug(
195
+ "Selected-source edge cache %s: %d/%d files cached",
196
+ cache_status,
197
+ cached_files,
198
+ len(selected_files),
199
+ )
200
+ result = _extract_graph_from_files(session, selected_files)
201
+ _inspect_graph(result)
111
202
  return result
112
203
 
113
204
 
205
+ def _get_extraction_session(
206
+ cache_key: GraphCacheKey,
207
+ project_path: str,
208
+ excludes: list[str],
209
+ ignore_type_checking_imports: bool,
210
+ ) -> _ExtractionSession:
211
+ session = _extraction_sessions.get(cache_key)
212
+ if session is None:
213
+ py_files = _find_python_files(project_path, excludes)
214
+ session = _ExtractionSession(
215
+ project_path=project_path,
216
+ py_files=py_files,
217
+ normalized_py_file_set={_normalize(path) for path in py_files},
218
+ ignore_type_checking_imports=ignore_type_checking_imports,
219
+ )
220
+ _extraction_sessions[cache_key] = session
221
+ return session
222
+
223
+
224
+ def _inspect_graph(graph: Graph) -> None:
225
+ if not debug_enabled():
226
+ return
227
+ debug("Extracted graph: %d edges (including self-edges)", len(graph))
228
+ for edge in graph:
229
+ debug(
230
+ "Dependency: %s -> %s; external=%s; kinds=%s",
231
+ edge.source,
232
+ edge.target,
233
+ edge.external,
234
+ edge.import_kinds,
235
+ )
236
+
237
+
114
238
  def _build_cache_key(
115
239
  project_path: str,
116
240
  exclude_patterns: list[str],
@@ -152,66 +276,59 @@ def _load_archignore_patterns(project_path: str) -> list[str]:
152
276
  return patterns
153
277
 
154
278
 
155
- def _extract_graph_uncached(
156
- project_path: str,
157
- exclude_patterns: list[str],
158
- *,
159
- ignore_type_checking_imports: bool = False,
160
- ) -> Graph:
161
- """Extract graph without caching."""
162
- py_files = _find_python_files(project_path, exclude_patterns)
163
-
279
+ def _extract_graph_from_files(session: _ExtractionSession, files: list[str]) -> Graph:
280
+ """Assemble a graph from cached or newly parsed file edges."""
164
281
  edges: list[Edge] = []
165
- py_files_set = set(py_files)
166
- normalized_py_file_set = {_normalize(f) for f in py_files_set}
167
-
168
- for file_path in py_files:
169
- # Add self-referencing edge (ensures the file appears as a node)
170
- edges.append(
171
- Edge(
172
- source=_normalize(file_path),
173
- target=_normalize(file_path),
174
- external=False,
175
- )
282
+ for file_path in files:
283
+ edges.extend(_extract_file_edges(session, file_path))
284
+ return _merge_edges(edges)
285
+
286
+
287
+ def _extract_file_edges(session: _ExtractionSession, file_path: str) -> list[Edge]:
288
+ cached = session.edges_by_file.get(file_path)
289
+ if cached is not None:
290
+ return cached
291
+
292
+ debug("Processing file: %s", file_path)
293
+ source_label = _normalize(file_path)
294
+ edges = [
295
+ Edge(
296
+ source=source_label,
297
+ target=source_label,
298
+ external=False,
176
299
  )
300
+ ]
177
301
 
178
- imports = _extract_located_imports(file_path)
179
- for located_import in imports:
180
- module_name = located_import.module_name
181
- import_kind = located_import.import_kind
182
- if (
183
- ignore_type_checking_imports
184
- and import_kind == ImportKind.TYPE_IMPORT
185
- ):
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
302
+ for located_import in _extract_located_imports(file_path):
303
+ if (
304
+ session.ignore_type_checking_imports
305
+ and located_import.import_kind == ImportKind.TYPE_IMPORT
306
+ ):
307
+ debug(
308
+ "Skipped type-only import: %s:%s -> %s",
309
+ file_path,
310
+ located_import.line_number,
311
+ located_import.module_name,
190
312
  )
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:
313
+ continue
314
+ for resolved, is_external in _resolve_import_targets(
315
+ located_import, file_path, session.project_path
316
+ ):
317
+ if resolved and resolved != source_label:
318
+ if not is_external and resolved not in session.normalized_py_file_set:
319
+ debug("Skipped excluded dependency: %s -> %s", file_path, resolved)
203
320
  continue
204
-
205
321
  edges.append(
206
322
  Edge(
207
- source=_normalize(file_path),
323
+ source=source_label,
208
324
  target=resolved,
209
325
  external=is_external,
210
326
  import_kinds=_edge_import_kinds(located_import),
211
327
  )
212
328
  )
213
329
 
214
- return _merge_edges(edges)
330
+ session.edges_by_file[file_path] = edges
331
+ return edges
215
332
 
216
333
 
217
334
  def _normalize(path: str) -> str:
@@ -223,6 +340,9 @@ def _find_python_files(root: str, exclude: list[str]) -> list[str]:
223
340
  """Recursively find all .py files, excluding specified patterns."""
224
341
  py_files: list[str] = []
225
342
  root = os.path.abspath(root)
343
+ # Defaults can only match directory names, never a .py filename. Keep
344
+ # checking user patterns against files, including .archignore entries.
345
+ file_excludes = [pattern for pattern in exclude if pattern not in _DEFAULT_EXCLUDE]
226
346
  for dirpath, dirnames, filenames in os.walk(root):
227
347
  # Filter out excluded directories in-place
228
348
  dirnames[:] = [
@@ -233,10 +353,11 @@ def _find_python_files(root: str, exclude: list[str]) -> list[str]:
233
353
 
234
354
  for filename in filenames:
235
355
  full_path = os.path.join(dirpath, filename)
236
- if filename.endswith(".py") and not _should_exclude_path(
237
- full_path, root, exclude, is_dir=False
356
+ if filename.endswith(".py") and not (
357
+ file_excludes and _should_exclude_path(full_path, root, file_excludes, is_dir=False)
238
358
  ):
239
359
  py_files.append(os.path.abspath(full_path))
360
+ debug("Discovered file: %s", full_path)
240
361
 
241
362
  return py_files
242
363
 
@@ -310,10 +431,11 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
310
431
 
311
432
  imports: list[_LocatedImport] = []
312
433
  ignore_directives = _find_ignore_directives(source)
313
- type_checking_ranges = _find_type_checking_ranges(tree)
314
- conditional_import_ranges = _find_conditional_import_ranges(tree)
434
+ nodes = list(ast.walk(tree))
435
+ type_checking_ranges = _find_type_checking_ranges(nodes)
436
+ conditional_import_ranges = _find_conditional_import_ranges(nodes)
315
437
 
316
- for node in ast.walk(tree):
438
+ for node in nodes:
317
439
  if isinstance(node, ast.Import):
318
440
  syntax_kind = ImportKind.IMPORT
319
441
  kind = _classify_import(
@@ -323,9 +445,7 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
323
445
  conditional_import_ranges,
324
446
  )
325
447
  for alias in node.names:
326
- imports.append(
327
- _LocatedImport(alias.name, kind, node.lineno, syntax_kind)
328
- )
448
+ imports.append(_LocatedImport(alias.name, kind, node.lineno, syntax_kind))
329
449
 
330
450
  elif isinstance(node, ast.ImportFrom):
331
451
  syntax_kind = (
@@ -339,17 +459,17 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
339
459
  type_checking_ranges,
340
460
  conditional_import_ranges,
341
461
  )
342
- fallback_module_name = (
343
- "." * node.level if node.level and node.module is None else None
344
- )
462
+ fallback_module_name = "." * node.level if node.level and node.module is None else None
463
+ aliases = _module_aliases(node) if node.module else ()
345
464
  for module_name in _import_from_module_names(node):
346
465
  imports.append(
347
466
  _LocatedImport(
348
- module_name,
349
- kind,
350
- node.lineno,
351
- syntax_kind,
352
- fallback_module_name,
467
+ module_name=module_name,
468
+ import_kind=kind,
469
+ line_number=node.lineno,
470
+ resolution_kind=syntax_kind,
471
+ fallback_module_name=fallback_module_name,
472
+ aliases=aliases,
353
473
  )
354
474
  )
355
475
 
@@ -362,15 +482,9 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
362
482
  conditional_import_ranges,
363
483
  )
364
484
  for module_name in _extract_dynamic_import_names(node):
365
- imports.append(
366
- _LocatedImport(module_name, kind, node.lineno, syntax_kind)
367
- )
485
+ imports.append(_LocatedImport(module_name, kind, node.lineno, syntax_kind))
368
486
 
369
- return [
370
- import_
371
- for import_ in imports
372
- if not _is_ignored_import(import_, ignore_directives)
373
- ]
487
+ return [import_ for import_ in imports if not _is_ignored_import(import_, ignore_directives)]
374
488
 
375
489
 
376
490
  def _find_ignore_directives(source: str) -> dict[int, _IgnoreDirective]:
@@ -429,6 +543,11 @@ def _extract_dynamic_import_names(node: ast.Call) -> list[str]:
429
543
  return []
430
544
 
431
545
 
546
+ def _module_aliases(node: ast.ImportFrom) -> tuple[str, ...]:
547
+ """Return aliases that may refer to imported submodules."""
548
+ return tuple(alias.name for alias in node.names if alias.name != "*")
549
+
550
+
432
551
  def _import_from_module_names(node: ast.ImportFrom) -> tuple[str, ...]:
433
552
  """Return resolvable module names for a from-import statement."""
434
553
  dots = "." * (node.level or 0)
@@ -466,11 +585,11 @@ def _classify_import(
466
585
  return default_kind
467
586
 
468
587
 
469
- def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
588
+ def _find_type_checking_ranges(nodes: list[ast.AST]) -> list[tuple[int, int]]:
470
589
  """Find line ranges of TYPE_CHECKING blocks."""
471
590
  ranges: list[tuple[int, int]] = []
472
591
 
473
- for node in ast.walk(tree):
592
+ for node in nodes:
474
593
  if isinstance(node, ast.If):
475
594
  # Check for `if TYPE_CHECKING:` pattern
476
595
  test = node.test
@@ -491,11 +610,11 @@ def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
491
610
  return sorted(ranges, key=lambda ele: ele[0])
492
611
 
493
612
 
494
- def _find_conditional_import_ranges(tree: ast.Module) -> list[tuple[int, int]]:
613
+ def _find_conditional_import_ranges(nodes: list[ast.AST]) -> list[tuple[int, int]]:
495
614
  """Find try/except ImportError ranges that contain optional imports."""
496
615
  ranges: list[tuple[int, int]] = []
497
616
 
498
- for node in ast.walk(tree):
617
+ for node in nodes:
499
618
  if not isinstance(node, ast.Try):
500
619
  continue
501
620
  if not any(_handles_import_error(handler.type) for handler in node.handlers):
@@ -558,14 +677,10 @@ def _resolve_import(
558
677
  Returns (resolved_path, is_external).
559
678
  The path is normalized with forward slashes.
560
679
  """
561
- if (
562
- kind
563
- in (
564
- ImportKind.RELATIVE_IMPORT,
565
- ImportKind.TYPE_IMPORT,
566
- )
567
- and import_name.startswith(".")
568
- ):
680
+ if kind in (
681
+ ImportKind.RELATIVE_IMPORT,
682
+ ImportKind.TYPE_IMPORT,
683
+ ) and import_name.startswith("."):
569
684
  # Relative import
570
685
  return _resolve_relative_import(import_name, source_file, project_root)
571
686
 
@@ -573,6 +688,58 @@ def _resolve_import(
573
688
  return _resolve_absolute_import(import_name, project_root)
574
689
 
575
690
 
691
+ def _resolve_import_targets(
692
+ import_: _LocatedImport,
693
+ source_file: str,
694
+ project_root: str,
695
+ ) -> list[tuple[str, bool]]:
696
+ """Resolve an import, including namespace-package submodule aliases."""
697
+ resolution_kind = import_.resolution_kind or import_.import_kind
698
+ resolved, is_external = _resolve_import(
699
+ import_.module_name,
700
+ source_file,
701
+ project_root,
702
+ resolution_kind,
703
+ )
704
+ if is_external and import_.fallback_module_name is not None:
705
+ fallback, fallback_is_external = _resolve_import(
706
+ import_.fallback_module_name,
707
+ source_file,
708
+ project_root,
709
+ resolution_kind,
710
+ )
711
+ if fallback and not fallback_is_external:
712
+ resolved, is_external = fallback, False
713
+ if not is_external or not import_.aliases:
714
+ return [(resolved, is_external)]
715
+
716
+ alias_targets: list[tuple[str, bool]] = []
717
+ found_internal_alias = False
718
+ for alias in import_.aliases:
719
+ alias_module = _join_import_alias(import_.module_name, alias)
720
+ alias_resolved, alias_is_external = _resolve_import(
721
+ alias_module,
722
+ source_file,
723
+ project_root,
724
+ resolution_kind,
725
+ )
726
+ alias_targets.append((alias_resolved, alias_is_external))
727
+ found_internal_alias = found_internal_alias or not alias_is_external
728
+
729
+ if found_internal_alias:
730
+ return alias_targets
731
+ return [(resolved, is_external)]
732
+
733
+
734
+ def _join_import_alias(module_name: str, alias: str) -> str:
735
+ """Join a from-import module name with a candidate submodule alias."""
736
+ if not module_name:
737
+ return alias
738
+ if set(module_name) == {"."}:
739
+ return f"{module_name}{alias}"
740
+ return f"{module_name}.{alias}"
741
+
742
+
576
743
  def _resolve_relative_import(
577
744
  import_name: str,
578
745
  source_file: str,
@@ -0,0 +1,79 @@
1
+ """Observational instrumentation for the existing check contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import wraps
6
+ from time import perf_counter
7
+ from typing import Callable, TypeVar, cast
8
+
9
+ from archunitpython.common.assertion.violation import Violation
10
+ from archunitpython.common.fluentapi.checkable import CheckOptions
11
+ from archunitpython.common.logging.inspection import InspectionSession, current_session
12
+
13
+ F = TypeVar("F", bound=Callable[..., list[Violation]])
14
+
15
+
16
+ def inspect_check(check: F) -> F:
17
+ """Trace a check while preserving its options, results, and exceptions."""
18
+
19
+ @wraps(check)
20
+ def inspected(self: object, options: CheckOptions | None = None) -> list[Violation]:
21
+ logging_options = options.logging if options else None
22
+ session = (
23
+ InspectionSession(logging_options)
24
+ if logging_options and logging_options.enabled
25
+ else None
26
+ )
27
+ token = current_session.set(session)
28
+ started = perf_counter() if session else 0.0
29
+ rule_name = type(self).__name__
30
+ try:
31
+ if session:
32
+ session.emit("info", "Starting check: %s", rule_name)
33
+ # Describe only known rule configuration, never user callbacks.
34
+ for name in (
35
+ "_project_path",
36
+ "_filters",
37
+ "_subject_filters",
38
+ "_object_filters",
39
+ "_module_filters",
40
+ "_pre_filters",
41
+ "_check_filters",
42
+ "_is_negated",
43
+ "_pattern",
44
+ "_regex",
45
+ "_source",
46
+ "_target",
47
+ "_layers",
48
+ "_allowed_dependencies",
49
+ "_forbidden_dependencies",
50
+ "_threshold",
51
+ "_comparison",
52
+ "_metric_attr",
53
+ "_name",
54
+ "_zone_type",
55
+ ):
56
+ if session.options.level == "debug" and name in vars(self):
57
+ session.emit("debug", "%s: %s", name.removeprefix("_"), vars(self)[name])
58
+ violations = check(self, options)
59
+ if session:
60
+ session.emit(
61
+ "warn" if violations else "info",
62
+ "Finished check: %s - %d violation(s) (%.3fs)",
63
+ rule_name,
64
+ len(violations),
65
+ perf_counter() - started,
66
+ )
67
+ for violation in violations:
68
+ session.emit("warn", "Violation: %s", violation)
69
+ return violations
70
+ except Exception as error:
71
+ if session:
72
+ session.emit("error", "Check failed: %s - %s", rule_name, error)
73
+ raise
74
+ finally:
75
+ current_session.reset(token)
76
+ if session:
77
+ session.close()
78
+
79
+ return cast(F, inspected)