testsAndMisc/python_pkg/code_tutor/_plan_builder.py
Krzysztof kuhy Rudnicki adf2e402df Grade against 3.14 too, not just run on it
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading `meta/` against 3.10 -- three tools judging an interpreter
nothing runs any more, and ruff withholding the pyupgrade fixes that only
become safe at the newer target. `pin_ci_python.py` rewrites these three knobs.

What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC`
is now correct (UP017), and ruff's stable formatter applying PEP 758 --
`except (A, B):` becomes `except A, B:`. That makes the source 3.14-only at the
syntax level, which is what `requires-python = ">=3.14"` already promised; kuhy
chose it deliberately over holding ruff a release back (2026-08-29).

Also drops a comment in `wsg_grabber/models.py` that the bump makes false: it
explained why the file used ``str, Enum`` instead of ``StrEnum`` under mypy at
3.10, sitting directly above a ``class Verdict(StrEnum)`` that had already
stopped obeying it.

ruff, ruff-format, mypy --strict and pylint all clean at the new target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
2026-08-29 11:50:39 +02:00

143 lines
4.4 KiB
Python

"""Study plan construction: dependency ordering and session grouping."""
from __future__ import annotations
import dataclasses
from datetime import UTC, datetime
from graphlib import CycleError, TopologicalSorter
from pathlib import Path
from typing import TYPE_CHECKING
from python_pkg.code_tutor._deps import (
codebase_fingerprint,
get_file_imports,
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]:
"""Return files in topological order so dependencies come first.
Args:
file_deps: Mapping of file → set of files it depends on.
Returns:
Files ordered for learning (dependencies before dependents).
Cycles are broken by appending remaining nodes in arbitrary order.
"""
str_deps: dict[str, set[str]] = {
str(k): {str(v) for v in vs} for k, vs in file_deps.items()
}
try:
ordered_strs = list(TopologicalSorter(str_deps).static_order())
except CycleError:
ordered_strs = list(str_deps)
return [Path(s) for s in ordered_strs]
def _order_items(items: list[CodeItem], codebase: Path) -> list[CodeItem]:
"""Sort *items* in dependency-first order.
Python files are ordered by import relationships. Other files are
sorted by directory depth (shallowest first) then filename.
Args:
items: Unsorted list of code items.
codebase: Root directory of the codebase.
Returns:
Items in recommended learning order.
"""
if not items:
return []
py_files = get_python_files(codebase)
py_rel_set: set[str] = set()
file_order: dict[str, int] = {}
if py_files:
file_deps: dict[Path, set[Path]] = {
f: get_file_imports(f, codebase, py_files) for f in py_files
}
ordered_files = _toposort_files(file_deps)
for i, f in enumerate(ordered_files):
if f.is_relative_to(codebase):
rel = str(f.relative_to(codebase))
file_order[rel] = i
py_rel_set.add(rel)
def _sort_key(item: CodeItem) -> tuple[int, int, str, int]:
if item.file in py_rel_set:
order = file_order.get(item.file, 9999)
else:
order = len(Path(item.file).parts) * 1000
return (order, len(Path(item.file).parts), item.file, item.start_line)
return sorted(items, key=_sort_key)
def _session_title(items: list[CodeItem]) -> str:
"""Derive a human-readable session title from items' file paths.
Args:
items: Items that will belong to the session.
Returns:
A short title string based on the common directory or first file stem.
"""
if not items:
return "untitled"
dirs = [str(Path(it.file).parent) for it in items]
common = dirs[0]
for d in dirs[1:]:
if d != common:
parent = Path(common).parent
common = "." if parent == Path() or str(parent) == common else str(parent)
if common in {".", ""}:
return Path(items[0].file).stem
return Path(common).name or Path(items[0].file).stem
def build_plan(
codebase: Path,
items: list[CodeItem],
*,
session_size: int = 10,
) -> dict[str, object]:
"""Assemble a learning plan from extracted code items.
Args:
codebase: Root directory of the codebase.
items: All extracted items (sorted internally by dependency order).
session_size: Maximum number of items per session.
Returns:
Dict matching the ``plan.json`` schema ready for JSON serialisation.
"""
ordered = _order_items(items, codebase)
sessions: list[dict[str, object]] = []
for session_idx in range(0, max(len(ordered), 1), session_size):
chunk = ordered[session_idx : session_idx + session_size]
if not chunk:
break
session_id = session_idx // session_size + 1
sessions.append(
{
"id": session_id,
"title": _session_title(chunk),
"items": [dataclasses.asdict(it) for it in chunk],
}
)
return {
"codebase_path": str(codebase.resolve()),
"created_at": datetime.now(tz=UTC).isoformat(timespec="seconds"),
"total_items": len(ordered),
"source_fingerprint": codebase_fingerprint(codebase),
"sessions": sessions,
}