archunitpython 1.7.0__py3-none-any.whl → 1.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,13 @@
1
1
  """ArchUnitPython - Architecture testing library for Python projects."""
2
2
 
3
- __version__ = "1.7.0"
3
+ __version__ = "1.8.1"
4
4
 
5
5
  # Files API
6
6
  # Common
7
7
  from archunitpython.common import (
8
8
  CheckOptions,
9
9
  EmptyTestViolation,
10
+ LoggingOptions,
10
11
  TechnicalError,
11
12
  UserError,
12
13
  Violation,
@@ -50,6 +51,7 @@ __all__ = [
50
51
  "Violation",
51
52
  "EmptyTestViolation",
52
53
  "CheckOptions",
54
+ "LoggingOptions",
53
55
  "TechnicalError",
54
56
  "UserError",
55
57
  "extract_graph",
@@ -5,10 +5,13 @@ from __future__ import annotations
5
5
  import ast
6
6
  import os
7
7
  import re
8
- from dataclasses import dataclass
8
+ from dataclasses import dataclass, field
9
9
 
10
10
  from archunitpython.common.extraction.graph import Edge, Graph, ImportKind
11
11
  from archunitpython.common.fluentapi.checkable import CheckOptions
12
+ from archunitpython.common.logging.inspection import debug, debug_enabled
13
+ from archunitpython.common.pattern_matching import matches_all_patterns
14
+ from archunitpython.common.types import Filter
12
15
 
13
16
  GraphCacheKey = tuple[str, tuple[str, ...], bool]
14
17
 
@@ -57,15 +60,28 @@ class _IgnoreDirective:
57
60
  if not self.modules:
58
61
  return True
59
62
  return any(
60
- import_.module_name == module
61
- or import_.module_name.startswith(f"{module}.")
63
+ import_.module_name == module or import_.module_name.startswith(f"{module}.")
62
64
  for module in self.modules
63
65
  )
64
66
 
65
67
 
68
+ @dataclass
69
+ class _ExtractionSession:
70
+ project_path: str
71
+ py_files: list[str]
72
+ normalized_py_file_set: set[str]
73
+ ignore_type_checking_imports: bool
74
+ edges_by_file: dict[str, list[Edge]] = field(default_factory=dict)
75
+ selected_files: dict[tuple[Filter, ...], list[str]] = field(default_factory=dict)
76
+
77
+
78
+ _extraction_sessions: dict[GraphCacheKey, _ExtractionSession] = {}
79
+
80
+
66
81
  def clear_graph_cache(options: CheckOptions | None = None) -> None:
67
82
  """Clear the cached dependency graphs."""
68
83
  _graph_cache.clear()
84
+ _extraction_sessions.clear()
69
85
 
70
86
 
71
87
  def extract_graph(
@@ -97,21 +113,128 @@ def extract_graph(
97
113
  ignore_type_checking_imports = bool(options and options.ignore_type_checking_imports)
98
114
  cache_key = _build_cache_key(project_path, excludes, ignore_type_checking_imports)
99
115
 
116
+ debug(
117
+ "Extraction root: %s; excludes: %s; ignore type-only imports: %s",
118
+ project_path,
119
+ excludes,
120
+ ignore_type_checking_imports,
121
+ )
100
122
  if options and options.clear_cache:
123
+ debug("Clearing graph and extraction-session caches for %s", project_path)
101
124
  _graph_cache.pop(cache_key, None)
125
+ _extraction_sessions.pop(cache_key, None)
102
126
 
103
127
  if cache_key in _graph_cache:
128
+ debug("Graph cache hit: %d edges", len(_graph_cache[cache_key]))
129
+ _inspect_graph(_graph_cache[cache_key])
104
130
  return _graph_cache[cache_key]
131
+ debug("Graph cache miss")
132
+
133
+ session = _get_extraction_session(
134
+ cache_key, project_path, excludes, ignore_type_checking_imports
135
+ )
136
+ result = _extract_graph_from_files(session, session.py_files)
137
+ _inspect_graph(result)
138
+ _graph_cache[cache_key] = result
139
+ return result
140
+
141
+
142
+ def extract_graph_for_sources(
143
+ project_path: str | None,
144
+ source_filters: list[Filter],
145
+ *,
146
+ options: CheckOptions | None = None,
147
+ ) -> Graph:
148
+ """Extract edges from files selected by a direct file-dependency rule.
105
149
 
106
- result = _extract_graph_uncached(
150
+ All project files are inventoried to classify imported targets correctly.
151
+ Parsed edges are reused by later rules and full-graph checks in this process.
152
+ """
153
+ if not source_filters:
154
+ return extract_graph(project_path, options=options)
155
+
156
+ if project_path is None:
157
+ project_path = os.getcwd()
158
+ project_path = os.path.abspath(project_path)
159
+ excludes = _resolve_exclude_patterns(project_path, None)
160
+ ignore_type_checking_imports = bool(options and options.ignore_type_checking_imports)
161
+ cache_key = _build_cache_key(project_path, excludes, ignore_type_checking_imports)
162
+ debug(
163
+ "Extraction root: %s; excludes: %s; ignore type-only imports: %s",
107
164
  project_path,
108
165
  excludes,
109
- ignore_type_checking_imports=ignore_type_checking_imports,
166
+ ignore_type_checking_imports,
110
167
  )
111
- _graph_cache[cache_key] = result
168
+ if options and options.clear_cache:
169
+ debug("Clearing graph and extraction-session caches for %s", project_path)
170
+ _graph_cache.pop(cache_key, None)
171
+ _extraction_sessions.pop(cache_key, None)
172
+
173
+ session = _get_extraction_session(
174
+ cache_key, project_path, excludes, ignore_type_checking_imports
175
+ )
176
+ filter_key = tuple(source_filters)
177
+ if filter_key not in session.selected_files:
178
+ session.selected_files[filter_key] = [
179
+ path for path in session.py_files if matches_all_patterns(path, source_filters)
180
+ ]
181
+ selected_files = session.selected_files[filter_key]
182
+ if debug_enabled():
183
+ debug(
184
+ "Selected-source extraction: %d of %d files", len(selected_files), len(session.py_files)
185
+ )
186
+ if selected_files:
187
+ cached_files = sum(path in session.edges_by_file for path in selected_files)
188
+ if cached_files == len(selected_files):
189
+ cache_status = "hit"
190
+ elif cached_files:
191
+ cache_status = "partial hit"
192
+ else:
193
+ cache_status = "miss"
194
+ debug(
195
+ "Selected-source edge cache %s: %d/%d files cached",
196
+ cache_status,
197
+ cached_files,
198
+ len(selected_files),
199
+ )
200
+ result = _extract_graph_from_files(session, selected_files)
201
+ _inspect_graph(result)
112
202
  return result
113
203
 
114
204
 
205
+ def _get_extraction_session(
206
+ cache_key: GraphCacheKey,
207
+ project_path: str,
208
+ excludes: list[str],
209
+ ignore_type_checking_imports: bool,
210
+ ) -> _ExtractionSession:
211
+ session = _extraction_sessions.get(cache_key)
212
+ if session is None:
213
+ py_files = _find_python_files(project_path, excludes)
214
+ session = _ExtractionSession(
215
+ project_path=project_path,
216
+ py_files=py_files,
217
+ normalized_py_file_set={_normalize(path) for path in py_files},
218
+ ignore_type_checking_imports=ignore_type_checking_imports,
219
+ )
220
+ _extraction_sessions[cache_key] = session
221
+ return session
222
+
223
+
224
+ def _inspect_graph(graph: Graph) -> None:
225
+ if not debug_enabled():
226
+ return
227
+ debug("Extracted graph: %d edges (including self-edges)", len(graph))
228
+ for edge in graph:
229
+ debug(
230
+ "Dependency: %s -> %s; external=%s; kinds=%s",
231
+ edge.source,
232
+ edge.target,
233
+ edge.external,
234
+ edge.import_kinds,
235
+ )
236
+
237
+
115
238
  def _build_cache_key(
116
239
  project_path: str,
117
240
  exclude_patterns: list[str],
@@ -153,55 +276,59 @@ def _load_archignore_patterns(project_path: str) -> list[str]:
153
276
  return patterns
154
277
 
155
278
 
156
- def _extract_graph_uncached(
157
- project_path: str,
158
- exclude_patterns: list[str],
159
- *,
160
- ignore_type_checking_imports: bool = False,
161
- ) -> Graph:
162
- """Extract graph without caching."""
163
- py_files = _find_python_files(project_path, exclude_patterns)
164
-
279
+ def _extract_graph_from_files(session: _ExtractionSession, files: list[str]) -> Graph:
280
+ """Assemble a graph from cached or newly parsed file edges."""
165
281
  edges: list[Edge] = []
166
- py_files_set = set(py_files)
167
- normalized_py_file_set = {_normalize(f) for f in py_files_set}
168
-
169
- for file_path in py_files:
170
- # Add self-referencing edge (ensures the file appears as a node)
171
- edges.append(
172
- Edge(
173
- source=_normalize(file_path),
174
- target=_normalize(file_path),
175
- external=False,
176
- )
282
+ for file_path in files:
283
+ edges.extend(_extract_file_edges(session, file_path))
284
+ return _merge_edges(edges)
285
+
286
+
287
+ def _extract_file_edges(session: _ExtractionSession, file_path: str) -> list[Edge]:
288
+ cached = session.edges_by_file.get(file_path)
289
+ if cached is not None:
290
+ return cached
291
+
292
+ debug("Processing file: %s", file_path)
293
+ source_label = _normalize(file_path)
294
+ edges = [
295
+ Edge(
296
+ source=source_label,
297
+ target=source_label,
298
+ external=False,
177
299
  )
300
+ ]
178
301
 
179
- imports = _extract_located_imports(file_path)
180
- for located_import in imports:
181
- import_kind = located_import.import_kind
182
- if (
183
- ignore_type_checking_imports
184
- and import_kind == ImportKind.TYPE_IMPORT
185
- ):
186
- continue
187
- for resolved, is_external in _resolve_import_targets(
188
- located_import, file_path, project_path
189
- ):
190
- if resolved and resolved != _normalize(file_path):
191
- # Check if the resolved path is in our project
192
- if not is_external and resolved not in normalized_py_file_set:
193
- continue
194
-
195
- edges.append(
196
- Edge(
197
- source=_normalize(file_path),
198
- target=resolved,
199
- external=is_external,
200
- import_kinds=_edge_import_kinds(located_import),
201
- )
302
+ for located_import in _extract_located_imports(file_path):
303
+ if (
304
+ session.ignore_type_checking_imports
305
+ and located_import.import_kind == ImportKind.TYPE_IMPORT
306
+ ):
307
+ debug(
308
+ "Skipped type-only import: %s:%s -> %s",
309
+ file_path,
310
+ located_import.line_number,
311
+ located_import.module_name,
312
+ )
313
+ continue
314
+ for resolved, is_external in _resolve_import_targets(
315
+ located_import, file_path, session.project_path
316
+ ):
317
+ if resolved and resolved != source_label:
318
+ if not is_external and resolved not in session.normalized_py_file_set:
319
+ debug("Skipped excluded dependency: %s -> %s", file_path, resolved)
320
+ continue
321
+ edges.append(
322
+ Edge(
323
+ source=source_label,
324
+ target=resolved,
325
+ external=is_external,
326
+ import_kinds=_edge_import_kinds(located_import),
202
327
  )
328
+ )
203
329
 
204
- return _merge_edges(edges)
330
+ session.edges_by_file[file_path] = edges
331
+ return edges
205
332
 
206
333
 
207
334
  def _normalize(path: str) -> str:
@@ -213,6 +340,9 @@ def _find_python_files(root: str, exclude: list[str]) -> list[str]:
213
340
  """Recursively find all .py files, excluding specified patterns."""
214
341
  py_files: list[str] = []
215
342
  root = os.path.abspath(root)
343
+ # Defaults can only match directory names, never a .py filename. Keep
344
+ # checking user patterns against files, including .archignore entries.
345
+ file_excludes = [pattern for pattern in exclude if pattern not in _DEFAULT_EXCLUDE]
216
346
  for dirpath, dirnames, filenames in os.walk(root):
217
347
  # Filter out excluded directories in-place
218
348
  dirnames[:] = [
@@ -223,10 +353,11 @@ def _find_python_files(root: str, exclude: list[str]) -> list[str]:
223
353
 
224
354
  for filename in filenames:
225
355
  full_path = os.path.join(dirpath, filename)
226
- if filename.endswith(".py") and not _should_exclude_path(
227
- full_path, root, exclude, is_dir=False
356
+ if filename.endswith(".py") and not (
357
+ file_excludes and _should_exclude_path(full_path, root, file_excludes, is_dir=False)
228
358
  ):
229
359
  py_files.append(os.path.abspath(full_path))
360
+ debug("Discovered file: %s", full_path)
230
361
 
231
362
  return py_files
232
363
 
@@ -300,10 +431,11 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
300
431
 
301
432
  imports: list[_LocatedImport] = []
302
433
  ignore_directives = _find_ignore_directives(source)
303
- type_checking_ranges = _find_type_checking_ranges(tree)
304
- conditional_import_ranges = _find_conditional_import_ranges(tree)
434
+ nodes = list(ast.walk(tree))
435
+ type_checking_ranges = _find_type_checking_ranges(nodes)
436
+ conditional_import_ranges = _find_conditional_import_ranges(nodes)
305
437
 
306
- for node in ast.walk(tree):
438
+ for node in nodes:
307
439
  if isinstance(node, ast.Import):
308
440
  syntax_kind = ImportKind.IMPORT
309
441
  kind = _classify_import(
@@ -313,9 +445,7 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
313
445
  conditional_import_ranges,
314
446
  )
315
447
  for alias in node.names:
316
- imports.append(
317
- _LocatedImport(alias.name, kind, node.lineno, syntax_kind)
318
- )
448
+ imports.append(_LocatedImport(alias.name, kind, node.lineno, syntax_kind))
319
449
 
320
450
  elif isinstance(node, ast.ImportFrom):
321
451
  syntax_kind = (
@@ -329,9 +459,7 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
329
459
  type_checking_ranges,
330
460
  conditional_import_ranges,
331
461
  )
332
- fallback_module_name = (
333
- "." * node.level if node.level and node.module is None else None
334
- )
462
+ fallback_module_name = "." * node.level if node.level and node.module is None else None
335
463
  aliases = _module_aliases(node) if node.module else ()
336
464
  for module_name in _import_from_module_names(node):
337
465
  imports.append(
@@ -354,15 +482,9 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
354
482
  conditional_import_ranges,
355
483
  )
356
484
  for module_name in _extract_dynamic_import_names(node):
357
- imports.append(
358
- _LocatedImport(module_name, kind, node.lineno, syntax_kind)
359
- )
485
+ imports.append(_LocatedImport(module_name, kind, node.lineno, syntax_kind))
360
486
 
361
- return [
362
- import_
363
- for import_ in imports
364
- if not _is_ignored_import(import_, ignore_directives)
365
- ]
487
+ return [import_ for import_ in imports if not _is_ignored_import(import_, ignore_directives)]
366
488
 
367
489
 
368
490
  def _find_ignore_directives(source: str) -> dict[int, _IgnoreDirective]:
@@ -463,11 +585,11 @@ def _classify_import(
463
585
  return default_kind
464
586
 
465
587
 
466
- def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
588
+ def _find_type_checking_ranges(nodes: list[ast.AST]) -> list[tuple[int, int]]:
467
589
  """Find line ranges of TYPE_CHECKING blocks."""
468
590
  ranges: list[tuple[int, int]] = []
469
591
 
470
- for node in ast.walk(tree):
592
+ for node in nodes:
471
593
  if isinstance(node, ast.If):
472
594
  # Check for `if TYPE_CHECKING:` pattern
473
595
  test = node.test
@@ -488,11 +610,11 @@ def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
488
610
  return sorted(ranges, key=lambda ele: ele[0])
489
611
 
490
612
 
491
- def _find_conditional_import_ranges(tree: ast.Module) -> list[tuple[int, int]]:
613
+ def _find_conditional_import_ranges(nodes: list[ast.AST]) -> list[tuple[int, int]]:
492
614
  """Find try/except ImportError ranges that contain optional imports."""
493
615
  ranges: list[tuple[int, int]] = []
494
616
 
495
- for node in ast.walk(tree):
617
+ for node in nodes:
496
618
  if not isinstance(node, ast.Try):
497
619
  continue
498
620
  if not any(_handles_import_error(handler.type) for handler in node.handlers):
@@ -555,14 +677,10 @@ def _resolve_import(
555
677
  Returns (resolved_path, is_external).
556
678
  The path is normalized with forward slashes.
557
679
  """
558
- if (
559
- kind
560
- in (
561
- ImportKind.RELATIVE_IMPORT,
562
- ImportKind.TYPE_IMPORT,
563
- )
564
- and import_name.startswith(".")
565
- ):
680
+ if kind in (
681
+ ImportKind.RELATIVE_IMPORT,
682
+ ImportKind.TYPE_IMPORT,
683
+ ) and import_name.startswith("."):
566
684
  # Relative import
567
685
  return _resolve_relative_import(import_name, source_file, project_root)
568
686
 
@@ -0,0 +1,79 @@
1
+ """Observational instrumentation for the existing check contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import wraps
6
+ from time import perf_counter
7
+ from typing import Callable, TypeVar, cast
8
+
9
+ from archunitpython.common.assertion.violation import Violation
10
+ from archunitpython.common.fluentapi.checkable import CheckOptions
11
+ from archunitpython.common.logging.inspection import InspectionSession, current_session
12
+
13
+ F = TypeVar("F", bound=Callable[..., list[Violation]])
14
+
15
+
16
+ def inspect_check(check: F) -> F:
17
+ """Trace a check while preserving its options, results, and exceptions."""
18
+
19
+ @wraps(check)
20
+ def inspected(self: object, options: CheckOptions | None = None) -> list[Violation]:
21
+ logging_options = options.logging if options else None
22
+ session = (
23
+ InspectionSession(logging_options)
24
+ if logging_options and logging_options.enabled
25
+ else None
26
+ )
27
+ token = current_session.set(session)
28
+ started = perf_counter() if session else 0.0
29
+ rule_name = type(self).__name__
30
+ try:
31
+ if session:
32
+ session.emit("info", "Starting check: %s", rule_name)
33
+ # Describe only known rule configuration, never user callbacks.
34
+ for name in (
35
+ "_project_path",
36
+ "_filters",
37
+ "_subject_filters",
38
+ "_object_filters",
39
+ "_module_filters",
40
+ "_pre_filters",
41
+ "_check_filters",
42
+ "_is_negated",
43
+ "_pattern",
44
+ "_regex",
45
+ "_source",
46
+ "_target",
47
+ "_layers",
48
+ "_allowed_dependencies",
49
+ "_forbidden_dependencies",
50
+ "_threshold",
51
+ "_comparison",
52
+ "_metric_attr",
53
+ "_name",
54
+ "_zone_type",
55
+ ):
56
+ if session.options.level == "debug" and name in vars(self):
57
+ session.emit("debug", "%s: %s", name.removeprefix("_"), vars(self)[name])
58
+ violations = check(self, options)
59
+ if session:
60
+ session.emit(
61
+ "warn" if violations else "info",
62
+ "Finished check: %s - %d violation(s) (%.3fs)",
63
+ rule_name,
64
+ len(violations),
65
+ perf_counter() - started,
66
+ )
67
+ for violation in violations:
68
+ session.emit("warn", "Violation: %s", violation)
69
+ return violations
70
+ except Exception as error:
71
+ if session:
72
+ session.emit("error", "Check failed: %s - %s", rule_name, error)
73
+ raise
74
+ finally:
75
+ current_session.reset(token)
76
+ if session:
77
+ session.close()
78
+
79
+ return cast(F, inspected)
@@ -0,0 +1,99 @@
1
+ """Per-check diagnostic sessions, isolated from architecture evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from contextvars import ContextVar
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import TextIO
11
+ from uuid import uuid4
12
+
13
+ from archunitpython.common.logging.types import LoggingOptions
14
+
15
+ _LEVELS = {
16
+ "debug": logging.DEBUG,
17
+ "info": logging.INFO,
18
+ "warn": logging.WARNING,
19
+ "error": logging.ERROR,
20
+ }
21
+
22
+
23
+ class InspectionSession:
24
+ """Own a check's output without attaching process-wide logging handlers.
25
+
26
+ Diagnostics are best effort: formatting and output errors must never replace
27
+ a rule result or an exception raised by the analysis itself.
28
+ """
29
+
30
+ def __init__(self, options: LoggingOptions) -> None:
31
+ self.options = options
32
+ self._file: TextIO | None = None
33
+ self._file_attempted = False
34
+
35
+ def emit(self, level: str, message: str, *args: object) -> None:
36
+ if _LEVELS[level] < _LEVELS.get(self.options.level, logging.INFO):
37
+ return
38
+ try:
39
+ text = message % args if args else message
40
+ except Exception:
41
+ return
42
+ if self.options.console:
43
+ try:
44
+ logger = logging.getLogger("archunitpython")
45
+ if logger.hasHandlers():
46
+ logger.log(_LEVELS[level], text)
47
+ else:
48
+ sys.stderr.write(f"[{level.upper()}] {text}\n")
49
+ except Exception:
50
+ pass
51
+ if self.options.log_file:
52
+ try:
53
+ if not self._file_attempted:
54
+ self._file_attempted = True
55
+ path = (
56
+ Path(self.options.log_path)
57
+ if self.options.log_path
58
+ else Path("logs")
59
+ / (
60
+ datetime.now().strftime("archunit-%Y-%m-%d_%H-%M-%S-")
61
+ + uuid4().hex
62
+ + ".log"
63
+ )
64
+ )
65
+ path.parent.mkdir(parents=True, exist_ok=True)
66
+ self._file = path.open(
67
+ "a" if self.options.append_to_log_file else "w", encoding="utf-8"
68
+ )
69
+ if self._file is not None:
70
+ self._file.write(f"[{datetime.now().isoformat()}] [{level.upper()}] {text}\n")
71
+ self._file.flush()
72
+ except Exception:
73
+ self.close()
74
+
75
+ def close(self) -> None:
76
+ if self._file is not None:
77
+ try:
78
+ self._file.close()
79
+ except Exception:
80
+ pass
81
+ self._file = None
82
+
83
+
84
+ current_session: ContextVar[InspectionSession | None] = ContextVar(
85
+ "archunitpython_inspection", default=None
86
+ )
87
+
88
+
89
+ def debug(message: str, *args: object) -> None:
90
+ """Lazily emit details only inside an enabled debug check."""
91
+ session = current_session.get()
92
+ if session is not None:
93
+ session.emit("debug", message, *args)
94
+
95
+
96
+ def debug_enabled() -> bool:
97
+ """Guard expensive diagnostic traversal before constructing its details."""
98
+ session = current_session.get()
99
+ return session is not None and session.options.level == "debug"
@@ -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,10 @@ 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
+ regexp = filter_.search_regexp or filter_.regexp
52
+ matched = bool(regexp.search(target_string))
53
+ debug("Selector %s against %s (%s): %s", filter_.regexp.pattern, target_string, target, matched)
54
+ return matched
51
55
 
52
56
 
53
57
  def matches_pattern_classname(class_name: str, file_path: str, filter_: Filter) -> bool:
@@ -65,7 +69,10 @@ def matches_pattern_classname(class_name: str, file_path: str, filter_: Filter)
65
69
  else:
66
70
  target_string = normalize_path(file_path)
67
71
 
68
- return bool(filter_.regexp.search(target_string))
72
+ regexp = filter_.search_regexp or filter_.regexp
73
+ matched = bool(regexp.search(target_string))
74
+ debug("Selector %s against %s (%s): %s", filter_.regexp.pattern, target_string, target, matched)
75
+ return matched
69
76
 
70
77
 
71
78
  def matches_all_patterns(file_path: str, filters: list[Filter]) -> bool:
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from collections import defaultdict
6
6
 
7
7
  from archunitpython.common.extraction.graph import Edge
8
+ from archunitpython.common.logging.inspection import debug
8
9
  from archunitpython.common.projection.types import MapFunction, ProjectedEdge
9
10
 
10
11
 
@@ -29,11 +30,19 @@ def project_edges(
29
30
  for edge in graph:
30
31
  mapped = mapper(edge)
31
32
  if mapped is None:
33
+ debug("Projection omitted: %s -> %s", edge.source, edge.target)
32
34
  continue
35
+ debug(
36
+ "Projection: %s -> %s becomes %s -> %s",
37
+ edge.source,
38
+ edge.target,
39
+ mapped.source_label,
40
+ mapped.target_label,
41
+ )
33
42
  key = (mapped.source_label, mapped.target_label)
34
43
  groups[key].append(edge)
35
44
 
36
- 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