testsAndMisc/python_pkg/code_tutor/_progress.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

228 lines
6.7 KiB
Python

"""Persist and load study plans and progress state.
Storage layout::
~/.config/code_tutor/
└── <sha256 of absolute codebase path>/
├── plan.json # created by ``analyze``
├── progress.json # updated after each lesson
└── sessions/
└── YYYY-MM-DD.jsonl # full lesson records for analysis
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
import datetime
import hashlib
import json
from pathlib import Path
from typing import TypedDict, cast
from python_pkg.code_tutor._analyzer import CodeItem
# ---------------------------------------------------------------------------
# TypedDicts matching the plan.json / progress.json schemas
# ---------------------------------------------------------------------------
class ItemData(TypedDict):
"""Schema for a single code item entry in ``plan.json``."""
id: str
file: str
type: str
name: str
start_line: int
end_line: int
class_name: str
depends_on: list[str]
class SessionData(TypedDict):
"""Schema for a session block in ``plan.json``."""
id: int
title: str
items: list[ItemData]
class PlanData(TypedDict):
"""Top-level schema of ``plan.json``."""
codebase_path: str
created_at: str
total_items: int
source_fingerprint: str
sessions: list[SessionData]
class ProgressData(TypedDict):
"""Schema of ``progress.json`` tracking what the user has studied."""
learned: list[str]
struggled: list[str]
skipped: list[str]
last_session: str
# ---------------------------------------------------------------------------
# Full lesson record — written to sessions/YYYY-MM-DD.jsonl for analysis
# ---------------------------------------------------------------------------
@dataclass
class LessonRecord:
"""Complete transcript of one Socratic lesson.
Stored as a JSON line so the file can be streamed / analysed later.
Attributes:
timestamp: ISO-8601 UTC timestamp of the lesson.
item_id: Dotted ID of the code item studied.
file: Relative path of the source file.
lines: ``"start-end"`` line range string.
snippet: The exact source text shown to the user.
outcome: ``"learned"``, ``"struggled"``, or ``"skipped"``.
answers: Mapping of question label to the user's answer.
improvement: User's free-text code improvement idea (may be empty).
verdict: Final LLM verdict: ``"PASS"``, ``"FAIL"``, or ``"skipped"``.
attempt: Which attempt produced the final verdict (1-3).
"""
timestamp: str
item_id: str
file: str
lines: str
snippet: str
outcome: str
answers: dict[str, str]
improvement: str
verdict: str
attempt: int
challenge_result: str = ""
def append_session_record(codebase: Path, record: LessonRecord) -> None:
"""Append *record* as a JSON line to today's session log for *codebase*.
Creates ``sessions/`` if it does not exist. Each line is a self-contained
JSON object so the file can be streamed with ``jq``.
Args:
codebase: Root directory of the codebase being studied.
record: Lesson record to persist.
"""
sessions_dir = config_dir(codebase) / "sessions"
sessions_dir.mkdir(parents=True, exist_ok=True)
today = datetime.datetime.now(tz=datetime.UTC).date().isoformat()
log_file = sessions_dir / f"{today}.jsonl"
line = json.dumps(asdict(record), ensure_ascii=False)
with log_file.open("a", encoding="utf-8") as fh:
fh.write(line + "\n")
# ---------------------------------------------------------------------------
# Config directory helpers
# ---------------------------------------------------------------------------
def config_dir(codebase: Path) -> Path:
"""Return the config directory for *codebase*.
The directory name is the SHA-256 hex digest of the absolute path string,
so different codebases never collide.
Args:
codebase: Root directory of the codebase being studied.
Returns:
``~/.config/code_tutor/<hex>/`` (not guaranteed to exist).
"""
path_hash = hashlib.sha256(str(codebase.resolve()).encode()).hexdigest()
return Path.home() / ".config" / "code_tutor" / path_hash
def load_plan(codebase: Path) -> PlanData | None:
"""Load the saved plan for *codebase*, or return ``None`` if absent.
Args:
codebase: Root directory of the codebase.
Returns:
Parsed ``PlanData`` dict, or ``None`` when no plan file exists.
"""
plan_file = config_dir(codebase) / "plan.json"
if not plan_file.exists():
return None
return cast("PlanData", json.loads(plan_file.read_text(encoding="utf-8")))
def save_plan(codebase: Path, plan: dict[str, object]) -> None:
"""Persist *plan* to ``plan.json`` for *codebase*.
Creates the config directory if it does not already exist.
Args:
codebase: Root directory of the codebase.
plan: Plan dict (as returned by ``build_plan``).
"""
dest = config_dir(codebase)
dest.mkdir(parents=True, exist_ok=True)
(dest / "plan.json").write_text(json.dumps(plan, indent=2), encoding="utf-8")
def load_progress(codebase: Path) -> ProgressData:
"""Load progress for *codebase*, returning an empty record if none exists.
Args:
codebase: Root directory of the codebase.
Returns:
``ProgressData`` dict with learned/struggled/skipped lists.
"""
progress_file = config_dir(codebase) / "progress.json"
if not progress_file.exists():
return {
"learned": [],
"struggled": [],
"skipped": [],
"last_session": "",
}
return cast("ProgressData", json.loads(progress_file.read_text(encoding="utf-8")))
def save_progress(codebase: Path, progress: ProgressData) -> None:
"""Write *progress* to ``progress.json`` for *codebase*.
Args:
codebase: Root directory of the codebase.
progress: Progress dict to persist.
"""
dest = config_dir(codebase)
dest.mkdir(parents=True, exist_ok=True)
(dest / "progress.json").write_text(
json.dumps(progress, indent=2), encoding="utf-8"
)
def item_from_data(data: ItemData) -> CodeItem:
"""Convert an ``ItemData`` TypedDict to a ``CodeItem`` dataclass.
Args:
data: Dict loaded from ``plan.json``.
Returns:
Corresponding ``CodeItem`` with all fields populated.
"""
return CodeItem(
id=data["id"],
file=data["file"],
type=data["type"],
name=data["name"],
start_line=data["start_line"],
end_line=data["end_line"],
class_name=data["class_name"],
depends_on=data["depends_on"],
)