Skip to content

Commit 1ab4143

Browse files
tuncbkoseshreve
authored andcommitted
Add missing grade_cells before autograding (jupyter#1770)
1 parent 73bfae4 commit 1ab4143

File tree

2 files changed

+194
-3
lines changed

2 files changed

+194
-3
lines changed

nbgrader/preprocessors/overwritecells.py

+141
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,84 @@
33
from .. import utils
44
from ..api import Gradebook, MissingEntry
55
from . import NbGraderPreprocessor
6+
from ..nbgraderformat import MetadataValidator
67
from nbconvert.exporters.exporter import ResourcesDict
78
from nbformat.notebooknode import NotebookNode
9+
from traitlets import Bool, Unicode
810
from typing import Tuple, Any
11+
from textwrap import dedent
912

1013

1114
class OverwriteCells(NbGraderPreprocessor):
1215
"""A preprocessor to overwrite information about grade and solution cells."""
1316

17+
add_missing_cells = Bool(
18+
False,
19+
help=dedent(
20+
"""
21+
Whether or not missing grade_cells should be added back
22+
to the notebooks being graded.
23+
"""
24+
),
25+
).tag(config=True)
26+
27+
missing_cell_notification = Unicode(
28+
"This cell (id:{cell_id}) was missing from the submission. " +
29+
"It was added back by nbgrader.\n\n", # Markdown requires two newlines
30+
help=dedent(
31+
"""
32+
A text to add at the beginning of every missing cell re-added to the notebook during autograding.
33+
"""
34+
)
35+
).tag(config=True)
36+
37+
def missing_cell_transform(self, source_cell, max_score, is_solution=False, is_task=False):
38+
"""
39+
Converts source_cell obtained from Gradebook into a cell that can be added to the notebook.
40+
It is assumed that the cell is a grade_cell (unless is_task=True)
41+
"""
42+
43+
missing_cell_notification = self.missing_cell_notification.format(cell_id=source_cell.name)
44+
45+
cell = {
46+
"cell_type": source_cell.cell_type,
47+
"metadata": {
48+
"deletable": False,
49+
"editable": False,
50+
"nbgrader": {
51+
"grade": True,
52+
"grade_id": source_cell.name,
53+
"locked": source_cell.locked,
54+
"checksum": source_cell.checksum,
55+
"cell_type": source_cell.cell_type,
56+
"points": max_score,
57+
"solution": False
58+
}
59+
},
60+
"source": missing_cell_notification + source_cell.source
61+
}
62+
63+
# Code cell format is slightly different
64+
if cell["cell_type"] == "code":
65+
cell["execution_count"] = None
66+
cell["outputs"] = []
67+
cell["source"] = "# " + cell["source"] # make the notification we add a comment
68+
69+
# some grade cells are editable (manually graded answers)
70+
if is_solution:
71+
del cell["metadata"]["editable"]
72+
cell["metadata"]["nbgrader"]["solution"] = True
73+
# task cells are also a bit different
74+
elif is_task:
75+
cell["metadata"]["nbgrader"]["grade"] = False
76+
cell["metadata"]["nbgrader"]["task"] = True
77+
# this is when task cells were added so metadata validation should start from here
78+
cell["metadata"]["nbgrader"]["schema_version"] = 3
79+
80+
cell = NotebookNode(cell)
81+
cell = MetadataValidator().upgrade_cell_metadata(cell)
82+
return cell
83+
1484
def preprocess(self, nb: NotebookNode, resources: ResourcesDict) -> Tuple[NotebookNode, ResourcesDict]:
1585
# pull information from the resources
1686
self.notebook_id = resources['nbgrader']['notebook']
@@ -22,6 +92,77 @@ def preprocess(self, nb: NotebookNode, resources: ResourcesDict) -> Tuple[Notebo
2292

2393
with self.gradebook:
2494
nb, resources = super(OverwriteCells, self).preprocess(nb, resources)
95+
if self.add_missing_cells:
96+
nb, resources = self.add_missing_grade_cells(nb, resources)
97+
nb, resources = self.add_missing_task_cells(nb, resources)
98+
99+
return nb, resources
100+
101+
def add_missing_grade_cells(self, nb: NotebookNode, resources: ResourcesDict) -> Tuple[NotebookNode, ResourcesDict]:
102+
"""
103+
Add missing grade cells back to the notebook.
104+
If missing, find the previous solution/grade cell, and add the current cell after it.
105+
It is assumed such a cell exists because
106+
presumably the grade_cell exists to grade some work in the solution cell.
107+
"""
108+
source_nb = self.gradebook.find_notebook(self.notebook_id, self.assignment_id)
109+
source_cells = source_nb.source_cells
110+
source_cell_ids = [cell.name for cell in source_cells]
111+
grade_cells = {cell.name: cell for cell in source_nb.grade_cells}
112+
solution_cell_ids = [cell.name for cell in source_nb.solution_cells]
113+
114+
# track indices of solution and grade cells in the submitted notebook
115+
submitted_cell_idxs = dict()
116+
for idx, cell in enumerate(nb.cells):
117+
if utils.is_grade(cell) or utils.is_solution(cell):
118+
submitted_cell_idxs[cell.metadata.nbgrader["grade_id"]] = idx
119+
120+
# Every time we add a cell, the idxs above get shifted
121+
# We could process the notebook backwards, but that makes adding the cells in the right place more difficult
122+
# So we keep track of how many we have added so far
123+
added_count = 0
124+
125+
for grade_cell_id, grade_cell in grade_cells.items():
126+
# If missing, find the previous solution/grade cell, and add the current cell after it.
127+
if grade_cell_id not in submitted_cell_idxs:
128+
self.log.warning(f"Missing grade cell {grade_cell_id} encountered, adding to notebook")
129+
source_cell_idx = source_cell_ids.index(grade_cell_id)
130+
cell_to_add = source_cells[source_cell_idx]
131+
cell_to_add = self.missing_cell_transform(cell_to_add, grade_cell.max_score,
132+
is_solution=grade_cell_id in solution_cell_ids)
133+
# First cell was deleted, add it to start
134+
if source_cell_idx == 0:
135+
nb.cells.insert(0, cell_to_add)
136+
submitted_cell_idxs[grade_cell_id] = 0
137+
# Deleted cell is not the first, add it after the previous solution/grade cell
138+
else:
139+
prev_cell_id = source_cell_ids[source_cell_idx - 1]
140+
prev_cell_idx = submitted_cell_idxs[prev_cell_id] + added_count
141+
nb.cells.insert(prev_cell_idx + 1, cell_to_add) # +1 to add it after
142+
submitted_cell_idxs[grade_cell_id] = submitted_cell_idxs[prev_cell_id]
143+
144+
# If the cell we just added is followed by other missing cells, we need to know its index in the nb
145+
# However, no need to add `added_count` to avoid double-counting
146+
147+
added_count += 1 # shift idxs
148+
149+
return nb, resources
150+
151+
def add_missing_task_cells(self, nb: NotebookNode, resources: ResourcesDict) -> Tuple[NotebookNode, ResourcesDict]:
152+
"""
153+
Add missing task cells back to the notebook.
154+
We can't figure out their original location, so they are added at the end, in their original order.
155+
"""
156+
source_nb = self.gradebook.find_notebook(self.notebook_id, self.assignment_id)
157+
source_cells = source_nb.source_cells
158+
source_cell_ids = [cell.name for cell in source_cells]
159+
submitted_ids = [cell["metadata"]["nbgrader"]["grade_id"] for cell in nb.cells if
160+
"nbgrader" in cell["metadata"]]
161+
for task_cell in source_nb.task_cells:
162+
if task_cell.name not in submitted_ids:
163+
cell_to_add = source_cells[source_cell_ids.index(task_cell.name)]
164+
cell_to_add = self.missing_cell_transform(cell_to_add, task_cell.max_score, is_task=True)
165+
nb.cells.append(cell_to_add)
25166

26167
return nb, resources
27168

nbgrader/tests/preprocessors/test_overwritecells.py

+53-3
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import pytest
22

3-
from nbformat.v4 import new_notebook
3+
from nbformat.v4 import new_notebook, new_markdown_cell
44

55
from ...preprocessors import SaveCells, OverwriteCells
66
from ...api import Gradebook
77
from ...utils import compute_checksum
88
from .base import BaseTestPreprocessor
99
from .. import (
1010
create_grade_cell, create_solution_cell, create_grade_and_solution_cell,
11-
create_locked_cell)
11+
create_locked_cell, create_task_cell)
1212

1313

1414
@pytest.fixture
@@ -23,6 +23,7 @@ def gradebook(request, db):
2323

2424
def fin():
2525
gb.close()
26+
2627
request.addfinalizer(fin)
2728

2829
return gb
@@ -197,7 +198,7 @@ def test_overwrite_locked_checksum(self, preprocessors, resources):
197198

198199
assert cell.metadata.nbgrader["checksum"] == compute_checksum(cell)
199200

200-
def test_nonexistant_grade_id(self, preprocessors, resources):
201+
def test_nonexistent_grade_id(self, preprocessors, resources):
201202
"""Are cells not in the database ignored?"""
202203
cell = create_grade_cell("", "code", "", 1)
203204
cell.metadata.nbgrader['grade'] = False
@@ -215,3 +216,52 @@ def test_nonexistant_grade_id(self, preprocessors, resources):
215216
nb, resources = preprocessors[1].preprocess(nb, resources)
216217
assert 'grade_id' not in cell.metadata.nbgrader
217218

219+
# Tests for adding missing cells back
220+
def test_add_missing_cells(self, preprocessors, resources):
221+
"""
222+
Note: This test will produce warnings (from OverwriteCells preprocessor) by default.
223+
Current implementation of adding missing cells should:
224+
- add missing cells right after the previous grade/solution cell, as the best approximation of their location
225+
- add task cells at the end (because we can't detect their location in the notebook), in order of appearance
226+
"""
227+
228+
cells = [
229+
create_solution_cell("Code assignment", "code", "code_solution"),
230+
create_grade_cell("some tests", "code", "code_test1", 1),
231+
create_grade_cell("more tests", "code", "code_test2", 1),
232+
new_markdown_cell("some description"),
233+
create_grade_and_solution_cell("write answer here", "markdown", "md_manually_graded1", 1),
234+
create_grade_and_solution_cell("write answer here", "markdown", "md_manually_graded2", 1),
235+
new_markdown_cell("some description"),
236+
create_task_cell("some task description", "markdown", "task_cell1", 1),
237+
new_markdown_cell("some description"),
238+
create_task_cell("some task description", "markdown", "task_cell2", 1),
239+
]
240+
# Add checksums to suppress warning
241+
nbgrader_cells = [0, 1, 2, 4, 5, 7, 9]
242+
for idx, cell in enumerate(cells):
243+
if idx in nbgrader_cells:
244+
cell.metadata.nbgrader["checksum"] = compute_checksum(cell)
245+
246+
expected_order = [0, 1, 2, 4, 5, 3, 6, 8, 7, 9]
247+
expected = [cells[i].metadata.nbgrader["grade_id"] if "nbgrader" in cells[i].metadata else "markdown" for i in expected_order]
248+
249+
nb = new_notebook()
250+
nb.cells = cells
251+
252+
# save to database
253+
nb, resources = preprocessors[0].preprocess(nb, resources)
254+
255+
# remove grade/task cells to test their restoration
256+
nb.cells.pop(9)
257+
nb.cells.pop(7)
258+
nb.cells.pop(5)
259+
nb.cells.pop(4)
260+
nb.cells.pop(2)
261+
nb.cells.pop(1)
262+
263+
# restore
264+
preprocessors[1].add_missing_cells = True
265+
nb, resources = preprocessors[1].preprocess(nb, resources)
266+
result = [cell["metadata"]["nbgrader"]["grade_id"] if "nbgrader" in cell["metadata"] else "markdown" for cell in nb.cells]
267+
assert expected == result

0 commit comments

Comments
 (0)