2
2
import sys
3
3
import time
4
4
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
5
9
6
10
import pytest
7
11
from nbformat import v4 as nbformat
12
+ from nbformat import ValidationError
8
13
from tornado .web import HTTPError
9
14
from traitlets import TraitError
10
15
@@ -63,7 +68,16 @@ def add_code_cell(notebook):
63
68
notebook .cells .append (cell )
64
69
65
70
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 ]:
67
81
cm = jp_contents_manager
68
82
model = await ensure_async (cm .new_untitled (type = "notebook" ))
69
83
name = model ["name" ]
@@ -72,8 +86,19 @@ async def new_notebook(jp_contents_manager):
72
86
full_model = await ensure_async (cm .get (path ))
73
87
nb = full_model ["content" ]
74
88
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
76
94
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" ]
77
102
await ensure_async (cm .save (full_model , path ))
78
103
return nb , name , path
79
104
@@ -667,3 +692,83 @@ async def test_check_and_sign(jp_contents_manager):
667
692
cm .mark_trusted_cells (nb , path )
668
693
cm .check_and_sign (nb , path )
669
694
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