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.
- archunitpython/__init__.py +3 -1
- archunitpython/common/extraction/extract_graph.py +256 -89
- archunitpython/common/fluentapi/inspection.py +79 -0
- archunitpython/common/logging/inspection.py +99 -0
- archunitpython/common/logging/types.py +2 -0
- archunitpython/common/pattern_matching.py +9 -2
- archunitpython/common/projection/project_edges.py +12 -1
- archunitpython/common/regex_factory.py +14 -0
- archunitpython/common/types.py +2 -1
- archunitpython/files/assertion/depend_on_files.py +14 -3
- archunitpython/files/fluentapi/files.py +10 -2
- archunitpython/layers/fluentapi/layers.py +2 -0
- archunitpython/metrics/extraction/extract_class_info.py +17 -0
- archunitpython/metrics/fluentapi/metrics.py +47 -0
- archunitpython/slices/fluentapi/slices.py +3 -0
- archunitpython/testing/assertion.py +16 -6
- archunitpython/testing/common/color_utils.py +12 -1
- {archunitpython-1.6.0.dist-info → archunitpython-1.8.1.dist-info}/METADATA +153 -6
- {archunitpython-1.6.0.dist-info → archunitpython-1.8.1.dist-info}/RECORD +21 -19
- {archunitpython-1.6.0.dist-info → archunitpython-1.8.1.dist-info}/WHEEL +1 -1
- {archunitpython-1.6.0.dist-info → archunitpython-1.8.1.dist-info}/licenses/LICENSE +0 -0
archunitpython/__init__.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""ArchUnitPython - Architecture testing library for Python projects."""
|
|
2
2
|
|
|
3
|
-
__version__ = "1.
|
|
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
|
-
|
|
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
|
|
166
|
+
ignore_type_checking_imports,
|
|
109
167
|
)
|
|
110
|
-
|
|
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
|
|
156
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
import_kind
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
|
237
|
-
full_path, root,
|
|
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
|
-
|
|
314
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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)
|