codextoolsimport-nst-exam-stubs.py
tools/import-nst-exam-stubs.py
#!/usr/bin/env python3
"""Import numbered NST question trees, never statements, figures or answers.
Existing paper files are never overwritten. Update the catalogue's explicit
answer-coverage decisions before importing newly discovered sources.
"""
import argparse
from dataclasses import dataclass, field
import json
import hashlib
from pathlib import Path
import re
REPO = Path(__file__).resolve().parent.parent
TOKEN = re.compile(
r"\\(?P<command>question|q|partq|partheading|examsection|sect|section|subsection|subsubsection|paragraph|textbf)\*?"
r"\{(?P<argument>[^{}\n]{1,120})\}"
r"|\\(?P<fixed>sectiona|sectionb)\b"
r"|\\(?P<enumeration>begin|end)\{(?P<environment>enumerate|parts|subparts)\}(?P<options>\[[^\]\n]*\])?"
r"|\\(?P<item>item)\b(?:\[(?P<item_label>[^\]\n]+)\])?"
r"|\((?P<plain>[a-z]|[ivx]{2,6}|\d+)\)"
)
@dataclass
class Node:
title: str
parent: "Node | None" = None
kind: str = "part"
children: list = field(default_factory=list)
def child(self, title, kind="part"):
if any(c.title == title for c in self.children):
raise ValueError(f"Repeated label {title!r} under {self.title!r}")
node = Node(title, self, kind)
self.children.append(node)
return node
def roman(n):
result = ""
for value, text in [(100, "c"), (90, "xc"), (50, "l"), (40, "xl"),
(10, "x"), (9, "ix"), (5, "v"), (4, "iv"), (1, "i")]:
while n >= value:
result += text
n -= value
return result
def number(n, style):
if style == "roman":
return roman(n)
if style == "arabic":
return str(n)
result = ""
while n:
n, digit = divmod(n - 1, 26)
result = chr(97 + digit) + result
return result
def reviewed_numbering_tree(source, title, definition, source_sha256):
"""Accept reviewed labels only, guarded against a changed source transcription."""
digest = hashlib.sha256(source.encode()).hexdigest()
if digest != source_sha256:
raise ValueError("Reviewed numbering tree source hash does not match")
root = Node(title, kind="paper")
def append(parent, entries):
if not isinstance(entries, list):
raise ValueError("Reviewed numbering children must be a list")
for item in entries:
if set(item) != {"title", "kind", "children"}:
raise ValueError("Reviewed numbering nodes contain labels only")
label, kind = item["title"], item["kind"]
if kind not in {"section", "question", "part"} or not re.fullmatch(
r"(?:Section [A-Z0-9]+(?:\([ivx]+\))?|[A-E]?\d+(?:\.\d+)*[A-Z]?|[a-z]+(?: \([A-Za-z0-9 ]{1,64}\))?|[A-Z]+)", label
):
raise ValueError(f"Unsupported reviewed label/kind: {label!r}/{kind!r}")
child = parent.child(label, kind)
append(child, item["children"])
append(root, definition)
if not any(n is not root and not n.children and n.kind != "section" for n in walk(root)):
raise ValueError("Reviewed numbering tree contains no tasks")
return root
def question_tree(source, title, *, reference_enumerations=(), inline_subparts=False,
numbering_tree=None, source_sha256=None):
"""Read labels only; ambiguous inline/reference lists require paper options."""
if numbering_tree is not None:
if reference_enumerations or inline_subparts:
raise ValueError("Reviewed trees cannot be combined with inferred parser options")
return reviewed_numbering_tree(source, title, numbering_tree, source_sha256)
if source_sha256 is not None:
raise ValueError("Source hash without a reviewed numbering tree")
source = re.sub(r"(?<!\\)%[^\n]*", "", source)
preamble, source = source.split(r"\begin{document}", 1)
source = source.split(r"\end{document}", 1)[0]
# Read only these literal declarations, never execute or expand arbitrary TeX.
custom_options = dict(re.findall(
r"\\newenvironment\{(parts|subparts)\}\{\s*\\begin\{enumerate\}"
r"(\[[^\]\n]*\])\s*\}\{\s*\\end\{enumerate\}\s*\}", preamble
))
prefix = re.search(
r"\\newcommand\{\\question\}\[1\]\{\s*\\clearpage\s*"
r"\\section\*?\{([A-E])#1\}\s*\}", preamble
)
question_prefix = prefix[1] if prefix else ""
# An unstyled list can contain supplied data instead of tasks. Its exact
# question/part path must be explicitly reviewed; all other such lists fail.
references = {tuple(path) for path in reference_enumerations}
used_references = set()
# This supplied list consists of integration identities, not exam subparts.
source = re.sub(r"\[You may find the following results useful:[\s\S]*?\]", "", source)
inline_labels = set()
# Inline labels need an explicit paper-level review: elsewhere the same
# typography may denote equations, cases or references rather than subparts.
for line in re.finditer(r"[^\n]+", source) if inline_subparts else ():
for pattern, formatter in [(r"[ivx]+", roman), (r"\d+", str)]:
labels = list(re.finditer(r"(?<![\w)])\((" + pattern + r")\)", line[0]))
if len(labels) >= 2 and [m[1] for m in labels] == [formatter(i) for i in range(1, len(labels) + 1)]:
inline_labels.update(line.start() + m.start() for m in labels)
root = Node(title, kind="paper")
section, question, part, roman_parent = None, None, None, None
stack = []
last_end = 0
for match in TOKEN.finditer(source):
command, argument = match["command"], match["argument"]
if stack and stack[-1].get("reference"):
if match["enumeration"] == "begin":
raise ValueError("Nested enumeration inside a reviewed reference list")
if match["enumeration"] == "end":
if match["environment"] != stack[-1]["environment"]:
raise ValueError("Mismatched reference-list environment")
stack.pop()
continue
section_label = None
if match["fixed"]:
section_label = match["fixed"][-1].upper()
elif command in {"examsection", "sect"}:
section_label = argument
elif command in {"section", "subsection", "subsubsection", "textbf"}:
hit = re.fullmatch(r"Section\s+([A-E]|[IVX]+|\d+)(?:\s+[^{}]*)?", argument, re.I)
if hit:
section_label = hit[1].upper()
if section_label:
if stack:
raise ValueError("Section begins inside an enumeration")
section = root.child("Section " + section_label, "section")
question = part = roman_parent = None
elif command == "paragraph" and (combined := re.fullmatch(
r"(?:Question\s+)?([A-E]?\d+)((?:\([a-z0-9]+\))+)", argument, re.I
)):
if stack:
raise ValueError("Paragraph label begins inside an enumeration")
parent = section or root
if question is None or question.title != combined[1]:
question = parent.child(combined[1], "question")
labels = re.findall(r"\(([^)]+)\)", combined[2])
part = question
for i, label in enumerate(labels):
existing = next((c for c in part.children if c.title == label), None)
part = existing if existing and i < len(labels) - 1 else part.child(label)
roman_parent = None
elif command in {"question", "q", "section", "subsection", "textbf"} and re.fullmatch(
r"(?:Question\s+)?[A-E]?\d+\.?", argument or "", re.I
):
if stack:
raise ValueError("Question begins inside an enumeration")
label = re.sub(r"^Question\s+|\.$", "", argument, flags=re.I)
if command == "question" and label.isdigit():
label = question_prefix + label
question = (section or root).child(label, "question")
part = roman_parent = None
elif command == "partheading":
# The practical notebooks print complete labels such as A1a.
# A whole-question mark heading, e.g. D12, adds no new node.
if question is None or stack:
raise ValueError("Practical part heading without an outer question")
if argument != question.title:
labelled = re.fullmatch(r"([A-E]?\d+)([a-z])", argument)
if labelled is None or labelled[1] != question.title:
raise ValueError(f"Practical part label does not match its question: {argument}")
part = question.child(labelled[2])
roman_parent = None
elif command == "partq":
if question is None:
raise ValueError("Part without a question")
part = question.child(argument.strip("()"))
roman_parent = None
elif match["enumeration"] == "begin":
if question is None:
raise ValueError("Enumeration before the first question")
options = match["options"] or ""
environment = match["environment"]
if environment != "enumerate":
if environment not in custom_options or options:
raise ValueError(f"Unsupported declaration/options for {environment}")
options = custom_options[environment]
style = next((s for s in ["roman", "arabic", "alph"] if "\\" + s in options), None)
if style is None:
node = part or question
path = []
while node is not root:
path.append(node.title)
node = node.parent
path = tuple(reversed(path))
if environment == "enumerate" and not options and path in references and path not in used_references:
used_references.add(path)
stack.append(dict(reference=True, environment=environment))
continue
raise ValueError("Enumeration needs an explicit numbering style or reviewed reference path")
start = re.search(r"start\s*=\s*(\d+)", options)
parent = stack[-1]["current"] if stack else (part or question)
if parent is None:
raise ValueError("Nested enumeration before its parent item")
# A restarted outer enumerate continues the question's alphabetic parts.
if not stack and style == "alph":
parent = question
stack.append(dict(parent=parent, current=None, style=style,
environment=environment,
counter=int(start[1]) - 1 if start else 0))
elif match["enumeration"] == "end":
if not stack:
raise ValueError("Unmatched end of enumeration")
if match["environment"] != stack[-1]["environment"]:
raise ValueError("Mismatched enumeration environment")
stack.pop()
part = stack[-1]["current"] if stack else None
roman_parent = None
elif match["item"] and stack:
frame = stack[-1]
frame["counter"] += 1
label = (match["item_label"] or number(frame["counter"], frame["style"])).strip("().")
part = frame["parent"].child(label)
frame["current"] = part
roman_parent = None
elif match["plain"] and question:
line_prefix = source[source.rfind("\n", 0, match.start()) + 1:match.start()]
inline = match.start() in inline_labels
if match["plain"].isdigit() and not inline:
continue
if not inline and line_prefix.strip() not in ("", "EITHER", "OR") and source[last_end:match.start()].strip():
continue
label = match["plain"]
is_roman = inline or len(label) > 1 or (label == "i" and not any(c.title == "h" for c in question.children))
if is_roman:
if label == "i" or roman_parent is None:
roman_parent = (stack[-1]["current"] if stack else part) or question
part = roman_parent.child(label)
else:
part = question.child(label)
roman_parent = None
else:
continue
last_end = match.end()
if stack:
raise ValueError("Unclosed enumeration")
if used_references != references:
raise ValueError(f"Unused reviewed reference paths: {sorted(references - used_references)}")
if not any(node.kind == "question" for node in walk(root)):
raise ValueError("No questions found")
return root
def walk(node):
yield node
for child in node.children:
yield from walk(child)
def render(tree, source, *, title2=None, fixedcaps=False, root_parent=None):
metadata = "{scope}" + ("\n{c}" if fixedcaps else "")
if title2:
if "\n" in title2 or "{" in title2 or "}" in title2:
raise ValueError("Paper subtitle must be plain single-line text")
metadata += f"\n{{title2={title2}}}"
blocks = [f"= {tree.title}\n{metadata}", source]
for node in list(walk(tree))[1:]:
# Some papers repeat printed a/b labels within one question. Keep the
# reviewed qualifier in metadata so it does not change the printed label.
qualified = re.fullmatch(r"([a-z]+) \(([A-Za-z0-9 ]{1,64})\)", node.title)
title = qualified[1] if qualified else node.title
disambiguate = f"\n{{disambiguate={qualified[2]}}}" if qualified else ""
capitalization = "{c}\n" if re.fullmatch(r"(?:[A-E]\d+|\d+[A-Z])", node.title) else ""
parent = root_parent if node.parent is tree and root_parent else node.parent.title
blocks.append(f"= {title}\n{capitalization}{{parent={parent}}}{disambiguate}\n{{scope}}")
if not node.children and node.kind != "section":
blocks.append(f"= Solution\n{{parent={node.title}}}")
return "\n\n".join(blocks) + "\n"
def update_index(path, title, note, years, check):
includes = "\n".join(f"\\Include[{year}]" for year in sorted(years, reverse=True))
if path.exists():
old = path.read_text()
current = re.findall(r"^\\Include\[(\d{4})\]$", old, re.M)
if current == sorted(years, reverse=True):
return
if check:
raise ValueError(f"Outdated year index: {path}")
old = re.sub(r"^\\Include\[\d{4}\]\n?", "", old, flags=re.M)
path.write_text(old.rstrip() + "\n\n" + includes + "\n")
elif check:
raise ValueError(f"Missing index: {path}")
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"= {title}\n\n" + (note + "\n\n" if note else "") + includes + "\n")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tex-root", type=Path,
default=REPO.parent / "exams/cambridge-exams/tex/nst")
parser.add_argument("--check", action="store_true", help="check existing trees without writing")
args = parser.parse_args()
catalogue = json.loads(Path(__file__).with_name("nst-exam-catalog.json").read_text())
known_courses = {c["source_path"] for c in catalogue["courses"]}
discovered = {"/".join(p.relative_to(args.tex_root).parts[:2])
for p in args.tex_root.rglob("*.tex")}
if discovered - known_courses:
raise ValueError(f"Course placement and coverage need cataloguing: {sorted(discovered - known_courses)}")
counts = dict(created=0, checked=0, skipped=0, questions=0, parts=0)
for course in catalogue["courses"]:
folder = REPO / course["path"]
years = set()
if course.get("existing_mathematics"):
years = {p.name for p in folder.iterdir() if p.is_dir() and p.name.isdigit()}
else:
for path in sorted((args.tex_root / course["source_path"]).rglob("*.tex")):
rel = path.relative_to(args.tex_root).as_posix()
year = next(p for p in path.relative_to(args.tex_root).parts if re.fullmatch(r"\d{4}(?:\.tex)?", p)).removesuffix(".tex")
if year in course.get("skip_years", []):
counts["skipped"] += 1
continue
paper = path.stem if path.stem != year else None
paper_options = catalogue.get("paper_options", {}).get(rel, {})
paper = paper_options.get("paper", paper)
title = paper_options.get("title", "Paper " + paper if paper else year)
render_options = {key: paper_options[key] for key in ("title2", "fixedcaps", "root_parent")
if key in paper_options}
output = folder / year / ("paper-" + paper + ".bigb") if paper else folder / (year + ".bigb")
# Keep already authored trees, but do not create duplicates when
# the course advertises student answers for this paper's year.
if catalogue.get("availability", {}).get(rel, {}).get("decision") == "skip" and not output.exists():
counts["skipped"] += 1
continue
tree = question_tree(path.read_text(), title,
**catalogue.get("parser_options", {}).get(rel, {}))
counts["questions"] += sum(n.kind == "question" for n in walk(tree))
counts["parts"] += sum(n.kind == "part" for n in walk(tree))
if rel not in catalogue["papers"]:
raise ValueError(f"Source links and solution coverage need cataloguing: {rel}")
source = catalogue["papers"][rel]
if output.exists():
# Compare header trees only: future authored solution prose is preserved.
actual = re.findall(r"^= .*\n(?:\{[^\n]*\}\n)*", output.read_text(), re.M)
expected = re.findall(r"^= .*\n(?:\{[^\n]*\}\n)*", render(tree, source, **render_options), re.M)
if actual != expected:
raise ValueError(f"Header tree differs from transcription: {output}")
counts["checked"] += 1
elif args.check:
raise ValueError(f"Missing paper tree: {output}")
else:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(render(tree, source, **render_options))
counts["created"] += 1
years.add(year)
if paper:
papers = [p.stem for p in output.parent.glob("paper-*.bigb")]
index = output.parent / "index.bigb"
includes = "\n".join(f"\\Include[{p}]" for p in sorted(papers)) + "\n"
old = index.read_text() if index.exists() else f"= {year}\n{{scope}}\n\n"
current = re.findall(r"^\\Include\[(paper-[^\]\n]+)\]$", old, re.M)
if current == sorted(papers):
continue
if args.check:
raise ValueError(f"Outdated paper index: {index}")
else:
old = re.sub(r"^\\Include\[paper-[^\]\n]+\]\n?", "", old, flags=re.M)
index.write_text(old.rstrip() + "\n\n" + includes)
if years:
update_index(folder / "index.bigb", course["exam_title"], course.get("note", ""), years, args.check)
print(json.dumps(counts))
if __name__ == "__main__":
main()