archunitpython 1.7.0__py3-none-any.whl → 1.8.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.
- archunitpython/__init__.py +3 -1
- archunitpython/common/extraction/extract_graph.py +45 -28
- 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 +7 -2
- archunitpython/common/projection/project_edges.py +12 -1
- archunitpython/files/fluentapi/files.py +6 -0
- 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.0.dist-info}/METADATA +75 -5
- {archunitpython-1.7.0.dist-info → archunitpython-1.8.0.dist-info}/RECORD +18 -16
- {archunitpython-1.7.0.dist-info → archunitpython-1.8.0.dist-info}/WHEEL +1 -1
- {archunitpython-1.7.0.dist-info → archunitpython-1.8.0.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.0"
|
|
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",
|
|
@@ -9,6 +9,7 @@ from dataclasses import dataclass
|
|
|
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
|
|
12
13
|
|
|
13
14
|
GraphCacheKey = tuple[str, tuple[str, ...], bool]
|
|
14
15
|
|
|
@@ -57,8 +58,7 @@ class _IgnoreDirective:
|
|
|
57
58
|
if not self.modules:
|
|
58
59
|
return True
|
|
59
60
|
return any(
|
|
60
|
-
import_.module_name == module
|
|
61
|
-
or import_.module_name.startswith(f"{module}.")
|
|
61
|
+
import_.module_name == module or import_.module_name.startswith(f"{module}.")
|
|
62
62
|
for module in self.modules
|
|
63
63
|
)
|
|
64
64
|
|
|
@@ -97,21 +97,46 @@ def extract_graph(
|
|
|
97
97
|
ignore_type_checking_imports = bool(options and options.ignore_type_checking_imports)
|
|
98
98
|
cache_key = _build_cache_key(project_path, excludes, ignore_type_checking_imports)
|
|
99
99
|
|
|
100
|
+
debug(
|
|
101
|
+
"Extraction root: %s; excludes: %s; ignore type-only imports: %s",
|
|
102
|
+
project_path,
|
|
103
|
+
excludes,
|
|
104
|
+
ignore_type_checking_imports,
|
|
105
|
+
)
|
|
100
106
|
if options and options.clear_cache:
|
|
107
|
+
debug("Clearing graph cache for %s", project_path)
|
|
101
108
|
_graph_cache.pop(cache_key, None)
|
|
102
109
|
|
|
103
110
|
if cache_key in _graph_cache:
|
|
111
|
+
debug("Graph cache hit: %d edges", len(_graph_cache[cache_key]))
|
|
112
|
+
_inspect_graph(_graph_cache[cache_key])
|
|
104
113
|
return _graph_cache[cache_key]
|
|
114
|
+
debug("Graph cache miss")
|
|
105
115
|
|
|
106
116
|
result = _extract_graph_uncached(
|
|
107
117
|
project_path,
|
|
108
118
|
excludes,
|
|
109
119
|
ignore_type_checking_imports=ignore_type_checking_imports,
|
|
110
120
|
)
|
|
121
|
+
_inspect_graph(result)
|
|
111
122
|
_graph_cache[cache_key] = result
|
|
112
123
|
return result
|
|
113
124
|
|
|
114
125
|
|
|
126
|
+
def _inspect_graph(graph: Graph) -> None:
|
|
127
|
+
if not debug_enabled():
|
|
128
|
+
return
|
|
129
|
+
debug("Extracted graph: %d edges (including self-edges)", len(graph))
|
|
130
|
+
for edge in graph:
|
|
131
|
+
debug(
|
|
132
|
+
"Dependency: %s -> %s; external=%s; kinds=%s",
|
|
133
|
+
edge.source,
|
|
134
|
+
edge.target,
|
|
135
|
+
edge.external,
|
|
136
|
+
edge.import_kinds,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
115
140
|
def _build_cache_key(
|
|
116
141
|
project_path: str,
|
|
117
142
|
exclude_patterns: list[str],
|
|
@@ -167,6 +192,7 @@ def _extract_graph_uncached(
|
|
|
167
192
|
normalized_py_file_set = {_normalize(f) for f in py_files_set}
|
|
168
193
|
|
|
169
194
|
for file_path in py_files:
|
|
195
|
+
debug("Processing file: %s", file_path)
|
|
170
196
|
# Add self-referencing edge (ensures the file appears as a node)
|
|
171
197
|
edges.append(
|
|
172
198
|
Edge(
|
|
@@ -179,10 +205,13 @@ def _extract_graph_uncached(
|
|
|
179
205
|
imports = _extract_located_imports(file_path)
|
|
180
206
|
for located_import in imports:
|
|
181
207
|
import_kind = located_import.import_kind
|
|
182
|
-
if
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
208
|
+
if ignore_type_checking_imports and import_kind == ImportKind.TYPE_IMPORT:
|
|
209
|
+
debug(
|
|
210
|
+
"Skipped type-only import: %s:%s -> %s",
|
|
211
|
+
file_path,
|
|
212
|
+
located_import.line_number,
|
|
213
|
+
located_import.module_name,
|
|
214
|
+
)
|
|
186
215
|
continue
|
|
187
216
|
for resolved, is_external in _resolve_import_targets(
|
|
188
217
|
located_import, file_path, project_path
|
|
@@ -190,6 +219,7 @@ def _extract_graph_uncached(
|
|
|
190
219
|
if resolved and resolved != _normalize(file_path):
|
|
191
220
|
# Check if the resolved path is in our project
|
|
192
221
|
if not is_external and resolved not in normalized_py_file_set:
|
|
222
|
+
debug("Skipped excluded dependency: %s -> %s", file_path, resolved)
|
|
193
223
|
continue
|
|
194
224
|
|
|
195
225
|
edges.append(
|
|
@@ -227,6 +257,7 @@ def _find_python_files(root: str, exclude: list[str]) -> list[str]:
|
|
|
227
257
|
full_path, root, exclude, is_dir=False
|
|
228
258
|
):
|
|
229
259
|
py_files.append(os.path.abspath(full_path))
|
|
260
|
+
debug("Discovered file: %s", full_path)
|
|
230
261
|
|
|
231
262
|
return py_files
|
|
232
263
|
|
|
@@ -313,9 +344,7 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
|
|
|
313
344
|
conditional_import_ranges,
|
|
314
345
|
)
|
|
315
346
|
for alias in node.names:
|
|
316
|
-
imports.append(
|
|
317
|
-
_LocatedImport(alias.name, kind, node.lineno, syntax_kind)
|
|
318
|
-
)
|
|
347
|
+
imports.append(_LocatedImport(alias.name, kind, node.lineno, syntax_kind))
|
|
319
348
|
|
|
320
349
|
elif isinstance(node, ast.ImportFrom):
|
|
321
350
|
syntax_kind = (
|
|
@@ -329,9 +358,7 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
|
|
|
329
358
|
type_checking_ranges,
|
|
330
359
|
conditional_import_ranges,
|
|
331
360
|
)
|
|
332
|
-
fallback_module_name =
|
|
333
|
-
"." * node.level if node.level and node.module is None else None
|
|
334
|
-
)
|
|
361
|
+
fallback_module_name = "." * node.level if node.level and node.module is None else None
|
|
335
362
|
aliases = _module_aliases(node) if node.module else ()
|
|
336
363
|
for module_name in _import_from_module_names(node):
|
|
337
364
|
imports.append(
|
|
@@ -354,15 +381,9 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
|
|
|
354
381
|
conditional_import_ranges,
|
|
355
382
|
)
|
|
356
383
|
for module_name in _extract_dynamic_import_names(node):
|
|
357
|
-
imports.append(
|
|
358
|
-
_LocatedImport(module_name, kind, node.lineno, syntax_kind)
|
|
359
|
-
)
|
|
384
|
+
imports.append(_LocatedImport(module_name, kind, node.lineno, syntax_kind))
|
|
360
385
|
|
|
361
|
-
return [
|
|
362
|
-
import_
|
|
363
|
-
for import_ in imports
|
|
364
|
-
if not _is_ignored_import(import_, ignore_directives)
|
|
365
|
-
]
|
|
386
|
+
return [import_ for import_ in imports if not _is_ignored_import(import_, ignore_directives)]
|
|
366
387
|
|
|
367
388
|
|
|
368
389
|
def _find_ignore_directives(source: str) -> dict[int, _IgnoreDirective]:
|
|
@@ -555,14 +576,10 @@ def _resolve_import(
|
|
|
555
576
|
Returns (resolved_path, is_external).
|
|
556
577
|
The path is normalized with forward slashes.
|
|
557
578
|
"""
|
|
558
|
-
if (
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
ImportKind.TYPE_IMPORT,
|
|
563
|
-
)
|
|
564
|
-
and import_name.startswith(".")
|
|
565
|
-
):
|
|
579
|
+
if kind in (
|
|
580
|
+
ImportKind.RELATIVE_IMPORT,
|
|
581
|
+
ImportKind.TYPE_IMPORT,
|
|
582
|
+
) and import_name.startswith("."):
|
|
566
583
|
# Relative import
|
|
567
584
|
return _resolve_relative_import(import_name, source_file, project_root)
|
|
568
585
|
|
|
@@ -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,9 @@ def matches_pattern(file_path: str, filter_: Filter) -> bool:
|
|
|
47
48
|
else:
|
|
48
49
|
target_string = normalize_path(file_path)
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
matched = bool(filter_.regexp.search(target_string))
|
|
52
|
+
debug("Selector %s against %s (%s): %s", filter_.regexp.pattern, target_string, target, matched)
|
|
53
|
+
return matched
|
|
51
54
|
|
|
52
55
|
|
|
53
56
|
def matches_pattern_classname(class_name: str, file_path: str, filter_: Filter) -> bool:
|
|
@@ -65,7 +68,9 @@ def matches_pattern_classname(class_name: str, file_path: str, filter_: Filter)
|
|
|
65
68
|
else:
|
|
66
69
|
target_string = normalize_path(file_path)
|
|
67
70
|
|
|
68
|
-
|
|
71
|
+
matched = bool(filter_.regexp.search(target_string))
|
|
72
|
+
debug("Selector %s against %s (%s): %s", filter_.regexp.pattern, target_string, target, matched)
|
|
73
|
+
return matched
|
|
69
74
|
|
|
70
75
|
|
|
71
76
|
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
|
|
@@ -15,6 +15,7 @@ from collections.abc import Sequence
|
|
|
15
15
|
from archunitpython.common.assertion.violation import EmptyTestViolation, Violation
|
|
16
16
|
from archunitpython.common.extraction.extract_graph import extract_graph
|
|
17
17
|
from archunitpython.common.fluentapi.checkable import CheckOptions, RuleRationaleMixin
|
|
18
|
+
from archunitpython.common.fluentapi.inspection import inspect_check
|
|
18
19
|
from archunitpython.common.pattern_matching import matches_all_patterns
|
|
19
20
|
from archunitpython.common.projection.edge_projections import (
|
|
20
21
|
per_external_edge,
|
|
@@ -329,6 +330,7 @@ class CycleFreeFileCondition(RuleRationaleMixin):
|
|
|
329
330
|
self._project_path = project_path
|
|
330
331
|
self._filters = filters
|
|
331
332
|
|
|
333
|
+
@inspect_check
|
|
332
334
|
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
333
335
|
graph = extract_graph(self._project_path, options=options)
|
|
334
336
|
edges = project_edges(graph, per_internal_edge())
|
|
@@ -365,6 +367,7 @@ class DependOnFileCondition(RuleRationaleMixin):
|
|
|
365
367
|
self._object_filters = object_filters
|
|
366
368
|
self._is_negated = is_negated
|
|
367
369
|
|
|
370
|
+
@inspect_check
|
|
368
371
|
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
369
372
|
graph = extract_graph(self._project_path, options=options)
|
|
370
373
|
edges = project_edges(graph, per_internal_edge())
|
|
@@ -397,6 +400,7 @@ class DependOnExternalModuleCondition(RuleRationaleMixin):
|
|
|
397
400
|
self._module_filters.append(RegexFactory.path_matcher(module_name))
|
|
398
401
|
return self
|
|
399
402
|
|
|
403
|
+
@inspect_check
|
|
400
404
|
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
401
405
|
graph = extract_graph(self._project_path, options=options)
|
|
402
406
|
edges = project_edges(graph, per_external_edge())
|
|
@@ -424,6 +428,7 @@ class MatchPatternFileCondition(RuleRationaleMixin):
|
|
|
424
428
|
self._check_filters = check_filters
|
|
425
429
|
self._is_negated = is_negated
|
|
426
430
|
|
|
431
|
+
@inspect_check
|
|
427
432
|
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
428
433
|
nodes = _get_filtered_nodes(self._project_path, self._pre_filters, options)
|
|
429
434
|
|
|
@@ -451,6 +456,7 @@ class CustomFileCheckableCondition(RuleRationaleMixin):
|
|
|
451
456
|
self._message = message
|
|
452
457
|
self._is_negated = is_negated
|
|
453
458
|
|
|
459
|
+
@inspect_check
|
|
454
460
|
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
455
461
|
graph = extract_graph(self._project_path, options=options)
|
|
456
462
|
nodes = project_to_nodes(graph)
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from archunitpython.common.assertion.violation import Violation
|
|
6
6
|
from archunitpython.common.extraction.extract_graph import extract_graph
|
|
7
7
|
from archunitpython.common.fluentapi.checkable import CheckOptions
|
|
8
|
+
from archunitpython.common.fluentapi.inspection import inspect_check
|
|
8
9
|
from archunitpython.common.projection.edge_projections import per_internal_edge
|
|
9
10
|
from archunitpython.common.projection.project_edges import project_edges
|
|
10
11
|
from archunitpython.common.regex_factory import RegexFactory
|
|
@@ -42,6 +43,7 @@ class LayeredArchitecture:
|
|
|
42
43
|
self._layers.setdefault(name, [])
|
|
43
44
|
return LayerDependencyRuleBuilder(self, name)
|
|
44
45
|
|
|
46
|
+
@inspect_check
|
|
45
47
|
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
46
48
|
graph = extract_graph(self._project_path, options=options)
|
|
47
49
|
edges = project_edges(graph, per_internal_edge())
|
|
@@ -9,6 +9,7 @@ from archunitpython.common.extraction.extract_graph import (
|
|
|
9
9
|
_find_python_files,
|
|
10
10
|
_resolve_exclude_patterns,
|
|
11
11
|
)
|
|
12
|
+
from archunitpython.common.logging.inspection import debug
|
|
12
13
|
from archunitpython.metrics.common.types import (
|
|
13
14
|
ClassInfo,
|
|
14
15
|
EnhancedClassInfo,
|
|
@@ -41,6 +42,7 @@ def extract_class_info(
|
|
|
41
42
|
|
|
42
43
|
classes: list[ClassInfo] = []
|
|
43
44
|
for file_path in py_files:
|
|
45
|
+
debug("Analyzing classes in: %s", file_path)
|
|
44
46
|
classes.extend(_process_source_file(file_path))
|
|
45
47
|
|
|
46
48
|
return classes
|
|
@@ -61,6 +63,7 @@ def extract_enhanced_class_info(
|
|
|
61
63
|
|
|
62
64
|
results: list[FileAnalysisResult] = []
|
|
63
65
|
for file_path in py_files:
|
|
66
|
+
debug("Analyzing classes in: %s", file_path)
|
|
64
67
|
result = _process_source_file_enhanced(file_path)
|
|
65
68
|
if result.classes:
|
|
66
69
|
results.append(result)
|
|
@@ -88,6 +91,13 @@ def _process_source_file(file_path: str) -> list[ClassInfo]:
|
|
|
88
91
|
if isinstance(node, ast.ClassDef):
|
|
89
92
|
class_info = _extract_class(node, norm_path)
|
|
90
93
|
classes.append(class_info)
|
|
94
|
+
debug(
|
|
95
|
+
"Class: %s in %s; %d methods; %d fields",
|
|
96
|
+
class_info.name,
|
|
97
|
+
norm_path,
|
|
98
|
+
len(class_info.methods),
|
|
99
|
+
len(class_info.fields),
|
|
100
|
+
)
|
|
91
101
|
|
|
92
102
|
return classes
|
|
93
103
|
|
|
@@ -112,6 +122,13 @@ def _process_source_file_enhanced(file_path: str) -> FileAnalysisResult:
|
|
|
112
122
|
if isinstance(node, ast.ClassDef):
|
|
113
123
|
enhanced = _extract_enhanced_class(node, norm_path)
|
|
114
124
|
result.classes.append(enhanced)
|
|
125
|
+
debug(
|
|
126
|
+
"Enhanced class: %s in %s; abstract=%s; protocol=%s",
|
|
127
|
+
enhanced.name,
|
|
128
|
+
norm_path,
|
|
129
|
+
enhanced.is_abstract,
|
|
130
|
+
enhanced.is_protocol,
|
|
131
|
+
)
|
|
115
132
|
result.total_types += 1
|
|
116
133
|
if enhanced.is_protocol:
|
|
117
134
|
result.protocols += 1
|
|
@@ -12,6 +12,8 @@ from typing import Any, Callable
|
|
|
12
12
|
|
|
13
13
|
from archunitpython.common.assertion.violation import Violation
|
|
14
14
|
from archunitpython.common.fluentapi.checkable import CheckOptions, RuleRationaleMixin
|
|
15
|
+
from archunitpython.common.fluentapi.inspection import inspect_check
|
|
16
|
+
from archunitpython.common.logging.inspection import debug, debug_enabled
|
|
15
17
|
from archunitpython.common.pattern_matching import matches_pattern_classname
|
|
16
18
|
from archunitpython.common.regex_factory import RegexFactory
|
|
17
19
|
from archunitpython.common.types import Filter, Pattern
|
|
@@ -97,6 +99,20 @@ class MetricsBuilder:
|
|
|
97
99
|
)
|
|
98
100
|
|
|
99
101
|
|
|
102
|
+
def _log_metric_value(
|
|
103
|
+
metric: Any, subject: str, value: float, threshold: float, comparison: MetricComparison
|
|
104
|
+
) -> None:
|
|
105
|
+
if not debug_enabled():
|
|
106
|
+
return
|
|
107
|
+
try:
|
|
108
|
+
name = metric.name
|
|
109
|
+
except Exception:
|
|
110
|
+
name = type(metric).__name__
|
|
111
|
+
debug(
|
|
112
|
+
"Metric %s for %s: value=%s; threshold=%s (%s)", name, subject, value, threshold, comparison
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
100
116
|
def _get_filtered_classes(project_path: str | None, filters: list[Filter]) -> list[ClassInfo]:
|
|
101
117
|
classes = extract_class_info(project_path)
|
|
102
118
|
if not filters:
|
|
@@ -187,12 +203,20 @@ class ClassMetricCondition(RuleRationaleMixin):
|
|
|
187
203
|
self._threshold = threshold
|
|
188
204
|
self._comparison = comparison
|
|
189
205
|
|
|
206
|
+
@inspect_check
|
|
190
207
|
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
191
208
|
classes = _get_filtered_classes(self._project_path, self._filters)
|
|
192
209
|
violations: list[Violation] = []
|
|
193
210
|
|
|
194
211
|
for cls in classes:
|
|
195
212
|
value = self._metric.calculate(cls)
|
|
213
|
+
_log_metric_value(
|
|
214
|
+
self._metric,
|
|
215
|
+
cls.name,
|
|
216
|
+
value,
|
|
217
|
+
self._threshold,
|
|
218
|
+
self._comparison,
|
|
219
|
+
)
|
|
196
220
|
if check_threshold(value, self._threshold, self._comparison):
|
|
197
221
|
violations.append(
|
|
198
222
|
MetricViolation(
|
|
@@ -247,6 +271,7 @@ class FileMetricCondition(RuleRationaleMixin):
|
|
|
247
271
|
self._threshold = threshold
|
|
248
272
|
self._comparison: MetricComparison = comparison
|
|
249
273
|
|
|
274
|
+
@inspect_check
|
|
250
275
|
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
251
276
|
import os
|
|
252
277
|
|
|
@@ -268,6 +293,13 @@ class FileMetricCondition(RuleRationaleMixin):
|
|
|
268
293
|
continue
|
|
269
294
|
|
|
270
295
|
value = self._metric.calculate_from_file(file_path)
|
|
296
|
+
_log_metric_value(
|
|
297
|
+
self._metric,
|
|
298
|
+
file_path,
|
|
299
|
+
value,
|
|
300
|
+
self._threshold,
|
|
301
|
+
self._comparison,
|
|
302
|
+
)
|
|
271
303
|
if check_threshold(value, self._threshold, self._comparison):
|
|
272
304
|
violations.append(
|
|
273
305
|
FileCountViolation(
|
|
@@ -381,6 +413,7 @@ class DistanceCondition(RuleRationaleMixin):
|
|
|
381
413
|
self._threshold = threshold
|
|
382
414
|
self._comparison: MetricComparison = comparison
|
|
383
415
|
|
|
416
|
+
@inspect_check
|
|
384
417
|
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
385
418
|
files = extract_enhanced_class_info(self._project_path)
|
|
386
419
|
violations: list[Violation] = []
|
|
@@ -388,6 +421,14 @@ class DistanceCondition(RuleRationaleMixin):
|
|
|
388
421
|
for file_result in files:
|
|
389
422
|
dm = calculate_file_distance_metrics(file_result, files)
|
|
390
423
|
value = getattr(dm, self._metric_attr)
|
|
424
|
+
debug(
|
|
425
|
+
"Metric %s for %s: value=%s; threshold=%s (%s)",
|
|
426
|
+
self._metric_attr,
|
|
427
|
+
file_result.file_path,
|
|
428
|
+
value,
|
|
429
|
+
self._threshold,
|
|
430
|
+
self._comparison,
|
|
431
|
+
)
|
|
391
432
|
|
|
392
433
|
if check_threshold(value, self._threshold, self._comparison):
|
|
393
434
|
violations.append(
|
|
@@ -412,6 +453,7 @@ class ZoneCondition(RuleRationaleMixin):
|
|
|
412
453
|
self._filters = filters
|
|
413
454
|
self._zone_type = zone_type
|
|
414
455
|
|
|
456
|
+
@inspect_check
|
|
415
457
|
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
416
458
|
files = extract_enhanced_class_info(self._project_path)
|
|
417
459
|
violations: list[Violation] = []
|
|
@@ -420,6 +462,7 @@ class ZoneCondition(RuleRationaleMixin):
|
|
|
420
462
|
dm = calculate_file_distance_metrics(file_result, files)
|
|
421
463
|
in_zone = dm.in_zone_of_pain if self._zone_type == "pain" else dm.in_zone_of_uselessness
|
|
422
464
|
|
|
465
|
+
debug("Zone %s for %s: in_zone=%s", self._zone_type, file_result.file_path, in_zone)
|
|
423
466
|
if in_zone:
|
|
424
467
|
violations.append(
|
|
425
468
|
MetricViolation(
|
|
@@ -504,12 +547,14 @@ class CustomMetricCondition(RuleRationaleMixin):
|
|
|
504
547
|
self._threshold = threshold
|
|
505
548
|
self._comparison = comparison
|
|
506
549
|
|
|
550
|
+
@inspect_check
|
|
507
551
|
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
508
552
|
classes = _get_filtered_classes(self._project_path, self._filters)
|
|
509
553
|
violations: list[Violation] = []
|
|
510
554
|
|
|
511
555
|
for cls in classes:
|
|
512
556
|
value = self._calculation(cls)
|
|
557
|
+
debug("Custom metric %s for %s: value=%s", self._name, cls.name, value)
|
|
513
558
|
if check_threshold(value, self._threshold, self._comparison):
|
|
514
559
|
violations.append(
|
|
515
560
|
MetricViolation(
|
|
@@ -542,12 +587,14 @@ class CustomAssertionCondition(RuleRationaleMixin):
|
|
|
542
587
|
self._calculation = calculation
|
|
543
588
|
self._assertion = assertion
|
|
544
589
|
|
|
590
|
+
@inspect_check
|
|
545
591
|
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
546
592
|
classes = _get_filtered_classes(self._project_path, self._filters)
|
|
547
593
|
violations: list[Violation] = []
|
|
548
594
|
|
|
549
595
|
for cls in classes:
|
|
550
596
|
value = self._calculation(cls)
|
|
597
|
+
debug("Custom metric %s for %s: value=%s", self._name, cls.name, value)
|
|
551
598
|
if not self._assertion(value, cls):
|
|
552
599
|
violations.append(
|
|
553
600
|
MetricViolation(
|
|
@@ -15,6 +15,7 @@ import re
|
|
|
15
15
|
from archunitpython.common.assertion.violation import Violation
|
|
16
16
|
from archunitpython.common.extraction.extract_graph import extract_graph
|
|
17
17
|
from archunitpython.common.fluentapi.checkable import CheckOptions, RuleRationaleMixin
|
|
18
|
+
from archunitpython.common.fluentapi.inspection import inspect_check
|
|
18
19
|
from archunitpython.common.projection.project_edges import project_edges
|
|
19
20
|
from archunitpython.common.projection.types import MapFunction
|
|
20
21
|
from archunitpython.slices.assertion.admissible_edges import (
|
|
@@ -157,6 +158,7 @@ class PositiveSliceCondition(RuleRationaleMixin):
|
|
|
157
158
|
self._puml_content = puml_content
|
|
158
159
|
self._coherence_options = coherence_options
|
|
159
160
|
|
|
161
|
+
@inspect_check
|
|
160
162
|
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
161
163
|
graph = extract_graph(self._project_path, options=options)
|
|
162
164
|
rules, contained_nodes = generate_rule(self._puml_content)
|
|
@@ -193,6 +195,7 @@ class NegativeSliceCondition(RuleRationaleMixin):
|
|
|
193
195
|
self._source = source
|
|
194
196
|
self._target = target
|
|
195
197
|
|
|
198
|
+
@inspect_check
|
|
196
199
|
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
197
200
|
graph = extract_graph(self._project_path, options=options)
|
|
198
201
|
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from archunitpython.common.assertion.violation import Violation
|
|
6
6
|
from archunitpython.common.fluentapi.checkable import Checkable, CheckOptions
|
|
7
|
+
from archunitpython.testing.common.color_utils import ColorUtils
|
|
7
8
|
from archunitpython.testing.common.violation_factory import ViolationFactory
|
|
8
9
|
|
|
9
10
|
|
|
@@ -11,26 +12,32 @@ def format_violations(
|
|
|
11
12
|
violations: list[Violation],
|
|
12
13
|
*,
|
|
13
14
|
because: str | None = None,
|
|
15
|
+
color: bool | None = None,
|
|
14
16
|
) -> str:
|
|
15
17
|
"""Format violations into a human-readable string.
|
|
16
18
|
|
|
17
19
|
Args:
|
|
18
20
|
violations: List of violations to format.
|
|
21
|
+
because: Optional rationale for the rule.
|
|
22
|
+
color: None detects interactive terminals; False guarantees plain text.
|
|
23
|
+
True explicitly enables ANSI colors, unless NO_COLOR is set.
|
|
19
24
|
|
|
20
25
|
Returns:
|
|
21
26
|
Formatted string describing all violations.
|
|
22
27
|
"""
|
|
23
28
|
if not violations:
|
|
24
|
-
return "No violations found."
|
|
29
|
+
return ColorUtils.style("No violations found.", "32", color)
|
|
25
30
|
|
|
26
|
-
lines = [f"Found {len(violations)} architecture violation(s):"]
|
|
31
|
+
lines = [ColorUtils.style(f"Found {len(violations)} architecture violation(s):", "1;31", color)]
|
|
27
32
|
if because:
|
|
28
|
-
lines.extend(["", f"Because: {because}"])
|
|
33
|
+
lines.extend(["", ColorUtils.style(f"Because: {because}", "33", color)])
|
|
29
34
|
lines.append("")
|
|
30
35
|
for i, violation in enumerate(violations, 1):
|
|
31
36
|
tv = ViolationFactory.from_violation(violation)
|
|
32
|
-
lines.append(f" {i}. {tv.message}")
|
|
33
|
-
|
|
37
|
+
lines.append(f" {i}. {ColorUtils.style(tv.message, '1;33', color)}")
|
|
38
|
+
detail_color = "36" if "value=" in tv.details else "34"
|
|
39
|
+
for detail in tv.details.split("\n"):
|
|
40
|
+
lines.append(f" {ColorUtils.style(detail, detail_color, color)}")
|
|
34
41
|
lines.append("")
|
|
35
42
|
|
|
36
43
|
return "\n".join(lines)
|
|
@@ -39,12 +46,15 @@ def format_violations(
|
|
|
39
46
|
def assert_passes(
|
|
40
47
|
checkable: Checkable,
|
|
41
48
|
options: CheckOptions | None = None,
|
|
49
|
+
*,
|
|
50
|
+
color: bool | None = None,
|
|
42
51
|
) -> None:
|
|
43
52
|
"""Assert that an architecture rule passes (no violations).
|
|
44
53
|
|
|
45
54
|
Args:
|
|
46
55
|
checkable: Any object with a check() method (implements Checkable).
|
|
47
56
|
options: Optional check options.
|
|
57
|
+
color: Optional color override for failure output.
|
|
48
58
|
|
|
49
59
|
Raises:
|
|
50
60
|
AssertionError: If the rule has violations.
|
|
@@ -52,4 +62,4 @@ def assert_passes(
|
|
|
52
62
|
violations = checkable.check(options)
|
|
53
63
|
if violations:
|
|
54
64
|
because = getattr(checkable, "because_reason", None)
|
|
55
|
-
raise AssertionError(format_violations(violations, because=because))
|
|
65
|
+
raise AssertionError(format_violations(violations, because=because, color=color))
|
|
@@ -8,7 +8,9 @@ import sys
|
|
|
8
8
|
|
|
9
9
|
def _supports_color() -> bool:
|
|
10
10
|
"""Check if the terminal supports ANSI colors."""
|
|
11
|
-
if os.environ.get("
|
|
11
|
+
if "NO_COLOR" in os.environ or os.environ.get("CI", "").lower() == "true":
|
|
12
|
+
return False
|
|
13
|
+
if os.environ.get("TERM") == "dumb":
|
|
12
14
|
return False
|
|
13
15
|
if not hasattr(sys.stdout, "isatty"):
|
|
14
16
|
return False
|
|
@@ -24,6 +26,15 @@ def _wrap(code: str, text: str) -> str:
|
|
|
24
26
|
class ColorUtils:
|
|
25
27
|
"""ANSI color utilities for terminal output."""
|
|
26
28
|
|
|
29
|
+
@staticmethod
|
|
30
|
+
def style(text: str, code: str, enabled: bool | None = None) -> str:
|
|
31
|
+
"""Style text with automatic detection or an explicit color choice."""
|
|
32
|
+
if enabled is False or "NO_COLOR" in os.environ:
|
|
33
|
+
return text
|
|
34
|
+
if enabled is None and not _supports_color():
|
|
35
|
+
return text
|
|
36
|
+
return f"\033[{code}m{text}\033[0m"
|
|
37
|
+
|
|
27
38
|
@staticmethod
|
|
28
39
|
def red(text: str) -> str:
|
|
29
40
|
return _wrap("31", text)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: archunitpython
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.0
|
|
4
4
|
Summary: Architecture testing library for Python projects. Enforce dependency rules, detect cycles, validate metrics.
|
|
5
5
|
Project-URL: Homepage, https://github.com/LukasNiessen/ArchUnitPython
|
|
6
6
|
Project-URL: Repository, https://github.com/LukasNiessen/ArchUnitPython.git
|
|
@@ -926,6 +926,10 @@ The most important differences:
|
|
|
926
926
|
|
|
927
927
|
When tests fail, you get helpful output with file paths and violation details:
|
|
928
928
|
|
|
929
|
+
Interactive terminals also highlight the failure summary, numbered headings,
|
|
930
|
+
paths, metric values, and rule rationale. Output stays plain when redirected,
|
|
931
|
+
when `CI=true`, or when `NO_COLOR` is set (even to an empty value).
|
|
932
|
+
|
|
929
933
|
```
|
|
930
934
|
Found 2 architecture violation(s):
|
|
931
935
|
|
|
@@ -938,25 +942,91 @@ Found 2 architecture violation(s):
|
|
|
938
942
|
|
|
939
943
|
## 📝 Debug Logging & Configuration
|
|
940
944
|
|
|
941
|
-
|
|
945
|
+
Logging is disabled by default. Enable it per check to inspect file discovery,
|
|
946
|
+
graph extraction and cache use, selector decisions, edge projections, class
|
|
947
|
+
analysis, metric values, and the resulting violations. File, layer, slice,
|
|
948
|
+
count/LCOM/distance, and custom metric checks use the same logging options.
|
|
949
|
+
|
|
950
|
+
| Level | What you see |
|
|
951
|
+
| --- | --- |
|
|
952
|
+
| `error` | Analysis exceptions (the original exception is still raised). |
|
|
953
|
+
| `warn` | Failed check summaries and every violation, plus errors. |
|
|
954
|
+
| `info` | Check start, completion, duration, and violations, plus errors. |
|
|
955
|
+
| `debug` | All of the above, plus rule configuration, discovered files, dependencies, cache decisions, selector matches, projected/omitted edges, classes, and measured values. |
|
|
942
956
|
|
|
943
957
|
### Enabling Debug Logging
|
|
944
958
|
|
|
945
959
|
```python
|
|
946
|
-
from archunitpython import CheckOptions
|
|
947
|
-
|
|
960
|
+
from archunitpython import CheckOptions, LoggingOptions, project_files
|
|
961
|
+
|
|
962
|
+
rule = project_files("src/").should().have_no_cycles()
|
|
948
963
|
|
|
949
964
|
options = CheckOptions(
|
|
950
965
|
logging=LoggingOptions(
|
|
951
966
|
enabled=True,
|
|
952
967
|
level="debug", # "error" | "warn" | "info" | "debug"
|
|
953
|
-
log_file=True, # Creates logs/
|
|
968
|
+
log_file=True, # Creates a unique timestamped file in logs/
|
|
954
969
|
),
|
|
955
970
|
)
|
|
956
971
|
|
|
957
972
|
violations = rule.check(options)
|
|
958
973
|
```
|
|
959
974
|
|
|
975
|
+
For example, a debug trace includes entries like these (paths and timings depend
|
|
976
|
+
on your project):
|
|
977
|
+
|
|
978
|
+
```text
|
|
979
|
+
[INFO] Starting check: CycleFreeFileCondition
|
|
980
|
+
[DEBUG] Graph cache miss
|
|
981
|
+
[DEBUG] Discovered file: /project/src/api.py
|
|
982
|
+
[DEBUG] Processing file: /project/src/api.py
|
|
983
|
+
[DEBUG] Dependency: /project/src/api.py -> /project/src/storage.py; external=False; kinds=[]
|
|
984
|
+
[DEBUG] Projection: /project/src/api.py -> /project/src/storage.py becomes /project/src/api.py -> /project/src/storage.py
|
|
985
|
+
[INFO] Finished check: CycleFreeFileCondition - 0 violation(s) (0.002s)
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
Console diagnostics use the standard `archunitpython` logger when handlers are
|
|
989
|
+
configured, respecting their levels and formatting; otherwise they go to stderr.
|
|
990
|
+
For pytest live logs, use `pytest --log-cli-level=DEBUG`. You can also configure
|
|
991
|
+
Python's standard `logging` module in your application.
|
|
992
|
+
|
|
993
|
+
### Save detailed inspection without console noise
|
|
994
|
+
|
|
995
|
+
```python
|
|
996
|
+
options = CheckOptions(logging=LoggingOptions(
|
|
997
|
+
enabled=True,
|
|
998
|
+
level="debug",
|
|
999
|
+
console=False,
|
|
1000
|
+
log_file=True,
|
|
1001
|
+
log_path="logs/architecture.log",
|
|
1002
|
+
append_to_log_file=True,
|
|
1003
|
+
))
|
|
1004
|
+
violations = rule.check(options)
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
File logs are UTF-8, timestamped, and contain no added ANSI colors. Automatic
|
|
1008
|
+
paths are unique per check; an explicit `log_path` replaces that path.
|
|
1009
|
+
`append_to_log_file=False` overwrites an explicit file at the start of each check.
|
|
1010
|
+
Each check closes its own output file and isolates its diagnostics from nested
|
|
1011
|
+
and concurrent checks. Use separate paths when concurrent checks write to files.
|
|
1012
|
+
Diagnostic output is best effort: an unavailable file or failing logging handler
|
|
1013
|
+
does not change rule results or hide analysis exceptions. Debug inspection does
|
|
1014
|
+
not re-run custom predicates or metric calculations.
|
|
1015
|
+
|
|
1016
|
+
### Format results independently of logging
|
|
1017
|
+
|
|
1018
|
+
```python
|
|
1019
|
+
from archunitpython import assert_passes, format_violations
|
|
1020
|
+
|
|
1021
|
+
print(format_violations(violations)) # Detect terminal colors
|
|
1022
|
+
text = format_violations(violations, color=False) # Stable plain text for artifacts
|
|
1023
|
+
assert_passes(rule, options, color=False) # Plain assertion failure
|
|
1024
|
+
```
|
|
1025
|
+
|
|
1026
|
+
Pass `color=True` to explicitly request ANSI styling; `NO_COLOR` always wins.
|
|
1027
|
+
The numbered report, rationale, and details remain available at every log level,
|
|
1028
|
+
including when logging is disabled. Existing plain-text report wording is retained.
|
|
1029
|
+
|
|
960
1030
|
### CI Pipeline Integration
|
|
961
1031
|
|
|
962
1032
|
```yaml
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
archunitpython/__init__.py,sha256=
|
|
1
|
+
archunitpython/__init__.py,sha256=JaKTTUg_yKFG5p-rh1VNOua4-aFpo7CBaYiAGvzn708,1324
|
|
2
2
|
archunitpython/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
3
|
archunitpython/common/__init__.py,sha256=TKL39Z0kBpWqMH99jU4LsDSUZ5lQ_rcAJfLnmUTDTOI,656
|
|
4
|
-
archunitpython/common/pattern_matching.py,sha256=
|
|
4
|
+
archunitpython/common/pattern_matching.py,sha256=WLURKcd9tWXG8MPPNWGh8craK-UP37oW_k39g04THQE,2974
|
|
5
5
|
archunitpython/common/regex_factory.py,sha256=UtlElM0Uty3SuD7tPZeoDnpbFipFUuYDpWfuV-Xq4c4,2459
|
|
6
6
|
archunitpython/common/types.py,sha256=aA6hxCvmOy7_q74pE0sDRILNB8KxmHx9G4j5ql87mWs,755
|
|
7
7
|
archunitpython/common/assertion/__init__.py,sha256=qpFkUrRoJWbwL7KPWaVTqlx70fhUJtwjyY0elsm8tv0,131
|
|
@@ -9,16 +9,18 @@ archunitpython/common/assertion/violation.py,sha256=TnMOykN3kPoGrSf1KcxBXYRfaxfX
|
|
|
9
9
|
archunitpython/common/error/__init__.py,sha256=UWcdIKpGAJvo4WEGOVSaUjQQnKoPi8R496uDGXBWKsE,116
|
|
10
10
|
archunitpython/common/error/errors.py,sha256=y7mcXoZPyK7uD2dMMO1qBt1y7KcIeGlRYiVeI6bjms4,249
|
|
11
11
|
archunitpython/common/extraction/__init__.py,sha256=RkJOcxJoLYQxEagh3uWhjsUCprK3Nr3Bn3o7nmzk4_Q,284
|
|
12
|
-
archunitpython/common/extraction/extract_graph.py,sha256=
|
|
12
|
+
archunitpython/common/extraction/extract_graph.py,sha256=GGXyoxHGyvWF4iyqIueuj89ObvRUuK3RsuxALJIvByk,24308
|
|
13
13
|
archunitpython/common/extraction/graph.py,sha256=s4QW0WYfwjqXteBNygaNBdTgSC1XKil8cBFLtxl5rJE,1124
|
|
14
14
|
archunitpython/common/fluentapi/__init__.py,sha256=LeS7qS2p9-FqqBhDL8xRPex__W5Qk0UWfcpl7nVVLfI,178
|
|
15
15
|
archunitpython/common/fluentapi/checkable.py,sha256=BJFYibVhHLMFlnWdISPCIL3DYI1_3JN2-1jEQOaVaGE,1547
|
|
16
|
+
archunitpython/common/fluentapi/inspection.py,sha256=VnrQIHH0tKW-QeexG7HiMU-DyvxjNcpAWsuEdhfRyR8,2991
|
|
16
17
|
archunitpython/common/logging/__init__.py,sha256=u3-2_jYmiyoQ-C534SmQXvR1L9h3lbJqA9Yt1Nmv9HA,115
|
|
17
|
-
archunitpython/common/logging/
|
|
18
|
+
archunitpython/common/logging/inspection.py,sha256=7akoJaRsS9vVz8joiMIcTh8LD1ocKjUiURDFsmhGN2k,3291
|
|
19
|
+
archunitpython/common/logging/types.py,sha256=WOWi1UECxSlABGkYn7JcxooqVrC4ZOXSUBbOQjkdnKc,482
|
|
18
20
|
archunitpython/common/projection/__init__.py,sha256=-Sx-b80b2N3ap3ahAcLUCXqYYK3ccgTDpV-K1jSOSYQ,797
|
|
19
21
|
archunitpython/common/projection/edge_projections.py,sha256=Mud8DL6Y8H66CObqyikKYAxdXMRENuAFRtwhCxpwxoU,1524
|
|
20
22
|
archunitpython/common/projection/project_cycles.py,sha256=TQ4Ok1hT4T_znqFbwV4VTj-BXmlFr1O7TtZdVOd5oPg,3081
|
|
21
|
-
archunitpython/common/projection/project_edges.py,sha256=
|
|
23
|
+
archunitpython/common/projection/project_edges.py,sha256=Rul-BpDT549ZqSlnG6qOriO0bREA-cJqg4TatOKL8fA,1720
|
|
22
24
|
archunitpython/common/projection/project_nodes.py,sha256=U6sLYvJd6NBgg39V0Ry7r_-57gdXaaXsbH-D12nipCg,1497
|
|
23
25
|
archunitpython/common/projection/types.py,sha256=EP5yJd-uXYdACFdFzeID8bUVsg_vdhgiOzshrC-CC8Y,937
|
|
24
26
|
archunitpython/common/projection/cycles/__init__.py,sha256=giGUcwpEMr91XST-biyCmLUjP5okhwTA4CcCaxjZdmg,217
|
|
@@ -40,14 +42,14 @@ archunitpython/files/assertion/depend_on_external_modules.py,sha256=BXGpN-301YOc
|
|
|
40
42
|
archunitpython/files/assertion/depend_on_files.py,sha256=9lBj4YNe1jjKJV3r0ntsXfPHoKc_IbwgHhLgMYOs9Ok,2024
|
|
41
43
|
archunitpython/files/assertion/matching_files.py,sha256=1RyYqFA-IzzAXlgqRfiL18jk9xEJzEBY2fp_A3fxjdE,2015
|
|
42
44
|
archunitpython/files/fluentapi/__init__.py,sha256=wgl8IOqTeQGpNISu-Q8b_j6rTn8aEgoz6t1S5j8z13g,108
|
|
43
|
-
archunitpython/files/fluentapi/files.py,sha256=
|
|
45
|
+
archunitpython/files/fluentapi/files.py,sha256=qJW7n6uX4-pfIGox-BdVHK_PVu9ZN_lxpKSXidp8_4s,17316
|
|
44
46
|
archunitpython/graph/__init__.py,sha256=vm1jMOoN35IIU7YDs59qPtf_F82YV-QrEGfXhozAOek,756
|
|
45
47
|
archunitpython/graph/graph_reporter.py,sha256=D_KH01CM7TzeMFB6o5NIQfLVJq0rvKZn9l7WumXV6lo,25598
|
|
46
48
|
archunitpython/layers/__init__.py,sha256=DCRxqLhluTxTaT9ZM2MbfAdJVgqcOO9jtkZHzBbeLOw,164
|
|
47
49
|
archunitpython/layers/assertion/__init__.py,sha256=_VRKjO86psSnmX29EfikDe3RIpJMczTyn6nkSM7xQwc,226
|
|
48
50
|
archunitpython/layers/assertion/layer_dependencies.py,sha256=I8Y3DJnWhCsJfhgQB4lTHBobFG3_bB9a_ljqCNcYeBM,2646
|
|
49
51
|
archunitpython/layers/fluentapi/__init__.py,sha256=psKFB-5IXS1SJ8g62HthExKYJio3bgdVzIleOSiF1gA,125
|
|
50
|
-
archunitpython/layers/fluentapi/layers.py,sha256=
|
|
52
|
+
archunitpython/layers/fluentapi/layers.py,sha256=iPI4XxP8iqvRfoJWJE9hrjYDeeQq0fbP6KQG8hIJKT0,4526
|
|
51
53
|
archunitpython/metrics/__init__.py,sha256=HD72MWF0hCh2LzKfyk3mMJp2tf7TJebpwTtZ_kppjso,84
|
|
52
54
|
archunitpython/metrics/assertion/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
53
55
|
archunitpython/metrics/assertion/metric_thresholds.py,sha256=kwX1iRFnF7oLaLMlQsYrDsNRrivDE8pI6nrrZYAafvg,1262
|
|
@@ -58,28 +60,28 @@ archunitpython/metrics/calculation/lcom.py,sha256=LK_kNT-BM6yuR5TUOLWQLkVvMpqR6q
|
|
|
58
60
|
archunitpython/metrics/common/__init__.py,sha256=xtEyAhS4X0UbJXHxnpdiXljVZmKIKKekZAZ1zflSPFE,335
|
|
59
61
|
archunitpython/metrics/common/types.py,sha256=w1BUlw6p_3K8qZDoQXv50P_CcKixGYiyS_YBGYILySA,1601
|
|
60
62
|
archunitpython/metrics/extraction/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
61
|
-
archunitpython/metrics/extraction/extract_class_info.py,sha256=
|
|
63
|
+
archunitpython/metrics/extraction/extract_class_info.py,sha256=iQuug7gRAdnegOVWCKGJo4YrdVaPC7nkMlrQ940Vi8Q,8381
|
|
62
64
|
archunitpython/metrics/fluentapi/__init__.py,sha256=HD72MWF0hCh2LzKfyk3mMJp2tf7TJebpwTtZ_kppjso,84
|
|
63
65
|
archunitpython/metrics/fluentapi/export_utils.py,sha256=1r-zTQAUqn_agp4_lhbcffdzfdASNY3LebYvKAG9kcQ,2333
|
|
64
|
-
archunitpython/metrics/fluentapi/metrics.py,sha256=
|
|
66
|
+
archunitpython/metrics/fluentapi/metrics.py,sha256=bFGO7DJiRrtDSxcwF9u0OMSpj9OZKBj9Pw0kxEHoVTU,20922
|
|
65
67
|
archunitpython/metrics/projection/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
66
68
|
archunitpython/slices/__init__.py,sha256=tb8MoZiEqIdmWy7uyhmEtMNm5Fxjuw6atSvDsD131DE,96
|
|
67
69
|
archunitpython/slices/assertion/__init__.py,sha256=MRwV3d69ljAJ7hFYdxI6UDNC8U4Dm1ImeuLUVKEC9To,280
|
|
68
70
|
archunitpython/slices/assertion/admissible_edges.py,sha256=NNn1PGsK0LS_Msa3g1cAMQwLzxbot4rQNVRAHzW11pk,3124
|
|
69
71
|
archunitpython/slices/fluentapi/__init__.py,sha256=tb8MoZiEqIdmWy7uyhmEtMNm5Fxjuw6atSvDsD131DE,96
|
|
70
|
-
archunitpython/slices/fluentapi/slices.py,sha256=
|
|
72
|
+
archunitpython/slices/fluentapi/slices.py,sha256=NVH7hm2ywwEi4vJPYe098-_Au4sPfu3pTnGW2zlK_qw,7270
|
|
71
73
|
archunitpython/slices/projection/__init__.py,sha256=tVgcK4gw-bqhUq98vU7_iPE0NEhOPUr7vr1-p7JI1A4,237
|
|
72
74
|
archunitpython/slices/projection/slicing_projections.py,sha256=BHBkuVG80mqzT7N4FlCCfY5U_NvlXmAgTmHf_SPTXVM,4215
|
|
73
75
|
archunitpython/slices/uml/__init__.py,sha256=GQFA5WBKfi_YElWiNWNm9c9IM-VaW83_Cg6kpwkzxkw,196
|
|
74
76
|
archunitpython/slices/uml/export_diagram.py,sha256=FeBRV8Vt8LuhLqEbg5JzZOmEk2i49JPkozMcI5r335g,818
|
|
75
77
|
archunitpython/slices/uml/generate_rules.py,sha256=D9vvqDzHAM0F80z4u5yBalEqIiNfJJ5HGXP0fVlSzHw,2136
|
|
76
78
|
archunitpython/testing/__init__.py,sha256=MJm6CHGhBGFg1um2L20-GD2DEHKqg8SiUoI-sRsoXQk,128
|
|
77
|
-
archunitpython/testing/assertion.py,sha256=
|
|
79
|
+
archunitpython/testing/assertion.py,sha256=sqZHVq1GTuKzil7FgXwuslcs8qtvIejQqblKeCh3eeU,2323
|
|
78
80
|
archunitpython/testing/common/__init__.py,sha256=Wc4bC-N4t6giChKjj6wuTGKkb39thcYS_xKWG9paYeY,220
|
|
79
|
-
archunitpython/testing/common/color_utils.py,sha256=
|
|
81
|
+
archunitpython/testing/common/color_utils.py,sha256=Bqmx7l8Aw3s3brblyqhWdVRFn0WQiQWiR9y9kz072Wg,1706
|
|
80
82
|
archunitpython/testing/common/violation_factory.py,sha256=yWlvv2U7u-7KoTMfp1QCeJXxTLpPEagKqDHZmPSyrlk,4677
|
|
81
83
|
archunitpython/testing/pytest_plugin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
82
|
-
archunitpython-1.
|
|
83
|
-
archunitpython-1.
|
|
84
|
-
archunitpython-1.
|
|
85
|
-
archunitpython-1.
|
|
84
|
+
archunitpython-1.8.0.dist-info/METADATA,sha256=6RS5qNk1Aj81iipmKRzmVLr1CcFZUzCO9OF86sSc2bU,38359
|
|
85
|
+
archunitpython-1.8.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
86
|
+
archunitpython-1.8.0.dist-info/licenses/LICENSE,sha256=kaQWfzfHk45CNIx4sIW7Uf1sNW5rmo6BpZ-R8GruuK0,1102
|
|
87
|
+
archunitpython-1.8.0.dist-info/RECORD,,
|
|
File without changes
|