Skip to content

Commit 6e0e49e

Browse files
authored
Validate notebooks once per fetch or save (#724)
1 parent 32b2f74 commit 6e0e49e

File tree

5 files changed

+172
-23
lines changed

5 files changed

+172
-23
lines changed

jupyter_server/services/contents/fileio.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -261,11 +261,13 @@ def _get_os_path(self, path):
261261
raise HTTPError(404, "%s is outside root contents directory" % path)
262262
return os_path
263263

264-
def _read_notebook(self, os_path, as_version=4):
264+
def _read_notebook(self, os_path, as_version=4, capture_validation_error=None):
265265
"""Read a notebook from an os path."""
266266
with self.open(os_path, "r", encoding="utf-8") as f:
267267
try:
268-
return nbformat.read(f, as_version=as_version)
268+
return nbformat.read(
269+
f, as_version=as_version, capture_validation_error=capture_validation_error
270+
)
269271
except Exception as e:
270272
e_orig = e
271273

@@ -284,12 +286,19 @@ def _read_notebook(self, os_path, as_version=4):
284286
invalid_file = path_to_invalid(os_path)
285287
replace_file(os_path, invalid_file)
286288
replace_file(tmp_path, os_path)
287-
return self._read_notebook(os_path, as_version)
289+
return self._read_notebook(
290+
os_path, as_version, capture_validation_error=capture_validation_error
291+
)
288292

289-
def _save_notebook(self, os_path, nb):
293+
def _save_notebook(self, os_path, nb, capture_validation_error=None):
290294
"""Save a notebook to an os_path."""
291295
with self.atomic_writing(os_path, encoding="utf-8") as f:
292-
nbformat.write(nb, f, version=nbformat.NO_CONVERT)
296+
nbformat.write(
297+
nb,
298+
f,
299+
version=nbformat.NO_CONVERT,
300+
capture_validation_error=capture_validation_error,
301+
)
293302

294303
def _read_file(self, os_path, format):
295304
"""Read a non-notebook file.
@@ -352,11 +361,18 @@ async def _copy(self, src, dest):
352361
"""
353362
await async_copy2_safe(src, dest, log=self.log)
354363

355-
async def _read_notebook(self, os_path, as_version=4):
364+
async def _read_notebook(self, os_path, as_version=4, capture_validation_error=None):
356365
"""Read a notebook from an os path."""
357366
with self.open(os_path, "r", encoding="utf-8") as f:
358367
try:
359-
return await run_sync(partial(nbformat.read, as_version=as_version), f)
368+
return await run_sync(
369+
partial(
370+
nbformat.read,
371+
as_version=as_version,
372+
capture_validation_error=capture_validation_error,
373+
),
374+
f,
375+
)
360376
except Exception as e:
361377
e_orig = e
362378

@@ -375,12 +391,22 @@ async def _read_notebook(self, os_path, as_version=4):
375391
invalid_file = path_to_invalid(os_path)
376392
await async_replace_file(os_path, invalid_file)
377393
await async_replace_file(tmp_path, os_path)
378-
return await self._read_notebook(os_path, as_version)
394+
return await self._read_notebook(
395+
os_path, as_version, capture_validation_error=capture_validation_error
396+
)
379397

380-
async def _save_notebook(self, os_path, nb):
398+
async def _save_notebook(self, os_path, nb, capture_validation_error=None):
381399
"""Save a notebook to an os_path."""
382400
with self.atomic_writing(os_path, encoding="utf-8") as f:
383-
await run_sync(partial(nbformat.write, version=nbformat.NO_CONVERT), nb, f)
401+
await run_sync(
402+
partial(
403+
nbformat.write,
404+
version=nbformat.NO_CONVERT,
405+
capture_validation_error=capture_validation_error,
406+
),
407+
nb,
408+
f,
409+
)
384410

385411
async def _read_file(self, os_path, format):
386412
"""Read a non-notebook file.

jupyter_server/services/contents/filemanager.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -339,11 +339,14 @@ def _notebook_model(self, path, content=True):
339339
os_path = self._get_os_path(path)
340340

341341
if content:
342-
nb = self._read_notebook(os_path, as_version=4)
342+
validation_error = {}
343+
nb = self._read_notebook(
344+
os_path, as_version=4, capture_validation_error=validation_error
345+
)
343346
self.mark_trusted_cells(nb, path)
344347
model["content"] = nb
345348
model["format"] = "json"
346-
self.validate_notebook_model(model)
349+
self.validate_notebook_model(model, validation_error)
347350

348351
return model
349352

@@ -417,11 +420,12 @@ def save(self, model, path=""):
417420
os_path = self._get_os_path(path)
418421
self.log.debug("Saving %s", os_path)
419422

423+
validation_error = {}
420424
try:
421425
if model["type"] == "notebook":
422426
nb = nbformat.from_dict(model["content"])
423427
self.check_and_sign(nb, path)
424-
self._save_notebook(os_path, nb)
428+
self._save_notebook(os_path, nb, capture_validation_error=validation_error)
425429
# One checkpoint should always exist for notebooks.
426430
if not self.checkpoints.list_checkpoints(path):
427431
self.create_checkpoint(path)
@@ -440,7 +444,7 @@ def save(self, model, path=""):
440444

441445
validation_message = None
442446
if model["type"] == "notebook":
443-
self.validate_notebook_model(model)
447+
self.validate_notebook_model(model, validation_error=validation_error)
444448
validation_message = model.get("message", None)
445449

446450
model = self.get(path, content=False)
@@ -663,11 +667,14 @@ async def _notebook_model(self, path, content=True):
663667
os_path = self._get_os_path(path)
664668

665669
if content:
666-
nb = await self._read_notebook(os_path, as_version=4)
670+
validation_error = {}
671+
nb = await self._read_notebook(
672+
os_path, as_version=4, capture_validation_error=validation_error
673+
)
667674
self.mark_trusted_cells(nb, path)
668675
model["content"] = nb
669676
model["format"] = "json"
670-
self.validate_notebook_model(model)
677+
self.validate_notebook_model(model, validation_error)
671678

672679
return model
673680

@@ -741,11 +748,12 @@ async def save(self, model, path=""):
741748
os_path = self._get_os_path(path)
742749
self.log.debug("Saving %s", os_path)
743750

751+
validation_error = {}
744752
try:
745753
if model["type"] == "notebook":
746754
nb = nbformat.from_dict(model["content"])
747755
self.check_and_sign(nb, path)
748-
await self._save_notebook(os_path, nb)
756+
await self._save_notebook(os_path, nb, capture_validation_error=validation_error)
749757
# One checkpoint should always exist for notebooks.
750758
if not (await self.checkpoints.list_checkpoints(path)):
751759
await self.create_checkpoint(path)
@@ -764,7 +772,7 @@ async def save(self, model, path=""):
764772

765773
validation_message = None
766774
if model["type"] == "notebook":
767-
self.validate_notebook_model(model)
775+
self.validate_notebook_model(model, validation_error=validation_error)
768776
validation_message = model.get("message", None)
769777

770778
model = await self.get(path, content=False)

jupyter_server/services/contents/manager.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -489,10 +489,20 @@ def increment_filename(self, filename, path="", insert=""):
489489
break
490490
return name
491491

492-
def validate_notebook_model(self, model):
492+
def validate_notebook_model(self, model, validation_error=None):
493493
"""Add failed-validation message to model"""
494494
try:
495-
validate_nb(model["content"])
495+
# If we're given a validation_error dictionary, extract the exception
496+
# from it and raise the exception, else call nbformat's validate method
497+
# to determine if the notebook is valid. This 'else' condition may
498+
# pertain to server extension not using the server's notebook read/write
499+
# functions.
500+
if validation_error is not None:
501+
e = validation_error.get("ValidationError")
502+
if isinstance(e, ValidationError):
503+
raise e
504+
else:
505+
validate_nb(model["content"])
496506
except ValidationError as e:
497507
model["message"] = "Notebook validation failed: {}:\n{}".format(
498508
e.message,

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ install_requires =
3535
traitlets>=5
3636
jupyter_core>=4.6.0
3737
jupyter_client>=6.1.1
38-
nbformat
38+
nbformat>=5.2.0
3939
nbconvert
4040
Send2Trash
4141
terminado>=0.8.3

tests/services/contents/test_manager.py

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@
22
import sys
33
import time
44
from itertools import combinations
5+
from typing import Dict
6+
from typing import Optional
7+
from typing import Tuple
8+
from unittest.mock import patch
59

610
import pytest
711
from nbformat import v4 as nbformat
12+
from nbformat import ValidationError
813
from tornado.web import HTTPError
914
from traitlets import TraitError
1015

@@ -63,7 +68,16 @@ def add_code_cell(notebook):
6368
notebook.cells.append(cell)
6469

6570

66-
async def new_notebook(jp_contents_manager):
71+
def add_invalid_cell(notebook):
72+
output = nbformat.new_output("display_data", {"application/javascript": "alert('hi');"})
73+
cell = nbformat.new_code_cell("print('hi')", outputs=[output])
74+
cell.pop("source") # Remove source to invaliate
75+
notebook.cells.append(cell)
76+
77+
78+
async def prepare_notebook(
79+
jp_contents_manager, make_invalid: Optional[bool] = False
80+
) -> Tuple[Dict, str]:
6781
cm = jp_contents_manager
6882
model = await ensure_async(cm.new_untitled(type="notebook"))
6983
name = model["name"]
@@ -72,8 +86,19 @@ async def new_notebook(jp_contents_manager):
7286
full_model = await ensure_async(cm.get(path))
7387
nb = full_model["content"]
7488
nb["metadata"]["counter"] = int(1e6 * time.time())
75-
add_code_cell(nb)
89+
if make_invalid:
90+
add_invalid_cell(nb)
91+
else:
92+
add_code_cell(nb)
93+
return full_model, path
7694

95+
96+
async def new_notebook(jp_contents_manager):
97+
full_model, path = await prepare_notebook(jp_contents_manager)
98+
cm = jp_contents_manager
99+
name = full_model["name"]
100+
path = full_model["path"]
101+
nb = full_model["content"]
77102
await ensure_async(cm.save(full_model, path))
78103
return nb, name, path
79104

@@ -667,3 +692,83 @@ async def test_check_and_sign(jp_contents_manager):
667692
cm.mark_trusted_cells(nb, path)
668693
cm.check_and_sign(nb, path)
669694
assert cm.notary.check_signature(nb)
695+
696+
697+
async def test_nb_validation(jp_contents_manager):
698+
# Test that validation is performed once when a notebook is read or written
699+
700+
model, path = await prepare_notebook(jp_contents_manager, make_invalid=False)
701+
cm = jp_contents_manager
702+
703+
# We'll use a patch to capture the call count on "nbformat.validate" for the
704+
# successful methods and ensure that calls to the aliased "validate_nb" are
705+
# zero. Note that since patching side-effects the validation error case, we'll
706+
# skip call-count assertions for that portion of the test.
707+
with patch("nbformat.validate") as mock_validate, patch(
708+
"jupyter_server.services.contents.manager.validate_nb"
709+
) as mock_validate_nb:
710+
# Valid notebook, save, then get
711+
model = await ensure_async(cm.save(model, path))
712+
assert "message" not in model
713+
assert mock_validate.call_count == 1
714+
assert mock_validate_nb.call_count == 0
715+
mock_validate.reset_mock()
716+
mock_validate_nb.reset_mock()
717+
718+
# Get the notebook and ensure there are no messages
719+
model = await ensure_async(cm.get(path))
720+
assert "message" not in model
721+
assert mock_validate.call_count == 1
722+
assert mock_validate_nb.call_count == 0
723+
mock_validate.reset_mock()
724+
mock_validate_nb.reset_mock()
725+
726+
# Add invalid cell, save, then get
727+
add_invalid_cell(model["content"])
728+
729+
model = await ensure_async(cm.save(model, path))
730+
assert "message" in model
731+
assert "Notebook validation failed:" in model["message"]
732+
733+
model = await ensure_async(cm.get(path))
734+
assert "message" in model
735+
assert "Notebook validation failed:" in model["message"]
736+
737+
738+
async def test_validate_notebook_model(jp_contents_manager):
739+
# Test the validation_notebook_model method to ensure that validation is not
740+
# performed when a validation_error dictionary is provided and is performed
741+
# when that parameter is None.
742+
743+
model, path = await prepare_notebook(jp_contents_manager, make_invalid=False)
744+
cm = jp_contents_manager
745+
746+
with patch("jupyter_server.services.contents.manager.validate_nb") as mock_validate_nb:
747+
# Valid notebook and a non-None dictionary, no validate call expected
748+
749+
validation_error = {}
750+
cm.validate_notebook_model(model, validation_error)
751+
assert mock_validate_nb.call_count == 0
752+
mock_validate_nb.reset_mock()
753+
754+
# And without the extra parameter, validate call expected
755+
cm.validate_notebook_model(model)
756+
assert mock_validate_nb.call_count == 1
757+
mock_validate_nb.reset_mock()
758+
759+
# Now do the same with an invalid model
760+
# invalidate the model...
761+
add_invalid_cell(model["content"])
762+
763+
validation_error["ValidationError"] = ValidationError("not a real validation error")
764+
cm.validate_notebook_model(model, validation_error)
765+
assert "Notebook validation failed" in model["message"]
766+
assert mock_validate_nb.call_count == 0
767+
mock_validate_nb.reset_mock()
768+
model.pop("message")
769+
770+
# And without the extra parameter, validate call expected. Since patch side-effects
771+
# the patched method, we won't attempt to access the message field.
772+
cm.validate_notebook_model(model)
773+
assert mock_validate_nb.call_count == 1
774+
mock_validate_nb.reset_mock()

0 commit comments

Comments
 (0)