"""Self-check checkpoint machinery: load, grade, serialise. The **questions themselves** are the single source of truth, and they live in each unit's ``activity.md`` as ```checkpoint JSON blocks. This module loads them (see ``_load``), grades answers, and serialises them; the Python route (``selfcheck.py``) imports it directly, and the browser route (``build/site/progress.html`` and each unit page) is generated from the same data by ``scripts/build_pages.py`` and ``scripts/build_unit_pages.py``. Loading rather than duplicating is what keeps the two routes from drifting apart. The whole course is present in both delivery modes -- a git clone runs this against ``../unit-*/activity.md``; a website bakes the questions into the browser page -- so no separate data file is needed. Standard library only. Design rules, taken from the instructor review: * Every unit has at least one checkpoint a learner can pass or fail. * Feedback is diagnostic. A wrong answer that matches a known misconception gets that misconception named, not a generic "incorrect". * Where deriving the value *is* the learning objective (``cp06-b4-by-hand``), a failure never reveals the answer. It gives the next step instead. * Answers live in the activity in plain sight. This is a formative self-check, not an exam; a learner who reads the source to find the answer has spent their own time to avoid learning something, and the course says so out loud in Unit 17. """ from __future__ import annotations import json import re from dataclasses import asdict, dataclass, field from fractions import Fraction from pathlib import Path __all__ = ["CHECKPOINTS", "Checkpoint", "by_id", "by_unit", "to_json", "grade"] @dataclass class Checkpoint: """One self-checkable question.""" id: str unit: int title: str kind: str # fraction | integer | choice | multi | match | code question: str #: For choice/multi/match: the options shown to the learner. options: list[str] = field(default_factory=list) #: fraction -> canonical string; integer -> int; choice -> index; #: multi -> sorted indices; match -> list of option indices per prompt. answer: object = None #: For ``match``: the left-hand prompts that each need an option assigned. prompts: list[str] = field(default_factory=list) #: Wrong answers worth naming, keyed by the *canonical* answer string. #: #: IMPORTANT for authors: the canonical form is what :func:`canonicalise` #: produces, which uses **zero-based** option indices -- not the one-based #: numbers the learner sees on screen. So the key for "the learner picked #: option (3)" is ``"2"``, and for a match answer "(1),(2),(3)" it is #: ``"0,1,2"``. For ``fraction`` and ``integer`` checkpoints the key is just #: the answer itself, e.g. ``"1/30"``. #: #: Getting this off by one silently disables the diagnostic, so #: ``tests/test_checkpoints.py`` checks that every key is in range, is not #: the correct answer, and actually fires. diagnostics: dict[str, str] = field(default_factory=dict) #: Shown when the learner cannot progress; must not give the answer away for #: derivation checkpoints. hint: str = "" #: Shown after a correct answer, to consolidate the point. explanation: str = "" #: Path to the exercise stub, for ``code`` checkpoints. exercise: str = "" #: Which verification route this checkpoint belongs to, where it belongs to #: only one: "python" for checkpoints that need code run, "table" for their #: browser-assessable counterparts. Empty means both routes take it. #: #: Unit 8 is the only place the two routes assess different things, and this #: is what says so in the data rather than in a hand-maintained list on the #: record page -- which is where it used to live, and where it could not be #: checked against anything. route: str = "" def as_dict(self) -> dict: return asdict(self) COURSE_ROOT = Path(__file__).resolve().parent.parent _CHECKPOINT_BLOCK = re.compile(r"```checkpoint\n(.*?)\n```", re.S) def _load() -> "list[Checkpoint]": """Load every checkpoint from the units' activity.md checkpoint blocks. Each unit's ``activity.md`` carries its self-check questions as ```checkpoint JSON blocks in the answers section. Reading them here makes that the single source: the terminal self-check (this module) and the browser self-check (generated from it) both come from the activity they belong to and cannot drift from it. Standard library only, so the downloaded lab needs nothing installed. """ checkpoints: list[Checkpoint] = [] for activity in sorted(COURSE_ROOT.glob("unit-*/activity.md")): body = activity.read_text() for match in _CHECKPOINT_BLOCK.finditer(body): checkpoints.append(Checkpoint(**json.loads(match.group(1)))) return checkpoints CHECKPOINTS: list[Checkpoint] = _load() def by_id(checkpoint_id: str) -> Checkpoint: """Look up a checkpoint by id, raising :class:`KeyError` if absent.""" for checkpoint in CHECKPOINTS: if checkpoint.id == checkpoint_id: return checkpoint raise KeyError(checkpoint_id) def by_unit(unit: int) -> list[Checkpoint]: """All checkpoints belonging to one unit, in order.""" return [c for c in CHECKPOINTS if c.unit == unit] def canonicalise(checkpoint: Checkpoint, raw: str) -> object: """Turn a learner's raw text answer into the comparable canonical form. Raises :class:`ValueError` when the input cannot be interpreted at all, so the caller can tell "wrong answer" apart from "not a valid answer". """ text = raw.strip().replace(" ", "") if not text: raise ValueError("empty answer") if checkpoint.kind == "fraction": return str(Fraction(text)) if checkpoint.kind == "integer": return int(text) if checkpoint.kind == "choice": # Accept either a 1-based number as displayed, or a letter. if text.isdigit(): index = int(text) - 1 elif len(text) == 1 and text.isalpha(): index = ord(text.lower()) - ord("a") else: raise ValueError(f"cannot read {raw!r} as an option") if not 0 <= index < len(checkpoint.options): raise ValueError(f"option {raw!r} is out of range") return index if checkpoint.kind in ("multi", "match"): parts = [p for p in text.replace(";", ",").split(",") if p] indices = [] for part in parts: if part.isdigit(): indices.append(int(part) - 1) elif len(part) == 1 and part.isalpha(): indices.append(ord(part.lower()) - ord("a")) else: raise ValueError(f"cannot read {part!r} as an option") if any(not 0 <= i < len(checkpoint.options) for i in indices): raise ValueError(f"an option in {raw!r} is out of range") if checkpoint.kind == "multi": return sorted(set(indices)) if len(indices) != len(checkpoint.prompts): raise ValueError( f"expected {len(checkpoint.prompts)} answers, got {len(indices)}" ) return indices raise ValueError(f"checkpoint {checkpoint.id} is not answered by typing text") def _diagnostic_key(checkpoint: Checkpoint, given: object) -> str: if checkpoint.kind in ("multi", "match"): return ",".join(str(i) for i in given) # type: ignore[union-attr] if checkpoint.kind == "choice": return str(given) return str(given) def grade(checkpoint: Checkpoint, raw: str) -> tuple[bool, str]: """Grade a raw answer. Returns ``(passed, message)``. The message names the specific misconception when the wrong answer is one the course expects. """ try: given = canonicalise(checkpoint, raw) except ValueError as exc: return False, f"Could not read that answer: {exc}" except (ZeroDivisionError, ArithmeticError) as exc: return False, f"Could not read that answer as a number: {exc}" if given == checkpoint.answer: return True, checkpoint.explanation key = _diagnostic_key(checkpoint, given) if key in checkpoint.diagnostics: return False, checkpoint.diagnostics[key] if checkpoint.hint: return False, f"Not right yet. {checkpoint.hint}" return False, "Not right yet. Re-read the question and try once more." def to_json(indent: int | None = 2) -> str: """Serialise every checkpoint, for generating the browser self-check.""" payload = [] for checkpoint in CHECKPOINTS: data = checkpoint.as_dict() # Fractions are already canonical strings; indices are ints. Everything # here is JSON-native by construction. payload.append(data) return json.dumps(payload, indent=indent) if __name__ == "__main__": units = sorted({c.unit for c in CHECKPOINTS}) print(f"{len(CHECKPOINTS)} checkpoints across units {units[0]}-{units[-1]}:") for checkpoint in CHECKPOINTS: print(f" unit {checkpoint.unit} {checkpoint.id:<20} {checkpoint.title}")