#!/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"\\(?Pquestion|q|partq|partheading|examsection|sect|section|subsection|subsubsection|paragraph|textbf)\*?" r"\{(?P[^{}\n]{1,120})\}" r"|\\(?Psectiona|sectionb)\b" r"|\\(?Pbegin|end)\{(?Penumerate|parts|subparts)\}(?P\[[^\]\n]*\])?" r"|\\(?Pitem)\b(?:\[(?P[^\]\n]+)\])?" r"|\((?P[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"(?= 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()