mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 21:27:02 +01:00
_analyzer.py 388 -> 226. The file walk, dotted-name map, import resolution and fingerprint go to _deps.py; the skip frozensets and the function regex to _scan_tables.py, which covered the last nine lines after the first cut landed at 259. _is_binary is the only name any test patches on _analyzer and it stays put, so no patch string needed editing. Only test_analyzer.py's import block had to be split. Pass count is 216 before and after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
146 lines
4.4 KiB
Python
146 lines
4.4 KiB
Python
"""Walking the codebase for Python files and mapping their import graph.
|
|
|
|
Split out of :mod:`python_pkg.code_tutor._analyzer` to keep it under the
|
|
250-line cap. Item extraction stays there; this module answers "which files
|
|
does this one depend on" and fingerprints the tree.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import hashlib
|
|
from typing import TYPE_CHECKING
|
|
|
|
from python_pkg.code_tutor._analyzer import _should_skip
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
def get_python_files(codebase: Path) -> list[Path]:
|
|
"""Return all non-skipped Python files under *codebase*.
|
|
|
|
Args:
|
|
codebase: Root directory to search.
|
|
|
|
Returns:
|
|
Sorted list of absolute Python file paths.
|
|
"""
|
|
return [
|
|
path
|
|
for path in sorted(codebase.rglob("*.py"))
|
|
if path.is_file() and not _should_skip(str(path.relative_to(codebase)))
|
|
]
|
|
|
|
|
|
def _build_dotted_map(codebase: Path, all_files: list[Path]) -> dict[Path, str]:
|
|
"""Build a mapping from file to its dotted module path relative to *codebase*.
|
|
|
|
Args:
|
|
codebase: Root directory used for relative path computation.
|
|
all_files: All Python files within the codebase.
|
|
|
|
Returns:
|
|
Dict mapping each file in *all_files* to its dotted module string.
|
|
Files that cannot be made relative to *codebase* are excluded.
|
|
"""
|
|
result: dict[Path, str] = {}
|
|
for f in all_files:
|
|
try:
|
|
rel = f.relative_to(codebase)
|
|
except ValueError:
|
|
continue
|
|
result[f] = ".".join(rel.with_suffix("").parts)
|
|
return result
|
|
|
|
|
|
def _match_deps(
|
|
imported: set[str],
|
|
dotted_map: dict[Path, str],
|
|
path: Path,
|
|
) -> set[Path]:
|
|
"""Return the subset of *dotted_map* files that *path* appears to import.
|
|
|
|
A file ``f`` is included when any imported module name equals, contains,
|
|
or is contained by ``f``'s dotted path.
|
|
|
|
Args:
|
|
imported: Set of module name strings collected from import statements.
|
|
dotted_map: Mapping of file to dotted module path.
|
|
path: The file being analysed (excluded from its own dependency set).
|
|
|
|
Returns:
|
|
Set of dependency files.
|
|
"""
|
|
deps: set[Path] = set()
|
|
for imp in imported:
|
|
for f, dotted in dotted_map.items():
|
|
if f != path and (
|
|
imp == dotted
|
|
or imp.endswith("." + dotted)
|
|
or dotted.endswith("." + imp)
|
|
):
|
|
deps.add(f)
|
|
return deps
|
|
|
|
|
|
def get_file_imports(path: Path, codebase: Path, all_files: list[Path]) -> set[Path]:
|
|
"""Parse Python imports and return the codebase files this file depends on.
|
|
|
|
Uses a suffix-match heuristic: if an imported module name ends with (or
|
|
matches) the dotted path of a file in *all_files*, that file is treated as
|
|
a dependency.
|
|
|
|
Args:
|
|
path: The Python file to analyse.
|
|
codebase: Root directory used for relative path computation.
|
|
all_files: All Python files within the codebase.
|
|
|
|
Returns:
|
|
Subset of *all_files* that *path* imports from.
|
|
"""
|
|
try:
|
|
source = path.read_text(encoding="utf-8", errors="replace")
|
|
tree = ast.parse(source)
|
|
except SyntaxError:
|
|
return set()
|
|
|
|
imported: set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
imported.add(alias.name)
|
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
imported.add(node.module)
|
|
|
|
dotted_map = _build_dotted_map(codebase, all_files)
|
|
return _match_deps(imported, dotted_map, path)
|
|
|
|
|
|
def codebase_fingerprint(codebase: Path) -> str:
|
|
"""Compute a short fingerprint of all source files under *codebase*.
|
|
|
|
Uses each file's mtime and size rather than content, so it is fast
|
|
even on large repositories. Returns a 16-character hex string that
|
|
changes whenever any tracked file is added, removed, or modified.
|
|
|
|
Args:
|
|
codebase: Root directory of the codebase.
|
|
|
|
Returns:
|
|
16-character hex fingerprint string.
|
|
"""
|
|
parts: list[str] = []
|
|
for path in sorted(codebase.rglob("*")):
|
|
if not path.is_file():
|
|
continue
|
|
rel = str(path.relative_to(codebase))
|
|
if _should_skip(rel):
|
|
continue
|
|
try:
|
|
s = path.stat()
|
|
parts.append(f"{rel}:{s.st_mtime_ns}:{s.st_size}")
|
|
except OSError:
|
|
pass
|
|
return hashlib.sha256("\n".join(parts).encode()).hexdigest()[:16]
|