archunitpython 1.7.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 +199 -81
- 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.7.0.dist-info → archunitpython-1.8.1.dist-info}/METADATA +147 -6
- {archunitpython-1.7.0.dist-info → archunitpython-1.8.1.dist-info}/RECORD +21 -19
- {archunitpython-1.7.0.dist-info → archunitpython-1.8.1.dist-info}/WHEEL +1 -1
- {archunitpython-1.7.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
|
|
|
@@ -57,15 +60,28 @@ class _IgnoreDirective:
|
|
|
57
60
|
if not self.modules:
|
|
58
61
|
return True
|
|
59
62
|
return any(
|
|
60
|
-
import_.module_name == module
|
|
61
|
-
or import_.module_name.startswith(f"{module}.")
|
|
63
|
+
import_.module_name == module or import_.module_name.startswith(f"{module}.")
|
|
62
64
|
for module in self.modules
|
|
63
65
|
)
|
|
64
66
|
|
|
65
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
|
+
|
|
66
81
|
def clear_graph_cache(options: CheckOptions | None = None) -> None:
|
|
67
82
|
"""Clear the cached dependency graphs."""
|
|
68
83
|
_graph_cache.clear()
|
|
84
|
+
_extraction_sessions.clear()
|
|
69
85
|
|
|
70
86
|
|
|
71
87
|
def extract_graph(
|
|
@@ -97,21 +113,128 @@ def extract_graph(
|
|
|
97
113
|
ignore_type_checking_imports = bool(options and options.ignore_type_checking_imports)
|
|
98
114
|
cache_key = _build_cache_key(project_path, excludes, ignore_type_checking_imports)
|
|
99
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
|
+
)
|
|
100
122
|
if options and options.clear_cache:
|
|
123
|
+
debug("Clearing graph and extraction-session caches for %s", project_path)
|
|
101
124
|
_graph_cache.pop(cache_key, None)
|
|
125
|
+
_extraction_sessions.pop(cache_key, None)
|
|
102
126
|
|
|
103
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])
|
|
104
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.
|
|
105
149
|
|
|
106
|
-
|
|
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",
|
|
107
164
|
project_path,
|
|
108
165
|
excludes,
|
|
109
|
-
ignore_type_checking_imports
|
|
166
|
+
ignore_type_checking_imports,
|
|
110
167
|
)
|
|
111
|
-
|
|
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)
|
|
112
202
|
return result
|
|
113
203
|
|
|
114
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
|
+
|
|
115
238
|
def _build_cache_key(
|
|
116
239
|
project_path: str,
|
|
117
240
|
exclude_patterns: list[str],
|
|
@@ -153,55 +276,59 @@ def _load_archignore_patterns(project_path: str) -> list[str]:
|
|
|
153
276
|
return patterns
|
|
154
277
|
|
|
155
278
|
|
|
156
|
-
def
|
|
157
|
-
|
|
158
|
-
exclude_patterns: list[str],
|
|
159
|
-
*,
|
|
160
|
-
ignore_type_checking_imports: bool = False,
|
|
161
|
-
) -> Graph:
|
|
162
|
-
"""Extract graph without caching."""
|
|
163
|
-
py_files = _find_python_files(project_path, exclude_patterns)
|
|
164
|
-
|
|
279
|
+
def _extract_graph_from_files(session: _ExtractionSession, files: list[str]) -> Graph:
|
|
280
|
+
"""Assemble a graph from cached or newly parsed file edges."""
|
|
165
281
|
edges: list[Edge] = []
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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,
|
|
177
299
|
)
|
|
300
|
+
]
|
|
178
301
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
located_import,
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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,
|
|
312
|
+
)
|
|
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)
|
|
320
|
+
continue
|
|
321
|
+
edges.append(
|
|
322
|
+
Edge(
|
|
323
|
+
source=source_label,
|
|
324
|
+
target=resolved,
|
|
325
|
+
external=is_external,
|
|
326
|
+
import_kinds=_edge_import_kinds(located_import),
|
|
202
327
|
)
|
|
328
|
+
)
|
|
203
329
|
|
|
204
|
-
|
|
330
|
+
session.edges_by_file[file_path] = edges
|
|
331
|
+
return edges
|
|
205
332
|
|
|
206
333
|
|
|
207
334
|
def _normalize(path: str) -> str:
|
|
@@ -213,6 +340,9 @@ def _find_python_files(root: str, exclude: list[str]) -> list[str]:
|
|
|
213
340
|
"""Recursively find all .py files, excluding specified patterns."""
|
|
214
341
|
py_files: list[str] = []
|
|
215
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]
|
|
216
346
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
217
347
|
# Filter out excluded directories in-place
|
|
218
348
|
dirnames[:] = [
|
|
@@ -223,10 +353,11 @@ def _find_python_files(root: str, exclude: list[str]) -> list[str]:
|
|
|
223
353
|
|
|
224
354
|
for filename in filenames:
|
|
225
355
|
full_path = os.path.join(dirpath, filename)
|
|
226
|
-
if filename.endswith(".py") and not
|
|
227
|
-
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)
|
|
228
358
|
):
|
|
229
359
|
py_files.append(os.path.abspath(full_path))
|
|
360
|
+
debug("Discovered file: %s", full_path)
|
|
230
361
|
|
|
231
362
|
return py_files
|
|
232
363
|
|
|
@@ -300,10 +431,11 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
|
|
|
300
431
|
|
|
301
432
|
imports: list[_LocatedImport] = []
|
|
302
433
|
ignore_directives = _find_ignore_directives(source)
|
|
303
|
-
|
|
304
|
-
|
|
434
|
+
nodes = list(ast.walk(tree))
|
|
435
|
+
type_checking_ranges = _find_type_checking_ranges(nodes)
|
|
436
|
+
conditional_import_ranges = _find_conditional_import_ranges(nodes)
|
|
305
437
|
|
|
306
|
-
for node in
|
|
438
|
+
for node in nodes:
|
|
307
439
|
if isinstance(node, ast.Import):
|
|
308
440
|
syntax_kind = ImportKind.IMPORT
|
|
309
441
|
kind = _classify_import(
|
|
@@ -313,9 +445,7 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
|
|
|
313
445
|
conditional_import_ranges,
|
|
314
446
|
)
|
|
315
447
|
for alias in node.names:
|
|
316
|
-
imports.append(
|
|
317
|
-
_LocatedImport(alias.name, kind, node.lineno, syntax_kind)
|
|
318
|
-
)
|
|
448
|
+
imports.append(_LocatedImport(alias.name, kind, node.lineno, syntax_kind))
|
|
319
449
|
|
|
320
450
|
elif isinstance(node, ast.ImportFrom):
|
|
321
451
|
syntax_kind = (
|
|
@@ -329,9 +459,7 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
|
|
|
329
459
|
type_checking_ranges,
|
|
330
460
|
conditional_import_ranges,
|
|
331
461
|
)
|
|
332
|
-
fallback_module_name =
|
|
333
|
-
"." * node.level if node.level and node.module is None else None
|
|
334
|
-
)
|
|
462
|
+
fallback_module_name = "." * node.level if node.level and node.module is None else None
|
|
335
463
|
aliases = _module_aliases(node) if node.module else ()
|
|
336
464
|
for module_name in _import_from_module_names(node):
|
|
337
465
|
imports.append(
|
|
@@ -354,15 +482,9 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
|
|
|
354
482
|
conditional_import_ranges,
|
|
355
483
|
)
|
|
356
484
|
for module_name in _extract_dynamic_import_names(node):
|
|
357
|
-
imports.append(
|
|
358
|
-
_LocatedImport(module_name, kind, node.lineno, syntax_kind)
|
|
359
|
-
)
|
|
485
|
+
imports.append(_LocatedImport(module_name, kind, node.lineno, syntax_kind))
|
|
360
486
|
|
|
361
|
-
return [
|
|
362
|
-
import_
|
|
363
|
-
for import_ in imports
|
|
364
|
-
if not _is_ignored_import(import_, ignore_directives)
|
|
365
|
-
]
|
|
487
|
+
return [import_ for import_ in imports if not _is_ignored_import(import_, ignore_directives)]
|
|
366
488
|
|
|
367
489
|
|
|
368
490
|
def _find_ignore_directives(source: str) -> dict[int, _IgnoreDirective]:
|
|
@@ -463,11 +585,11 @@ def _classify_import(
|
|
|
463
585
|
return default_kind
|
|
464
586
|
|
|
465
587
|
|
|
466
|
-
def _find_type_checking_ranges(
|
|
588
|
+
def _find_type_checking_ranges(nodes: list[ast.AST]) -> list[tuple[int, int]]:
|
|
467
589
|
"""Find line ranges of TYPE_CHECKING blocks."""
|
|
468
590
|
ranges: list[tuple[int, int]] = []
|
|
469
591
|
|
|
470
|
-
for node in
|
|
592
|
+
for node in nodes:
|
|
471
593
|
if isinstance(node, ast.If):
|
|
472
594
|
# Check for `if TYPE_CHECKING:` pattern
|
|
473
595
|
test = node.test
|
|
@@ -488,11 +610,11 @@ def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
|
|
|
488
610
|
return sorted(ranges, key=lambda ele: ele[0])
|
|
489
611
|
|
|
490
612
|
|
|
491
|
-
def _find_conditional_import_ranges(
|
|
613
|
+
def _find_conditional_import_ranges(nodes: list[ast.AST]) -> list[tuple[int, int]]:
|
|
492
614
|
"""Find try/except ImportError ranges that contain optional imports."""
|
|
493
615
|
ranges: list[tuple[int, int]] = []
|
|
494
616
|
|
|
495
|
-
for node in
|
|
617
|
+
for node in nodes:
|
|
496
618
|
if not isinstance(node, ast.Try):
|
|
497
619
|
continue
|
|
498
620
|
if not any(_handles_import_error(handler.type) for handler in node.handlers):
|
|
@@ -555,14 +677,10 @@ def _resolve_import(
|
|
|
555
677
|
Returns (resolved_path, is_external).
|
|
556
678
|
The path is normalized with forward slashes.
|
|
557
679
|
"""
|
|
558
|
-
if (
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
ImportKind.TYPE_IMPORT,
|
|
563
|
-
)
|
|
564
|
-
and import_name.startswith(".")
|
|
565
|
-
):
|
|
680
|
+
if kind in (
|
|
681
|
+
ImportKind.RELATIVE_IMPORT,
|
|
682
|
+
ImportKind.TYPE_IMPORT,
|
|
683
|
+
) and import_name.startswith("."):
|
|
566
684
|
# Relative import
|
|
567
685
|
return _resolve_relative_import(import_name, source_file, project_root)
|
|
568
686
|
|
|
@@ -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)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Per-check diagnostic sessions, isolated from architecture evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TextIO
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from archunitpython.common.logging.types import LoggingOptions
|
|
14
|
+
|
|
15
|
+
_LEVELS = {
|
|
16
|
+
"debug": logging.DEBUG,
|
|
17
|
+
"info": logging.INFO,
|
|
18
|
+
"warn": logging.WARNING,
|
|
19
|
+
"error": logging.ERROR,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InspectionSession:
|
|
24
|
+
"""Own a check's output without attaching process-wide logging handlers.
|
|
25
|
+
|
|
26
|
+
Diagnostics are best effort: formatting and output errors must never replace
|
|
27
|
+
a rule result or an exception raised by the analysis itself.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, options: LoggingOptions) -> None:
|
|
31
|
+
self.options = options
|
|
32
|
+
self._file: TextIO | None = None
|
|
33
|
+
self._file_attempted = False
|
|
34
|
+
|
|
35
|
+
def emit(self, level: str, message: str, *args: object) -> None:
|
|
36
|
+
if _LEVELS[level] < _LEVELS.get(self.options.level, logging.INFO):
|
|
37
|
+
return
|
|
38
|
+
try:
|
|
39
|
+
text = message % args if args else message
|
|
40
|
+
except Exception:
|
|
41
|
+
return
|
|
42
|
+
if self.options.console:
|
|
43
|
+
try:
|
|
44
|
+
logger = logging.getLogger("archunitpython")
|
|
45
|
+
if logger.hasHandlers():
|
|
46
|
+
logger.log(_LEVELS[level], text)
|
|
47
|
+
else:
|
|
48
|
+
sys.stderr.write(f"[{level.upper()}] {text}\n")
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
if self.options.log_file:
|
|
52
|
+
try:
|
|
53
|
+
if not self._file_attempted:
|
|
54
|
+
self._file_attempted = True
|
|
55
|
+
path = (
|
|
56
|
+
Path(self.options.log_path)
|
|
57
|
+
if self.options.log_path
|
|
58
|
+
else Path("logs")
|
|
59
|
+
/ (
|
|
60
|
+
datetime.now().strftime("archunit-%Y-%m-%d_%H-%M-%S-")
|
|
61
|
+
+ uuid4().hex
|
|
62
|
+
+ ".log"
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
self._file = path.open(
|
|
67
|
+
"a" if self.options.append_to_log_file else "w", encoding="utf-8"
|
|
68
|
+
)
|
|
69
|
+
if self._file is not None:
|
|
70
|
+
self._file.write(f"[{datetime.now().isoformat()}] [{level.upper()}] {text}\n")
|
|
71
|
+
self._file.flush()
|
|
72
|
+
except Exception:
|
|
73
|
+
self.close()
|
|
74
|
+
|
|
75
|
+
def close(self) -> None:
|
|
76
|
+
if self._file is not None:
|
|
77
|
+
try:
|
|
78
|
+
self._file.close()
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
self._file = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
current_session: ContextVar[InspectionSession | None] = ContextVar(
|
|
85
|
+
"archunitpython_inspection", default=None
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def debug(message: str, *args: object) -> None:
|
|
90
|
+
"""Lazily emit details only inside an enabled debug check."""
|
|
91
|
+
session = current_session.get()
|
|
92
|
+
if session is not None:
|
|
93
|
+
session.emit("debug", message, *args)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def debug_enabled() -> bool:
|
|
97
|
+
"""Guard expensive diagnostic traversal before constructing its details."""
|
|
98
|
+
session = current_session.get()
|
|
99
|
+
return session is not None and session.options.level == "debug"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from archunitpython.common.logging.inspection import debug
|
|
5
6
|
from archunitpython.common.types import Filter
|
|
6
7
|
|
|
7
8
|
|
|
@@ -47,7 +48,10 @@ def matches_pattern(file_path: str, filter_: Filter) -> bool:
|
|
|
47
48
|
else:
|
|
48
49
|
target_string = normalize_path(file_path)
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
regexp = filter_.search_regexp or filter_.regexp
|
|
52
|
+
matched = bool(regexp.search(target_string))
|
|
53
|
+
debug("Selector %s against %s (%s): %s", filter_.regexp.pattern, target_string, target, matched)
|
|
54
|
+
return matched
|
|
51
55
|
|
|
52
56
|
|
|
53
57
|
def matches_pattern_classname(class_name: str, file_path: str, filter_: Filter) -> bool:
|
|
@@ -65,7 +69,10 @@ def matches_pattern_classname(class_name: str, file_path: str, filter_: Filter)
|
|
|
65
69
|
else:
|
|
66
70
|
target_string = normalize_path(file_path)
|
|
67
71
|
|
|
68
|
-
|
|
72
|
+
regexp = filter_.search_regexp or filter_.regexp
|
|
73
|
+
matched = bool(regexp.search(target_string))
|
|
74
|
+
debug("Selector %s against %s (%s): %s", filter_.regexp.pattern, target_string, target, matched)
|
|
75
|
+
return matched
|
|
69
76
|
|
|
70
77
|
|
|
71
78
|
def matches_all_patterns(file_path: str, filters: list[Filter]) -> bool:
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from collections import defaultdict
|
|
6
6
|
|
|
7
7
|
from archunitpython.common.extraction.graph import Edge
|
|
8
|
+
from archunitpython.common.logging.inspection import debug
|
|
8
9
|
from archunitpython.common.projection.types import MapFunction, ProjectedEdge
|
|
9
10
|
|
|
10
11
|
|
|
@@ -29,11 +30,19 @@ def project_edges(
|
|
|
29
30
|
for edge in graph:
|
|
30
31
|
mapped = mapper(edge)
|
|
31
32
|
if mapped is None:
|
|
33
|
+
debug("Projection omitted: %s -> %s", edge.source, edge.target)
|
|
32
34
|
continue
|
|
35
|
+
debug(
|
|
36
|
+
"Projection: %s -> %s becomes %s -> %s",
|
|
37
|
+
edge.source,
|
|
38
|
+
edge.target,
|
|
39
|
+
mapped.source_label,
|
|
40
|
+
mapped.target_label,
|
|
41
|
+
)
|
|
33
42
|
key = (mapped.source_label, mapped.target_label)
|
|
34
43
|
groups[key].append(edge)
|
|
35
44
|
|
|
36
|
-
|
|
45
|
+
projected = [
|
|
37
46
|
ProjectedEdge(
|
|
38
47
|
source_label=source_label,
|
|
39
48
|
target_label=target_label,
|
|
@@ -41,3 +50,5 @@ def project_edges(
|
|
|
41
50
|
)
|
|
42
51
|
for (source_label, target_label), edges in groups.items()
|
|
43
52
|
]
|
|
53
|
+
debug("Projection complete: %d raw edges -> %d projected edges", len(graph), len(projected))
|
|
54
|
+
return projected
|