Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 164 additions & 63 deletions src/archunitpython/common/extraction/extract_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
import ast
import os
import re
from dataclasses import dataclass
from dataclasses import dataclass, field

from archunitpython.common.extraction.graph import Edge, Graph, ImportKind
from archunitpython.common.fluentapi.checkable import CheckOptions
from archunitpython.common.logging.inspection import debug, debug_enabled
from archunitpython.common.pattern_matching import matches_all_patterns
from archunitpython.common.types import Filter

GraphCacheKey = tuple[str, tuple[str, ...], bool]

Expand Down Expand Up @@ -63,9 +65,23 @@ def matches(self, import_: _LocatedImport) -> bool:
)


@dataclass
class _ExtractionSession:
project_path: str
py_files: list[str]
normalized_py_file_set: set[str]
ignore_type_checking_imports: bool
edges_by_file: dict[str, list[Edge]] = field(default_factory=dict)
selected_files: dict[tuple[Filter, ...], list[str]] = field(default_factory=dict)


_extraction_sessions: dict[GraphCacheKey, _ExtractionSession] = {}


def clear_graph_cache(options: CheckOptions | None = None) -> None:
"""Clear the cached dependency graphs."""
_graph_cache.clear()
_extraction_sessions.clear()


def extract_graph(
Expand Down Expand Up @@ -104,25 +120,107 @@ def extract_graph(
ignore_type_checking_imports,
)
if options and options.clear_cache:
debug("Clearing graph cache for %s", project_path)
debug("Clearing graph and extraction-session caches for %s", project_path)
_graph_cache.pop(cache_key, None)
_extraction_sessions.pop(cache_key, None)

if cache_key in _graph_cache:
debug("Graph cache hit: %d edges", len(_graph_cache[cache_key]))
_inspect_graph(_graph_cache[cache_key])
return _graph_cache[cache_key]
debug("Graph cache miss")

result = _extract_graph_uncached(
session = _get_extraction_session(
cache_key, project_path, excludes, ignore_type_checking_imports
)
result = _extract_graph_from_files(session, session.py_files)
_inspect_graph(result)
_graph_cache[cache_key] = result
return result


def extract_graph_for_sources(
project_path: str | None,
source_filters: list[Filter],
*,
options: CheckOptions | None = None,
) -> Graph:
"""Extract edges from files selected by a direct file-dependency rule.

All project files are inventoried to classify imported targets correctly.
Parsed edges are reused by later rules and full-graph checks in this process.
"""
if not source_filters:
return extract_graph(project_path, options=options)

if project_path is None:
project_path = os.getcwd()
project_path = os.path.abspath(project_path)
excludes = _resolve_exclude_patterns(project_path, None)
ignore_type_checking_imports = bool(options and options.ignore_type_checking_imports)
cache_key = _build_cache_key(project_path, excludes, ignore_type_checking_imports)
debug(
"Extraction root: %s; excludes: %s; ignore type-only imports: %s",
project_path,
excludes,
ignore_type_checking_imports=ignore_type_checking_imports,
ignore_type_checking_imports,
)
if options and options.clear_cache:
debug("Clearing graph and extraction-session caches for %s", project_path)
_graph_cache.pop(cache_key, None)
_extraction_sessions.pop(cache_key, None)

session = _get_extraction_session(
cache_key, project_path, excludes, ignore_type_checking_imports
)
filter_key = tuple(source_filters)
if filter_key not in session.selected_files:
session.selected_files[filter_key] = [
path for path in session.py_files if matches_all_patterns(path, source_filters)
]
selected_files = session.selected_files[filter_key]
if debug_enabled():
debug(
"Selected-source extraction: %d of %d files", len(selected_files), len(session.py_files)
)
if selected_files:
cached_files = sum(path in session.edges_by_file for path in selected_files)
if cached_files == len(selected_files):
cache_status = "hit"
elif cached_files:
cache_status = "partial hit"
else:
cache_status = "miss"
debug(
"Selected-source edge cache %s: %d/%d files cached",
cache_status,
cached_files,
len(selected_files),
)
result = _extract_graph_from_files(session, selected_files)
_inspect_graph(result)
_graph_cache[cache_key] = result
return result


def _get_extraction_session(
cache_key: GraphCacheKey,
project_path: str,
excludes: list[str],
ignore_type_checking_imports: bool,
) -> _ExtractionSession:
session = _extraction_sessions.get(cache_key)
if session is None:
py_files = _find_python_files(project_path, excludes)
session = _ExtractionSession(
project_path=project_path,
py_files=py_files,
normalized_py_file_set={_normalize(path) for path in py_files},
ignore_type_checking_imports=ignore_type_checking_imports,
)
_extraction_sessions[cache_key] = session
return session


def _inspect_graph(graph: Graph) -> None:
if not debug_enabled():
return
Expand Down Expand Up @@ -178,60 +276,59 @@ def _load_archignore_patterns(project_path: str) -> list[str]:
return patterns


def _extract_graph_uncached(
project_path: str,
exclude_patterns: list[str],
*,
ignore_type_checking_imports: bool = False,
) -> Graph:
"""Extract graph without caching."""
py_files = _find_python_files(project_path, exclude_patterns)

def _extract_graph_from_files(session: _ExtractionSession, files: list[str]) -> Graph:
"""Assemble a graph from cached or newly parsed file edges."""
edges: list[Edge] = []
py_files_set = set(py_files)
normalized_py_file_set = {_normalize(f) for f in py_files_set}

for file_path in py_files:
debug("Processing file: %s", file_path)
# Add self-referencing edge (ensures the file appears as a node)
edges.append(
Edge(
source=_normalize(file_path),
target=_normalize(file_path),
external=False,
)
for file_path in files:
edges.extend(_extract_file_edges(session, file_path))
return _merge_edges(edges)


def _extract_file_edges(session: _ExtractionSession, file_path: str) -> list[Edge]:
cached = session.edges_by_file.get(file_path)
if cached is not None:
return cached

debug("Processing file: %s", file_path)
source_label = _normalize(file_path)
edges = [
Edge(
source=source_label,
target=source_label,
external=False,
)
]

imports = _extract_located_imports(file_path)
for located_import in imports:
import_kind = located_import.import_kind
if ignore_type_checking_imports and import_kind == ImportKind.TYPE_IMPORT:
debug(
"Skipped type-only import: %s:%s -> %s",
file_path,
located_import.line_number,
located_import.module_name,
)
continue
for resolved, is_external in _resolve_import_targets(
located_import, file_path, project_path
):
if resolved and resolved != _normalize(file_path):
# Check if the resolved path is in our project
if not is_external and resolved not in normalized_py_file_set:
debug("Skipped excluded dependency: %s -> %s", file_path, resolved)
continue

edges.append(
Edge(
source=_normalize(file_path),
target=resolved,
external=is_external,
import_kinds=_edge_import_kinds(located_import),
)
for located_import in _extract_located_imports(file_path):
if (
session.ignore_type_checking_imports
and located_import.import_kind == ImportKind.TYPE_IMPORT
):
debug(
"Skipped type-only import: %s:%s -> %s",
file_path,
located_import.line_number,
located_import.module_name,
)
continue
for resolved, is_external in _resolve_import_targets(
located_import, file_path, session.project_path
):
if resolved and resolved != source_label:
if not is_external and resolved not in session.normalized_py_file_set:
debug("Skipped excluded dependency: %s -> %s", file_path, resolved)
continue
edges.append(
Edge(
source=source_label,
target=resolved,
external=is_external,
import_kinds=_edge_import_kinds(located_import),
)
)

return _merge_edges(edges)
session.edges_by_file[file_path] = edges
return edges


def _normalize(path: str) -> str:
Expand All @@ -243,6 +340,9 @@ def _find_python_files(root: str, exclude: list[str]) -> list[str]:
"""Recursively find all .py files, excluding specified patterns."""
py_files: list[str] = []
root = os.path.abspath(root)
# Defaults can only match directory names, never a .py filename. Keep
# checking user patterns against files, including .archignore entries.
file_excludes = [pattern for pattern in exclude if pattern not in _DEFAULT_EXCLUDE]
for dirpath, dirnames, filenames in os.walk(root):
# Filter out excluded directories in-place
dirnames[:] = [
Expand All @@ -253,8 +353,8 @@ def _find_python_files(root: str, exclude: list[str]) -> list[str]:

for filename in filenames:
full_path = os.path.join(dirpath, filename)
if filename.endswith(".py") and not _should_exclude_path(
full_path, root, exclude, is_dir=False
if filename.endswith(".py") and not (
file_excludes and _should_exclude_path(full_path, root, file_excludes, is_dir=False)
):
py_files.append(os.path.abspath(full_path))
debug("Discovered file: %s", full_path)
Expand Down Expand Up @@ -331,10 +431,11 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:

imports: list[_LocatedImport] = []
ignore_directives = _find_ignore_directives(source)
type_checking_ranges = _find_type_checking_ranges(tree)
conditional_import_ranges = _find_conditional_import_ranges(tree)
nodes = list(ast.walk(tree))
type_checking_ranges = _find_type_checking_ranges(nodes)
conditional_import_ranges = _find_conditional_import_ranges(nodes)

for node in ast.walk(tree):
for node in nodes:
if isinstance(node, ast.Import):
syntax_kind = ImportKind.IMPORT
kind = _classify_import(
Expand Down Expand Up @@ -484,11 +585,11 @@ def _classify_import(
return default_kind


def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
def _find_type_checking_ranges(nodes: list[ast.AST]) -> list[tuple[int, int]]:
"""Find line ranges of TYPE_CHECKING blocks."""
ranges: list[tuple[int, int]] = []

for node in ast.walk(tree):
for node in nodes:
if isinstance(node, ast.If):
# Check for `if TYPE_CHECKING:` pattern
test = node.test
Expand All @@ -509,11 +610,11 @@ def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
return sorted(ranges, key=lambda ele: ele[0])


def _find_conditional_import_ranges(tree: ast.Module) -> list[tuple[int, int]]:
def _find_conditional_import_ranges(nodes: list[ast.AST]) -> list[tuple[int, int]]:
"""Find try/except ImportError ranges that contain optional imports."""
ranges: list[tuple[int, int]] = []

for node in ast.walk(tree):
for node in nodes:
if not isinstance(node, ast.Try):
continue
if not any(_handles_import_error(handler.type) for handler in node.handlers):
Expand Down
6 changes: 4 additions & 2 deletions src/archunitpython/common/pattern_matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ def matches_pattern(file_path: str, filter_: Filter) -> bool:
else:
target_string = normalize_path(file_path)

matched = bool(filter_.regexp.search(target_string))
regexp = filter_.search_regexp or filter_.regexp
matched = bool(regexp.search(target_string))
debug("Selector %s against %s (%s): %s", filter_.regexp.pattern, target_string, target, matched)
return matched

Expand All @@ -68,7 +69,8 @@ def matches_pattern_classname(class_name: str, file_path: str, filter_: Filter)
else:
target_string = normalize_path(file_path)

matched = bool(filter_.regexp.search(target_string))
regexp = filter_.search_regexp or filter_.regexp
matched = bool(regexp.search(target_string))
debug("Selector %s against %s (%s): %s", filter_.regexp.pattern, target_string, target, matched)
return matched

Expand Down
14 changes: 14 additions & 0 deletions src/archunitpython/common/regex_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ def _pattern_to_regex(pattern: Pattern) -> re.Pattern[str]:
return _glob_to_regex(pattern)


def _optimized_search_regex(pattern: Pattern) -> re.Pattern[str] | None:
"""Avoid redundant leading glob stars only for the search path.

Keep Filter.regexp unchanged for callers that use match/fullmatch directly.
"""
if isinstance(pattern, str) and pattern.startswith("*"):
return _glob_to_regex(pattern.lstrip("*"))
return None


class RegexFactory:
"""Factory for creating Filter objects from patterns."""

Expand All @@ -38,6 +48,7 @@ def filename_matcher(name: Pattern) -> Filter:
return Filter(
regexp=_pattern_to_regex(name),
options=PatternMatchingOptions(target="filename"),
search_regexp=_optimized_search_regex(name),
)

@staticmethod
Expand All @@ -46,6 +57,7 @@ def classname_matcher(name: Pattern) -> Filter:
return Filter(
regexp=_pattern_to_regex(name),
options=PatternMatchingOptions(target="classname"),
search_regexp=_optimized_search_regex(name),
)

@staticmethod
Expand All @@ -54,6 +66,7 @@ def folder_matcher(folder: Pattern) -> Filter:
return Filter(
regexp=_pattern_to_regex(folder),
options=PatternMatchingOptions(target="path-no-filename"),
search_regexp=_optimized_search_regex(folder),
)

@staticmethod
Expand All @@ -62,6 +75,7 @@ def path_matcher(path: Pattern) -> Filter:
return Filter(
regexp=_pattern_to_regex(path),
options=PatternMatchingOptions(target="path"),
search_regexp=_optimized_search_regex(path),
)

@staticmethod
Expand Down
Loading
Loading