Source code for nbgrader_jupyterquiz.grader.preprocessor

"""Nbgrader preprocessor that converts markdown quiz regions into interactive quizzes."""

import itertools
import json
import pathlib
import pprint
from textwrap import dedent

import nbformat.v4
from nbconvert.exporters.exporter import ResourcesDict
from nbformat.notebooknode import NotebookNode
from nbgrader import utils
from nbgrader.preprocessors.base import NbGraderPreprocessor
from traitlets import Bool, Unicode

from nbgrader_jupyterquiz.grader import encode, parse


[docs] class CreateQuiz(NbGraderPreprocessor): """ Convert markdown quiz regions to interactive jupyterquiz cells. Register in ``nbgrader_config.py`` at the **front** of the ``GenerateAssignment`` preprocessor list so that auto-generated autograder cells are picked up by ``SaveCells`` (into the gradebook) and ``ClearHiddenTests`` (to strip the grading body from the release):: c.GenerateAssignment.preprocessors.insert(0, "nbgrader_jupyterquiz.CreateQuiz") Appending instead of inserting (``.append(...)``) will cause autograde to fail because our cells wouldn't be in the gradebook and their checksums wouldn't match the release. """ begin_quiz_delimiter = Unicode( "#### Quiz", help="The delimiter marking the beginning of quiz source.", ).tag(config=True) end_quiz_delimiter = Unicode( "#### End Quiz", help="The delimiter marking the end of quiz source.", ).tag(config=True) enforce_metadata = Bool( True, help=dedent( """ Whether to raise an error if cells containing quiz regions are not marked as 'Manually Graded Task' cells. Only disable this if you are using nbgrader generate_assignment without the full grading pipeline. """ ), ).tag(config=True) auto_generate_tests = Bool( True, help=dedent( """ Whether to auto-generate autograder-cell content when the host task cell carries an nbgrader ``grade_id``. When ``True`` (the default), each generated ``display_quiz`` code cell also becomes the nbgrader-tracked graded cell: its source carries a ``### BEGIN HIDDEN TESTS`` block that embeds the answer key and calls :func:`~nbgrader_jupyterquiz.grade_quiz` — the block is stripped from the release by ``ClearHiddenTests`` and restored at autograde time by ``OverwriteCells``. A bare ``_result.score`` expression at the end of the cell feeds nbgrader's partial-credit scoring (``utils.determine_grade``). The task cell's own ``points`` field is left untouched so it can still be manually graded alongside the auto-graded quiz score. Disable to opt out of auto-grading and keep ``display_quiz`` as a plain (non-graded) code cell; in that mode instructors author their own autograded test cells. """ ), ).tag(config=True) # Current notebook name and quiz-cell counter (reset per notebook via preprocess). name = "" quiz_cell_counter = itertools.count()
[docs] def preprocess(self, nb: NotebookNode, resources: ResourcesDict) -> tuple[NotebookNode, ResourcesDict]: """ Process all cells in the notebook, expanding quiz regions. Parameters ---------- nb : NotebookNode Source notebook. resources : ResourcesDict Nbgrader resources dict (provides ``unique_key``). Returns ------- NotebookNode, ResourcesDict Modified notebook with quiz cells appended after each quiz region. """ nb, resources = super().preprocess(nb, resources) if "celltoolbar" in nb.metadata: del nb.metadata["celltoolbar"] self.name = resources["unique_key"] new_cells: list[NotebookNode] = [] imported = False for cell in nb["cells"]: quizzes, cell_contents = self._safe_parse_cell(cell) quiz_cells: list[NotebookNode] = [] if quizzes: quiz_cells, imported = self._handle_quiz_cell( cell, quizzes, cell_contents, imported, ) cell.source = "\n".join(cell_contents) new_cells.append(cell) new_cells.extend(quiz_cells) nb["cells"] = new_cells return nb, resources
def _safe_parse_cell(self, cell: NotebookNode) -> tuple[list[parse.Quiz], list[str]]: """ Parse a cell, reraising ParseErrors on task cells and swallowing elsewhere. Any non-fatal warnings raised by the parser (e.g. MC with 0 or 1 correct answers) are re-emitted through ``self.log.warning`` so they surface in nbgrader's UI output. Fatal parse errors on task cells are logged through ``self.log.error`` before being re-raised. Parameters ---------- cell : NotebookNode Cell to parse. Returns ------- quizzes : list[parse.Quiz] Empty when the cell has no quiz region or cannot be parsed and is not a task cell. cell_contents : list[str] Remaining lines of the cell with quiz regions stripped. """ grade_id = cell.metadata.get("nbgrader", {}).get("grade_id") cell_ref = f"cell {grade_id!r}" if grade_id else "cell (unnamed)" try: quizzes, cell_contents = parse.parse_cell( cell.source, self.begin_quiz_delimiter, self.end_quiz_delimiter, ) except parse.ParseError as err: if utils.is_task(cell): self.log.error("Quiz parse error in %s: %s", cell_ref, err) raise if not self.enforce_metadata: self.log.warning("Cell could not be parsed, but metadata enforcement is off.") return [], cell.source.split("\n") for quiz in quizzes: for warning in quiz.warnings: self.log.warning("%s: %s", cell_ref, warning) return quizzes, cell_contents def _handle_quiz_cell( self, cell: NotebookNode, quizzes: list[parse.Quiz], cell_contents: list[str], imported: bool, ) -> tuple[list[NotebookNode], bool]: """ Transform one quiz-bearing cell: validate, promote, emit code cells. Parameters ---------- cell : NotebookNode The source task cell being transformed. quizzes : list[parse.Quiz] Quiz regions parsed from the cell. cell_contents : list[str] Mutable list of remaining cell lines; inline/hidden span content is appended to this list in place. imported : bool Whether the ``display_quiz`` import statement has already been emitted upstream in this notebook. Returns ------- quiz_cells : list[NotebookNode] Generated code cells to append after the task cell. imported : bool Updated flag (``True`` once any quiz cell has been emitted). """ if not utils.is_task(cell) and self.enforce_metadata: raise RuntimeError("Quiz detected in a non-task cell; please mark all quiz cells as 'Manually Graded Task'.") quiz_cell_idx = next(self.quiz_cell_counter) grade_id = cell.metadata.get("nbgrader", {}).get("grade_id") # Host-cell graded toggle: the per-quiz ``graded`` option can # further opt individual quizzes out. Task-cell ``points`` are # left untouched — they remain for manual grading of whatever # isn't the auto-graded quiz (prose, code, etc.). The generated # auto-graded cell carries its own ``points`` (sum of # per-question weights). host_graded = bool(grade_id and self.auto_generate_tests) if grade_id: self._propagate_hide_correctness(quizzes) quiz_cells: list[NotebookNode] = [] for quiz_idx, quiz in enumerate(quizzes): tag = f"{quiz_cell_idx}.{quiz_idx}" source_ref = self._inject_quiz_content(quiz, tag, cell_contents) imp = "" if imported else "from nbgrader_jupyterquiz.display import display_quiz\n" imported = True quiz_graded = host_graded and quiz.options.get("graded") is not False quiz_cells.append( self._build_quiz_code_cell( quiz, quiz_idx, tag, source_ref, imp, grade_id, quiz_graded, len(quizzes), ) ) return quiz_cells, imported @staticmethod def _propagate_hide_correctness(quizzes: list[parse.Quiz]) -> None: """ Auto-enable hide-correctness for graded quizzes unless opted out. Mutates each quiz's options and answer/question dicts in place: sets ``hide_correctness=True`` and stamps ``hide: true`` onto every MC/many-choice answer and numeric question. Skips quizzes where the instructor explicitly wrote ``hide_correctness=false`` or ``graded=false`` (an ungraded quiz reveals correctness like any self-checking quiz). Instructors can still force hide-correctness on an ungraded quiz by writing ``hide_correctness=true`` explicitly. Parameters ---------- quizzes : list[parse.Quiz] Quiz regions belonging to a graded task cell. """ for quiz in quizzes: if quiz.options.get("hide_correctness") is False: continue # instructor opted out if quiz.options.get("graded") is False and quiz.options.get("hide_correctness") is not True: continue # ungraded: default to visible correctness quiz.options["hide_correctness"] = True for question in quiz.questions: if question["type"] in ("multiple_choice", "many_choice"): for answer in question["answers"]: answer.setdefault("hide", True) elif question["type"] in ("numeric", "string"): question.setdefault("hide", True) def _inject_quiz_content( self, quiz: parse.Quiz, tag: str, cell_contents: list[str], ) -> str: """ Render the quiz JSON into the host cell (inline span or filename). Returns the selector/path ``display_quiz`` should use to locate the question data at render time. Parameters ---------- quiz : parse.Quiz Parsed quiz region. tag : str ``"<cell_idx>.<quiz_idx>"`` tag used as both the span id suffix and the ``display_quiz`` ref suffix. cell_contents : list[str] Mutable cell source lines; the hidden/visible span (or the plaintext ``tag=...`` variant) is appended when the ``inline`` option is set. Returns ------- str Selector string passed as the first arg to ``display_quiz``: the filename when ``filename=`` is set, else ``"#<name>"``. """ display_questions = parse.redact_answer_key(quiz.questions) if quiz.options.get("hide_correctness") else quiz.questions questions_json = json.dumps(display_questions) if quiz.options.get("encoded"): questions_json = encode.to_base64(questions_json) if quiz.options.get("inline"): if quiz.options.get("hidden"): # ``tex2jax_ignore`` / ``mathjax_ignore`` keep MathJax # from rewriting ``$...$`` inside the JSON payload. # Backslashes are doubled so JupyterLab's markdown # renderer (which consumes ``\X`` punctuation escapes # before the JS reads ``element.innerHTML``) leaves # JSON's own ``\"`` / ``\\`` / ``\uXXXX`` escapes # intact for ``JSON.parse``. No-op for the default # ``encoded=true`` path — base64 has no backslashes. span_json = questions_json.replace("\\", "\\\\").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") cell_contents.append( f'<span style="display:none" id="{self.name}:{tag}" class="{self.name}:{tag} tex2jax_ignore mathjax_ignore">{span_json}</span>' ) else: cell_contents.append(f"{tag}={questions_json}") if filename := quiz.options.get("filename"): try: with pathlib.Path(filename).open("w") as f: f.writelines(questions_json) except OSError: self.log.error("Cannot open for writing: %s", filename) return filename return f"#{self.name}" def _build_quiz_code_cell( # noqa: PLR0913 self, quiz: parse.Quiz, quiz_idx: int, tag: str, source_ref: str, imp: str, grade_id: str | None, graded_mode: bool, region_count: int, ) -> NotebookNode: """ Build one code cell — graded (hidden tests + answer key) or plain. Parameters ---------- quiz : parse.Quiz Parsed quiz region for this cell. quiz_idx : int 0-based index of this quiz within its host task cell (used to uniquify the graded cell's grade_id when multiple quizzes share one task cell). tag : str ``"<cell_idx>.<quiz_idx>"`` tag used as both the DOM span id suffix and the ``display_quiz`` ref suffix. source_ref : str First argument to ``display_quiz``: filename or ``"#<name>"``. imp : str Either an empty string or the one-time ``from … import display_quiz`` statement to prepend on the first cell. grade_id : str or None Task cell's nbgrader grade_id, if any. graded_mode : bool ``True`` when this cell should be the nbgrader-tracked graded cell (hidden-tests block + answer key embedded). region_count : int Number of quiz regions in the host task cell. When > 1, the generated ``cell_grade_id`` is uniquified per region. Returns ------- NotebookNode New code cell with ``["remove-input"]`` tag and, in graded mode, nbgrader metadata so ``SaveCells`` registers it. """ if not graded_mode: return nbformat.v4.new_code_cell( source=f'{imp}display_quiz("{source_ref}:{tag}", grade_id={grade_id!r})', metadata={"tags": ["remove-input"]}, ) cell_grade_id = f"{grade_id}-autograded" if region_count > 1: cell_grade_id = f"{cell_grade_id}-{quiz_idx}" questions_literal = pprint.pformat( quiz.questions, width=80, indent=2, sort_dicts=False, ) # Bare ``_result.score`` at the end of the hidden block is # what nbgrader's ``determine_grade()`` reads: the cell's # execute_result becomes the partial-credit score. cell_source = ( f"{imp}" f'display_quiz("{source_ref}:{tag}", grade_id={cell_grade_id!r})\n' "### BEGIN HIDDEN TESTS\n" "from nbgrader_jupyterquiz import grade_quiz\n" f"_questions = {questions_literal}\n" f"_result = grade_quiz({cell_grade_id!r}, questions=_questions)\n" "_result.display_review()\n" 'print(f"Score: {_result.score}/{_result.max_score}")\n' "_result.score\n" "### END HIDDEN TESTS\n" ) cell_metadata = { "tags": ["remove-input"], "nbgrader": { "cell_type": "code", "grade": True, "grade_id": cell_grade_id, "locked": True, "points": sum(q.get("points", 1) for q in quiz.questions), "schema_version": 3, "solution": False, "task": False, }, } return nbformat.v4.new_code_cell(source=cell_source, metadata=cell_metadata)
[docs] def preprocess_cell(self, cell: NotebookNode, resources: ResourcesDict, _index: int) -> tuple[NotebookNode, ResourcesDict]: """ No-op — all processing happens in :meth:`preprocess`. Parameters ---------- cell : NotebookNode Current cell. resources : ResourcesDict Nbgrader resources dict. _index : int Cell index (unused). Returns ------- NotebookNode, ResourcesDict Cell unchanged. """ return cell, resources