blob: 8457f5f3fefc4df5aa16bfc3eaca374da48ac84c [file]
# Copyright 2026 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
# A library allowing to solve dependencies within the tools/metrics
# setup. It only detects internal dependencies imported through
#
# ```
# import chromium_src.tools.metrics...
# ```
#
# or similar import.
#
# It's used to make sure that all affected files are tested when
# running presubmits.
import os
import pathlib
import re
from typing import Any, Dict, List, Optional, Set
_IMPORT_PATTERNS = [
(re.compile(r'^import chromium_src\.tools\.metrics\.?(.*) as .*$'), False),
(re.compile(r'^import chromium_src\.tools\.metrics\.?([^ ]*)$'), False),
(re.compile(r'^from chromium_src\.tools\.metrics\.?(.*) import (.*)$'), True),
]
def _get_py_files_recursive(directory: pathlib.Path) -> List[pathlib.Path]:
"""Recursively finds all .py files in a given directory."""
return list(directory.rglob('*.py'))
def _resolve_fs_path(path: pathlib.Path) -> List[pathlib.Path]:
"""Resolves a path to either a single .py file or all files in a directory.
It checks if the path is a file (by appending .py) - if so, it returns a list
containing the Path to that file.
If it's not a py file, it tries to resolve it as a directory - returning paths
of all files within that directory recursively.
If neither exists, throws FileNotFoundError.
"""
file_candidate = pathlib.Path(str(path) + '.py')
if file_candidate.is_file():
return [file_candidate]
if path.is_dir():
return _get_py_files_recursive(path)
raise FileNotFoundError(
f"Could not resolve import. Neither '{file_candidate}' exists, "
f"nor is '{path}' a directory."
)
def _process_import_match(
root_path: pathlib.Path, path_group: str, symbol: Optional[str] = None
) -> List[pathlib.Path]:
"""Constructs the final Path from the parsed import statement.
If a symbol is present, it tries to append it first as a file/sub-module.
If that fails, it falls back to checking the path without the symbol.
"""
full_path = root_path / path_group.replace('.', os.sep)
if not symbol:
return _resolve_fs_path(full_path)
path_with_symbol = full_path / symbol
try:
return _resolve_fs_path(path_with_symbol)
except FileNotFoundError:
return _resolve_fs_path(full_path)
def _parse_line_dependencies(
root_path: pathlib.Path, line: str, pattern: re.Pattern
) -> List[pathlib.Path]:
"""Returns dependencies for the line (if any) using pattern."""
match = pattern.match(line)
if not match:
return []
path_suffix = match.group(1)
return [
p.relative_to(root_path)
for p in _process_import_match(root_path, path_suffix, None)
]
def _parse_line_dependencies_with_symbol(
root_path: pathlib.Path, line: str, pattern: re.Pattern
) -> List[pathlib.Path]:
"""Returns dependencies for the line (if any) using pattern."""
match = pattern.match(line)
if not match:
return []
path_suffix = match.group(1)
symbols_str = match.group(2).strip()
symbols = [s.strip() for s in symbols_str.split(',')]
imports: List[pathlib.Path] = []
for symbol in symbols:
imports.extend(
p.relative_to(root_path)
for p in _process_import_match(root_path, path_suffix, symbol)
)
return imports
def _dependencies_of(
root_path: pathlib.Path, relative_path: pathlib.Path
) -> List[pathlib.Path]:
"""Returns a list of dependencies (as Paths relative to root_path) for a file.
The file is identified by relative_path within root_path.
"""
full_path = root_path / relative_path
if not full_path.exists():
raise FileNotFoundError(f'Input file not found: {full_path}')
with open(full_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
dependencies = []
for line in lines:
line = line.strip()
for pattern, has_symbol in _IMPORT_PATTERNS:
if has_symbol:
dependencies.extend(
_parse_line_dependencies_with_symbol(root_path, line, pattern)
)
else:
dependencies.extend(_parse_line_dependencies(root_path, line, pattern))
return dependencies
def scan_directory_dependencies(
root_path: pathlib.Path, report_relative_to: Optional[pathlib.Path] = None
) -> Dict[pathlib.Path, List[pathlib.Path]]:
"""Scans the directory for .py files and builds a dependency map.
Returns:
Dict[pathlib.Path, List[pathlib.Path]]: Keys are relative file Paths,
values are lists of dependencies as relative file Paths.
"""
root_path = pathlib.Path(root_path)
reporting_root = (
pathlib.Path(report_relative_to) if report_relative_to else root_path
)
rel_files = [
p.relative_to(root_path) for p in _get_py_files_recursive(root_path)
]
return {
(root_path / path).relative_to(reporting_root): [
(root_path / dep_path).relative_to(reporting_root)
for dep_path in _dependencies_of(root_path, path)
]
for path in rel_files
}
def print_dependency_graph(
dependency_graph: Dict[pathlib.Path, List[pathlib.Path]],
) -> None:
"""Prints a visual representation of direct dependencies for each file."""
header_script = 'Script'
print(f'{header_script:<50} | Dependencies')
print('-' * 80)
empty_prefix = ''
for script_path in dependency_graph:
deps = dependency_graph[script_path]
if not deps:
print(f'{script_path:<50} | (None)')
else:
print(f'{script_path:<50} | -> {deps[0]}')
for dep in deps[1:]:
print(f'{empty_prefix:<50} | -> {dep}')
print('-' * 80)
def get_all_dependencies(
dependency_graph: Dict[pathlib.Path, List[pathlib.Path]],
target_script: pathlib.Path,
) -> Set[pathlib.Path]:
"""Returns a set of ALL dependencies (direct and indirect) for a script.
Detects and handles circular dependencies safely.
"""
if not dependency_graph:
return set()
if target_script not in dependency_graph:
raise ValueError(
f"Script '{target_script}' not found in the scanned graph."
)
all_dependencies: Set[pathlib.Path] = set()
queue = [target_script]
visited = set()
while queue:
current_script = queue.pop(0)
if current_script in visited:
continue
visited.add(current_script)
direct_deps = dependency_graph.get(current_script, [])
for dep in direct_deps:
all_dependencies.add(dep)
queue.append(dep)
return all_dependencies