archunitpython 1.6.1__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.
@@ -1,12 +1,13 @@
1
1
  """ArchUnitPython - Architecture testing library for Python projects."""
2
2
 
3
- __version__ = "1.6.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
 
@@ -45,6 +46,7 @@ class _LocatedImport:
45
46
  line_number: int
46
47
  resolution_kind: ImportKind | None = None
47
48
  fallback_module_name: str | None = None
49
+ aliases: tuple[str, ...] = ()
48
50
 
49
51
 
50
52
  @dataclass(frozen=True)
@@ -56,8 +58,7 @@ class _IgnoreDirective:
56
58
  if not self.modules:
57
59
  return True
58
60
  return any(
59
- import_.module_name == module
60
- or import_.module_name.startswith(f"{module}.")
61
+ import_.module_name == module or import_.module_name.startswith(f"{module}.")
61
62
  for module in self.modules
62
63
  )
63
64
 
@@ -96,21 +97,46 @@ def extract_graph(
96
97
  ignore_type_checking_imports = bool(options and options.ignore_type_checking_imports)
97
98
  cache_key = _build_cache_key(project_path, excludes, ignore_type_checking_imports)
98
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
+ )
99
106
  if options and options.clear_cache:
107
+ debug("Clearing graph cache for %s", project_path)
100
108
  _graph_cache.pop(cache_key, None)
101
109
 
102
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])
103
113
  return _graph_cache[cache_key]
114
+ debug("Graph cache miss")
104
115
 
105
116
  result = _extract_graph_uncached(
106
117
  project_path,
107
118
  excludes,
108
119
  ignore_type_checking_imports=ignore_type_checking_imports,
109
120
  )
121
+ _inspect_graph(result)
110
122
  _graph_cache[cache_key] = result
111
123
  return result
112
124
 
113
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
+
114
140
  def _build_cache_key(
115
141
  project_path: str,
116
142
  exclude_patterns: list[str],
@@ -166,6 +192,7 @@ def _extract_graph_uncached(
166
192
  normalized_py_file_set = {_normalize(f) for f in py_files_set}
167
193
 
168
194
  for file_path in py_files:
195
+ debug("Processing file: %s", file_path)
169
196
  # Add self-referencing edge (ensures the file appears as a node)
170
197
  edges.append(
171
198
  Edge(
@@ -177,39 +204,32 @@ def _extract_graph_uncached(
177
204
 
178
205
  imports = _extract_located_imports(file_path)
179
206
  for located_import in imports:
180
- module_name = located_import.module_name
181
207
  import_kind = located_import.import_kind
182
- if (
183
- ignore_type_checking_imports
184
- and import_kind == ImportKind.TYPE_IMPORT
185
- ):
186
- continue
187
- resolution_kind = located_import.resolution_kind or import_kind
188
- resolved, is_external = _resolve_import(
189
- module_name, file_path, project_path, resolution_kind
190
- )
191
- if is_external and located_import.fallback_module_name is not None:
192
- fallback, fallback_is_external = _resolve_import(
193
- located_import.fallback_module_name,
208
+ if ignore_type_checking_imports and import_kind == ImportKind.TYPE_IMPORT:
209
+ debug(
210
+ "Skipped type-only import: %s:%s -> %s",
194
211
  file_path,
195
- project_path,
196
- resolution_kind,
212
+ located_import.line_number,
213
+ located_import.module_name,
197
214
  )
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:
203
- continue
204
-
205
- edges.append(
206
- Edge(
207
- source=_normalize(file_path),
208
- target=resolved,
209
- external=is_external,
210
- import_kinds=_edge_import_kinds(located_import),
215
+ continue
216
+ for resolved, is_external in _resolve_import_targets(
217
+ located_import, file_path, project_path
218
+ ):
219
+ if resolved and resolved != _normalize(file_path):
220
+ # Check if the resolved path is in our project
221
+ if not is_external and resolved not in normalized_py_file_set:
222
+ debug("Skipped excluded dependency: %s -> %s", file_path, resolved)
223
+ continue
224
+
225
+ edges.append(
226
+ Edge(
227
+ source=_normalize(file_path),
228
+ target=resolved,
229
+ external=is_external,
230
+ import_kinds=_edge_import_kinds(located_import),
231
+ )
211
232
  )
212
- )
213
233
 
214
234
  return _merge_edges(edges)
215
235
 
@@ -237,6 +257,7 @@ def _find_python_files(root: str, exclude: list[str]) -> list[str]:
237
257
  full_path, root, exclude, is_dir=False
238
258
  ):
239
259
  py_files.append(os.path.abspath(full_path))
260
+ debug("Discovered file: %s", full_path)
240
261
 
241
262
  return py_files
242
263
 
@@ -323,9 +344,7 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
323
344
  conditional_import_ranges,
324
345
  )
325
346
  for alias in node.names:
326
- imports.append(
327
- _LocatedImport(alias.name, kind, node.lineno, syntax_kind)
328
- )
347
+ imports.append(_LocatedImport(alias.name, kind, node.lineno, syntax_kind))
329
348
 
330
349
  elif isinstance(node, ast.ImportFrom):
331
350
  syntax_kind = (
@@ -339,17 +358,17 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
339
358
  type_checking_ranges,
340
359
  conditional_import_ranges,
341
360
  )
342
- fallback_module_name = (
343
- "." * node.level if node.level and node.module is None else None
344
- )
361
+ fallback_module_name = "." * node.level if node.level and node.module is None else None
362
+ aliases = _module_aliases(node) if node.module else ()
345
363
  for module_name in _import_from_module_names(node):
346
364
  imports.append(
347
365
  _LocatedImport(
348
- module_name,
349
- kind,
350
- node.lineno,
351
- syntax_kind,
352
- fallback_module_name,
366
+ module_name=module_name,
367
+ import_kind=kind,
368
+ line_number=node.lineno,
369
+ resolution_kind=syntax_kind,
370
+ fallback_module_name=fallback_module_name,
371
+ aliases=aliases,
353
372
  )
354
373
  )
355
374
 
@@ -362,15 +381,9 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
362
381
  conditional_import_ranges,
363
382
  )
364
383
  for module_name in _extract_dynamic_import_names(node):
365
- imports.append(
366
- _LocatedImport(module_name, kind, node.lineno, syntax_kind)
367
- )
384
+ imports.append(_LocatedImport(module_name, kind, node.lineno, syntax_kind))
368
385
 
369
- return [
370
- import_
371
- for import_ in imports
372
- if not _is_ignored_import(import_, ignore_directives)
373
- ]
386
+ return [import_ for import_ in imports if not _is_ignored_import(import_, ignore_directives)]
374
387
 
375
388
 
376
389
  def _find_ignore_directives(source: str) -> dict[int, _IgnoreDirective]:
@@ -429,6 +442,11 @@ def _extract_dynamic_import_names(node: ast.Call) -> list[str]:
429
442
  return []
430
443
 
431
444
 
445
+ def _module_aliases(node: ast.ImportFrom) -> tuple[str, ...]:
446
+ """Return aliases that may refer to imported submodules."""
447
+ return tuple(alias.name for alias in node.names if alias.name != "*")
448
+
449
+
432
450
  def _import_from_module_names(node: ast.ImportFrom) -> tuple[str, ...]:
433
451
  """Return resolvable module names for a from-import statement."""
434
452
  dots = "." * (node.level or 0)
@@ -558,14 +576,10 @@ def _resolve_import(
558
576
  Returns (resolved_path, is_external).
559
577
  The path is normalized with forward slashes.
560
578
  """
561
- if (
562
- kind
563
- in (
564
- ImportKind.RELATIVE_IMPORT,
565
- ImportKind.TYPE_IMPORT,
566
- )
567
- and import_name.startswith(".")
568
- ):
579
+ if kind in (
580
+ ImportKind.RELATIVE_IMPORT,
581
+ ImportKind.TYPE_IMPORT,
582
+ ) and import_name.startswith("."):
569
583
  # Relative import
570
584
  return _resolve_relative_import(import_name, source_file, project_root)
571
585
 
@@ -573,6 +587,58 @@ def _resolve_import(
573
587
  return _resolve_absolute_import(import_name, project_root)
574
588
 
575
589
 
590
+ def _resolve_import_targets(
591
+ import_: _LocatedImport,
592
+ source_file: str,
593
+ project_root: str,
594
+ ) -> list[tuple[str, bool]]:
595
+ """Resolve an import, including namespace-package submodule aliases."""
596
+ resolution_kind = import_.resolution_kind or import_.import_kind
597
+ resolved, is_external = _resolve_import(
598
+ import_.module_name,
599
+ source_file,
600
+ project_root,
601
+ resolution_kind,
602
+ )
603
+ if is_external and import_.fallback_module_name is not None:
604
+ fallback, fallback_is_external = _resolve_import(
605
+ import_.fallback_module_name,
606
+ source_file,
607
+ project_root,
608
+ resolution_kind,
609
+ )
610
+ if fallback and not fallback_is_external:
611
+ resolved, is_external = fallback, False
612
+ if not is_external or not import_.aliases:
613
+ return [(resolved, is_external)]
614
+
615
+ alias_targets: list[tuple[str, bool]] = []
616
+ found_internal_alias = False
617
+ for alias in import_.aliases:
618
+ alias_module = _join_import_alias(import_.module_name, alias)
619
+ alias_resolved, alias_is_external = _resolve_import(
620
+ alias_module,
621
+ source_file,
622
+ project_root,
623
+ resolution_kind,
624
+ )
625
+ alias_targets.append((alias_resolved, alias_is_external))
626
+ found_internal_alias = found_internal_alias or not alias_is_external
627
+
628
+ if found_internal_alias:
629
+ return alias_targets
630
+ return [(resolved, is_external)]
631
+
632
+
633
+ def _join_import_alias(module_name: str, alias: str) -> str:
634
+ """Join a from-import module name with a candidate submodule alias."""
635
+ if not module_name:
636
+ return alias
637
+ if set(module_name) == {"."}:
638
+ return f"{module_name}{alias}"
639
+ return f"{module_name}.{alias}"
640
+
641
+
576
642
  def _resolve_relative_import(
577
643
  import_name: str,
578
644
  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)
@@ -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"
@@ -16,3 +16,5 @@ class LoggingOptions:
16
16
  level: LogLevel = "info"
17
17
  log_file: bool = False
18
18
  append_to_log_file: bool = False
19
+ console: bool = True
20
+ log_path: str | None = None
@@ -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
- return bool(filter_.regexp.search(target_string))
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
- return bool(filter_.regexp.search(target_string))
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
- return [
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
- lines.append(f" {tv.details}")
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("NO_COLOR"):
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.6.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
@@ -377,6 +377,12 @@ ArchUnitPython detects string-based dynamic imports such as `importlib.import_mo
377
377
  from my_app.adapters.sql import Repository # archunit: ignore
378
378
  ```
379
379
 
380
+ ### Namespace Packages
381
+
382
+ ArchUnitPython resolves imports from namespace packages that do not contain
383
+ `__init__.py` files. For example, `from my_app.domain import model` is resolved
384
+ to `my_app/domain/model.py` when `my_app/domain/` is a namespace package.
385
+
380
386
  ### Conditional Imports
381
387
 
382
388
  Imports inside `try` blocks that handle `ImportError` or
@@ -920,6 +926,10 @@ The most important differences:
920
926
 
921
927
  When tests fail, you get helpful output with file paths and violation details:
922
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
+
923
933
  ```
924
934
  Found 2 architecture violation(s):
925
935
 
@@ -932,25 +942,91 @@ Found 2 architecture violation(s):
932
942
 
933
943
  ## 📝 Debug Logging & Configuration
934
944
 
935
- We support logging to help you understand what files are being analyzed and troubleshoot test failures. Logging is disabled by default to keep test output clean.
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. |
936
956
 
937
957
  ### Enabling Debug Logging
938
958
 
939
959
  ```python
940
- from archunitpython import CheckOptions
941
- from archunitpython.common.logging.types import LoggingOptions
960
+ from archunitpython import CheckOptions, LoggingOptions, project_files
961
+
962
+ rule = project_files("src/").should().have_no_cycles()
942
963
 
943
964
  options = CheckOptions(
944
965
  logging=LoggingOptions(
945
966
  enabled=True,
946
967
  level="debug", # "error" | "warn" | "info" | "debug"
947
- log_file=True, # Creates logs/archunit-YYYY-MM-DD_HH-MM-SS.log
968
+ log_file=True, # Creates a unique timestamped file in logs/
948
969
  ),
949
970
  )
950
971
 
951
972
  violations = rule.check(options)
952
973
  ```
953
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
+
954
1030
  ### CI Pipeline Integration
955
1031
 
956
1032
  ```yaml
@@ -1,7 +1,7 @@
1
- archunitpython/__init__.py,sha256=V9u2N2MxgEJPGdWPrfPe6ejfnMW98_aCaXZXki9xa4k,1282
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=HMAfo8GsooHvA4d2IxCi3btrYTdwfOQXw4bDNr117P0,2669
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=SZ3-ZYI1RlIIF3saDoduXXzEHMwmflmhN60oWISeFOM,21608
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/types.py,sha256=wUriZeaUawA3JoBfJVLC6lIMglbLG955nXWdJGwxW4I,425
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=v7hDMSeghiLz0xHbG4BNF7jNQphcWobU7rgoiuw3fh4,1268
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=oZG0lWV6p0AiGANdoZfYd51H8zY-J4eTSPRTRPBYhs0,17152
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=Wl926_aWpNrlbWatDi3QDR-8geDflbYFEivWJv-X9dI,4438
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=u-i_AkRRdIbOVXhH7LVCCVIxRrz4IX6nOqukPxbB5Iw,7747
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=8OBOL3jVKbM9v_CjNNM9FMfV1JBfl5UEd1-PPOEScRs,19371
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=vNoyqmleTDTjnf2i35i2nte-Ycjm-oapfgaf3AhN0QI,7163
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=7cXlNwBklIGAyUJlPGFGAw97xZ9UgpkZvoO_QBdqm1c,1637
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=2I8Z1SZfWhhgudMgmXY6PPydGxGl35cK_To-GXnSyJg,1226
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.6.1.dist-info/METADATA,sha256=Pk3XGlaDYzHbLO8D_V1G-OQKssjrhd_-I2c3hIPgMwU,34913
83
- archunitpython-1.6.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
84
- archunitpython-1.6.1.dist-info/licenses/LICENSE,sha256=kaQWfzfHk45CNIx4sIW7Uf1sNW5rmo6BpZ-R8GruuK0,1102
85
- archunitpython-1.6.1.dist-info/RECORD,,
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.0
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any