Split the code_tutor import graph and scan tables out of _analyzer

_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
This commit is contained in:
Krzysztof Rudnicki 2026-08-16 17:48:32 +02:00
parent 6c999e3242
commit c113a3a192
8 changed files with 280 additions and 173 deletions

View File

@ -0,0 +1,19 @@
{
"title": "Split code_tutor/_analyzer.py under the 250-line cap",
"objective": "Bring python_pkg/code_tutor/_analyzer.py (388 lines) under the repo-wide 250-line cap by moving the file-walking and import-graph half into _deps.py and the static scan tables into _scan_tables.py.",
"acceptance_criteria": [
"_analyzer.py, _deps.py and _scan_tables.py are each at most 250 lines.",
"The code_tutor suite still reports exactly 216 passing tests -- the same count as before the split, proving no test was dropped or silently skipped.",
"The package keeps 100% branch coverage of the code_tutor source modules.",
"_is_binary, the only name any test patches on _analyzer, stays defined there so that patch string needs no edit.",
"Every importer of the moved functions -- cli.py, _plan_builder.py and test_analyzer.py -- names _deps directly rather than reaching them through an _analyzer re-export.",
"No assertion is changed and no test is deleted, skipped or weakened.",
"No suppression is added and no coverage exclusion is introduced."
],
"out_of_scope": [
"Changing what any test asserts, or which paths the analyzer skips.",
"Splitting cli.py, _verifier.py, _llm.py or the code_tutor test modules, which are separate commits.",
"Adding a re-export shim in _analyzer."
],
"verifier": "timeout 90 env PYTHONPATH=. python3 -m pytest python_pkg/code_tutor/tests -q (expects exactly 216 passed), plus an import-time assertion that every patched name resolves, plus pre-commit run --files on the touched files."
}

View File

@ -0,0 +1,51 @@
{
"intent": "Bring code_tutor/_analyzer.py under the 250-line cap by separating the import-graph half and the static scan tables, with no behaviour change.",
"scope": [
"python_pkg/code_tutor/_deps.py, _scan_tables.py (new)",
"python_pkg/code_tutor/_analyzer.py (388 -> 226)",
"python_pkg/code_tutor/cli.py, _plan_builder.py (import paths only)",
"python_pkg/code_tutor/tests/test_analyzer.py (import path only)",
"Non-goal: no assertion changed, no test deleted, skipped or weakened"
],
"changes": [
"Moved get_python_files, _build_dotted_map, _match_deps, get_file_imports and codebase_fingerprint into _deps.py, which imports _should_skip back from _analyzer and owns the hashlib import it was the only user of.",
"Moved the _SKIP_DIRS, _SKIP_SUFFIXES and _OTHER_LANGS frozensets and the _FUNCTION_RE pattern into _scan_tables.py. The first cut left _analyzer.py at 259, nine over; these tables are pure data with no patch targets, which made them the cheapest remaining lines.",
"cli.py and _plan_builder.py import the moved functions from _deps directly. test_analyzer.py's single import block was split across the two modules.",
"_is_binary is the only name any test patches on _analyzer, and it stays there, so no patch string in the package needed editing - a much cheaper seam than the _challenge one."
],
"verification": [
{
"command": "timeout 90 env PYTHONPATH=. python3 -m pytest python_pkg/code_tutor/tests -q",
"result": "pass",
"evidence": "216 passed in 2.2s - identical to the baseline count, and inside the timeout that the previous commit's hang made standard here."
},
{
"command": "timeout 90 env PYTHONPATH=. python3 -m pytest python_pkg/code_tutor/tests -q (first run)",
"result": "fail then pass",
"evidence": "One collection ImportError: test_analyzer.py imports _build_dotted_map and four other moved names from _analyzer. Its import block was split across the two modules and the run returned to 216."
},
{
"command": "PYTHONPATH=. python3 -c \"from python_pkg.code_tutor import _analyzer as a, _deps as d, cli, _plan_builder as pb, _scan_tables as t; a._is_binary; a._SKIP_DIRS; a._FUNCTION_RE; d.get_file_imports; d._should_skip; d.hashlib; cli.codebase_fingerprint; pb.get_python_files\"",
"result": "pass",
"evidence": "all patch targets resolve - including cli.codebase_fingerprint, which three test_cli.py patches name and which still resolves because cli imports it from its new home."
},
{
"command": "wc -l python_pkg/code_tutor/_analyzer.py python_pkg/code_tutor/_deps.py python_pkg/code_tutor/_scan_tables.py",
"result": "pass",
"evidence": "226, 145 and 48 lines, all under the 250-line cap."
},
{
"command": "pre-commit run --files <the touched files and artifacts>",
"result": "pass",
"evidence": "ruff auto-sorted six import statements across the new and edited modules; clean on re-run, and pytest-with-coverage passes."
}
],
"risks": [
"_analyzer <- _deps and _analyzer <- _scan_tables must stay one-way; _analyzer importing from _deps would be a cycle, and _deps already imports _should_skip from _analyzer.",
"The scan tables are now two modules away from the extraction code that reads them, so a future skip-list change has to be made in _scan_tables rather than where it is used."
],
"rollback": [
"git revert the commit; _analyzer.py returns to 388 lines and the two new modules disappear.",
"After rollback, re-run the package suite and confirm it still reports 216 passed."
]
}

View File

@ -8,47 +8,13 @@ from __future__ import annotations
import ast import ast
from dataclasses import dataclass, field from dataclasses import dataclass, field
import hashlib
from pathlib import Path from pathlib import Path
import re
_SKIP_DIRS: frozenset[str] = frozenset( from python_pkg.code_tutor._scan_tables import (
{ _FUNCTION_RE,
"third_party", _OTHER_LANGS,
".venv", _SKIP_DIRS,
"node_modules", _SKIP_SUFFIXES,
"__pycache__",
".git",
"dist",
"build",
}
)
_SKIP_SUFFIXES: frozenset[str] = frozenset(
{
".geojson",
".png",
".jpg",
".jpeg",
".gif",
".ico",
".svg",
".webp",
".pdf",
".zip",
".tar",
".gz",
".bin",
}
)
_OTHER_LANGS: frozenset[str] = frozenset(
{".js", ".ts", ".go", ".rs", ".c", ".cpp", ".dart"}
)
# Matches leading keyword(s) followed by an identifier and opening paren.
_FUNCTION_RE: re.Pattern[str] = re.compile(
r"^[ \t]*(?:(?:pub(?:lic)?|priv(?:ate)?|prot(?:ected)?|static|async)\s+)*"
r"(?:def|function|fn|func|void|int|float|bool|string)\s+(\w+)\s*\(",
re.MULTILINE,
) )
@ -258,131 +224,3 @@ def extract_items(codebase: Path) -> list[CodeItem]:
elif path.suffix in _OTHER_LANGS: elif path.suffix in _OTHER_LANGS:
items.extend(_extract_other(path, rel)) items.extend(_extract_other(path, rel))
return items return items
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]

View File

@ -0,0 +1,145 @@
"""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]

View File

@ -6,14 +6,17 @@ import dataclasses
from datetime import datetime, timezone from datetime import datetime, timezone
from graphlib import CycleError, TopologicalSorter from graphlib import CycleError, TopologicalSorter
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
from python_pkg.code_tutor._analyzer import ( from python_pkg.code_tutor._deps import (
CodeItem,
codebase_fingerprint, codebase_fingerprint,
get_file_imports, get_file_imports,
get_python_files, get_python_files,
) )
if TYPE_CHECKING:
from python_pkg.code_tutor._analyzer import CodeItem
def _toposort_files(file_deps: dict[Path, set[Path]]) -> list[Path]: def _toposort_files(file_deps: dict[Path, set[Path]]) -> list[Path]:
"""Return files in topological order so dependencies come first. """Return files in topological order so dependencies come first.

View File

@ -0,0 +1,48 @@
"""Which paths the analyzer walks past, and how it spots a function.
Split out of :mod:`python_pkg.code_tutor._analyzer` to keep it under the
250-line cap. Pure data tables with no collaborators, so nothing patches them.
"""
from __future__ import annotations
import re
_SKIP_DIRS: frozenset[str] = frozenset(
{
"third_party",
".venv",
"node_modules",
"__pycache__",
".git",
"dist",
"build",
}
)
_SKIP_SUFFIXES: frozenset[str] = frozenset(
{
".geojson",
".png",
".jpg",
".jpeg",
".gif",
".ico",
".svg",
".webp",
".pdf",
".zip",
".tar",
".gz",
".bin",
}
)
_OTHER_LANGS: frozenset[str] = frozenset(
{".js", ".ts", ".go", ".rs", ".c", ".cpp", ".dart"}
)
# Matches leading keyword(s) followed by an identifier and opening paren.
_FUNCTION_RE: re.Pattern[str] = re.compile(
r"^[ \t]*(?:(?:pub(?:lic)?|priv(?:ate)?|prot(?:ected)?|static|async)\s+)*"
r"(?:def|function|fn|func|void|int|float|bool|string)\s+(\w+)\s*\(",
re.MULTILINE,
)

View File

@ -21,7 +21,8 @@ from rich.console import Console
from rich.table import Table from rich.table import Table
import typer import typer
from python_pkg.code_tutor._analyzer import codebase_fingerprint, extract_items from python_pkg.code_tutor._analyzer import extract_items
from python_pkg.code_tutor._deps import codebase_fingerprint
from python_pkg.code_tutor._llm import OllamaBackend from python_pkg.code_tutor._llm import OllamaBackend
from python_pkg.code_tutor._plan_builder import build_plan from python_pkg.code_tutor._plan_builder import build_plan
from python_pkg.code_tutor._progress import ( from python_pkg.code_tutor._progress import (

View File

@ -8,16 +8,18 @@ from unittest.mock import patch
from python_pkg.code_tutor._analyzer import ( from python_pkg.code_tutor._analyzer import (
CodeItem, CodeItem,
_build_dotted_map,
_extract_other, _extract_other,
_extract_python, _extract_python,
_FunctionVisitor, _FunctionVisitor,
_is_binary, _is_binary,
_make_id, _make_id,
_match_deps,
_should_skip, _should_skip,
codebase_fingerprint,
extract_items, extract_items,
)
from python_pkg.code_tutor._deps import (
_build_dotted_map,
_match_deps,
codebase_fingerprint,
get_file_imports, get_file_imports,
get_python_files, get_python_files,
) )