1
+ """A module to define the `%%ipytest` cell magic"""
1
2
import io
2
3
import pathlib
3
4
import re
4
- from contextlib import redirect_stdout
5
- from typing import Callable , Dict
5
+ from contextlib import redirect_stderr , redirect_stdout
6
+ from dataclasses import dataclass
7
+ from typing import Callable , Dict , List
6
8
7
9
import ipynbname
10
+ import ipywidgets
8
11
import pytest
12
+ from IPython .core .display import HTML , Javascript
9
13
from IPython .core .interactiveshell import InteractiveShell
10
- from IPython .core .magic import Magics , magics_class , cell_magic
11
- from IPython .display import HTML , display
14
+ from IPython .core .magic import Magics , cell_magic , magics_class
15
+ from IPython .display import display
16
+ from nbconvert import filters
12
17
13
18
14
19
def _name_from_line (line : str = None ):
@@ -23,16 +28,23 @@ def _name_from_ipynbname() -> str | None:
23
28
24
29
25
30
def _name_from_globals (globals_dict : Dict ) -> str | None :
26
- module_path = globals_dict .get ('__vsc_ipynb_file__' ) if globals_dict else None
31
+ """Find the name of the test module from the globals dictionary if working in VSCode"""
32
+ module_path = globals_dict .get ("__vsc_ipynb_file__" ) if globals_dict else None
27
33
return pathlib .Path (module_path ).stem if module_path else None
28
34
29
35
30
36
def get_module_name (line : str , globals_dict : Dict = None ) -> str :
31
37
"""Fetch the test module name"""
32
- module_name = _name_from_line (line ) or _name_from_ipynbname () or _name_from_globals (globals_dict )
38
+ module_name = (
39
+ _name_from_line (line )
40
+ or _name_from_ipynbname ()
41
+ or _name_from_globals (globals_dict )
42
+ )
33
43
34
44
if not module_name :
35
- raise RuntimeError ("Test module is undefined. Did you provide an argument to %%ipytest?" )
45
+ raise RuntimeError (
46
+ "Test module is undefined. Did you provide an argument to %%ipytest?"
47
+ )
36
48
37
49
return module_name
38
50
@@ -49,9 +61,160 @@ def pytest_generate_tests(self, metafunc: pytest.Metafunc) -> None:
49
61
metafunc .parametrize ("function_to_test" , [self .function_to_test ])
50
62
51
63
64
+ @dataclass
65
+ class TestResult :
66
+ """Container class to store the test results when we collect them"""
67
+
68
+ stdout : str
69
+ stderr : str
70
+ test_name : str
71
+ success : bool
72
+
73
+
74
+ @dataclass
75
+ class OutputConfig :
76
+ """Container class to store the information to display in the test output"""
77
+
78
+ style : str
79
+ name : str
80
+ result : str
81
+
82
+
83
+ def format_success_failure (
84
+ syntax_error : bool , success : bool , name : str
85
+ ) -> OutputConfig :
86
+ """
87
+ Depending on the test results, returns a fragment that represents
88
+ either an error message, a success message, or a syntax error warning
89
+ """
90
+ if syntax_error :
91
+ return OutputConfig (
92
+ "alert-warning" ,
93
+ "Tests <strong>COULD NOT RUN</strong> for this cell." ,
94
+ "🤔 Careful, looks like you have a syntax error." ,
95
+ )
96
+
97
+ if not success :
98
+ return OutputConfig (
99
+ "alert-danger" ,
100
+ f"Tests <strong>FAILED</strong> for the function <code>{ name } </code>" ,
101
+ "😱 Your solution was not correct!" ,
102
+ )
103
+
104
+ return OutputConfig (
105
+ "alert-success" ,
106
+ f"Tests <strong>PASSED</strong> for the function <code>{ name } </code>" ,
107
+ "🙌 Congratulations, your solution was correct!" ,
108
+ )
109
+
110
+
111
+ def format_long_stdout (text : str ) -> str :
112
+ """
113
+ Format a long test stdout as a HTML by using the <details> element
114
+ """
115
+
116
+ stdout_body = re .split (r"_\s{3,}" , text )[- 1 ]
117
+ stdout_filtered = list (filter (re .compile (".*>E\s" ).match , stdout_body .splitlines ()))
118
+ html_body = "" .join (f"<p>{ line } </p>" for line in stdout_filtered )
119
+
120
+ test_runs = f"""<details style="overflow-y: auto; max-height: 200px;"><summary><u>Click here to expand</u></summary><div>{ html_body } </div></details></li>"""
121
+ return test_runs
122
+
123
+
124
+ class TestResultOutput (ipywidgets .VBox ):
125
+ """Class to display the test results in a structured way"""
126
+
127
+ def __init__ (
128
+ self ,
129
+ name : str = "" ,
130
+ syntax_error : bool = False ,
131
+ success : bool = False ,
132
+ test_outputs : List [TestResult ] = None ,
133
+ ):
134
+ output_config = format_success_failure (syntax_error , success , name )
135
+ output_cell = ipywidgets .Output ()
136
+
137
+ with output_cell :
138
+ custom_div_style = '"border: 1px solid; border-color: lightgray; background-color: whitesmoke; margin: 5px; padding: 10px;"'
139
+ display (HTML ("<h3>Test results</h3>" ))
140
+ display (
141
+ HTML (
142
+ f"""<div class="alert alert-box { output_config .style } "><h4>{ output_config .name } </h4>{ output_config .result } </div>"""
143
+ )
144
+ )
145
+
146
+ if not syntax_error :
147
+ if len (test_outputs ) > 0 and test_outputs [0 ].stdout :
148
+ display (
149
+ HTML (
150
+ f"<h4>Code output:</h4> <div style={ custom_div_style } >{ test_outputs [0 ].stdout } </div>"
151
+ )
152
+ )
153
+
154
+ display (
155
+ HTML (
156
+ f"""
157
+ <h4>We tested your solution <code>solution_{ name } </code> with { '1 input' if len (test_outputs ) == 1 else str (len (test_outputs )) + ' different inputs' } .
158
+ { "All tests passed!</h4>" if success else "Below you find the details for each test run:</h4>" }
159
+ """
160
+ )
161
+ )
162
+
163
+ if not success :
164
+ for test in test_outputs :
165
+ test_name = test .test_name
166
+ if match := re .search (r"\[.*?\]" , test_name ):
167
+ test_name = re .sub (r"\[|\]" , "" , match .group ())
168
+
169
+ display (
170
+ HTML (
171
+ f"""
172
+ <div style={ custom_div_style } >
173
+ <h5>{ "✔" if test .success else "❌" } Test { test_name } </h5>
174
+ { format_long_stdout (filters .ansi .ansi2html (test .stderr )) if not test .success else "" }
175
+ </div>
176
+ """
177
+ )
178
+ )
179
+ else :
180
+ display (
181
+ HTML (
182
+ "<h4>Your code cannot run because of the following error:</h4>"
183
+ )
184
+ )
185
+
186
+ super ().__init__ (children = [output_cell ])
187
+
188
+
189
+ class ResultCollector :
190
+ """A class that will collect the result of a test. If behaves a bit like a visitor pattern"""
191
+
192
+ def __init__ (self ) -> None :
193
+ self .tests : Dict [str , TestResult ] = {}
194
+
195
+ def pytest_runtest_logreport (self , report : pytest .TestReport ):
196
+ # Only collect the results if it did not fail
197
+ if report .when == "teardown" and not report .nodeid in self .tests :
198
+ self .tests [report .nodeid ] = TestResult (
199
+ report .capstdout , report .capstderr , report .nodeid , not report .failed
200
+ )
201
+
202
+ def pytest_exception_interact (
203
+ self , node : pytest .Item , call : pytest .CallInfo , report : pytest .TestReport
204
+ ):
205
+ # We need to collect the results and the stderr if the test failed
206
+ if report .failed :
207
+ self .tests [node .nodeid ] = TestResult (
208
+ report .capstdout ,
209
+ str (call .excinfo .getrepr () if call .excinfo else "" ),
210
+ report .nodeid ,
211
+ False ,
212
+ )
213
+
214
+
52
215
@pytest .fixture
53
216
def function_to_test ():
54
- """Function to test, overriden at runtime by the cell magic"""
217
+ """Function to test, overridden at runtime by the cell magic"""
55
218
56
219
57
220
@magics_class
@@ -72,54 +235,105 @@ def ipytest(self, line: str, cell: str):
72
235
raise FileNotFoundError (f"Module file '{ module_file } ' does not exist" )
73
236
74
237
# Run the cell through IPython
75
- self .shell .run_cell (cell )
76
-
77
- # Retrieve the functions names defined in the current cell
78
- # Only functions with names starting with `solution_` will be candidates for tests
79
- functions_names = re .findall (r"^def\s+(solution_.*?)\s*\(" , cell , re .M )
80
-
81
- # Get the functions objects from user namespace
82
- functions_to_run = {}
83
- for name , function in self .shell .user_ns .items ():
84
- if name in functions_names and callable (function ):
85
- functions_to_run [name .removeprefix ("solution_" )] = function
86
-
87
- if not functions_to_run :
88
- raise ValueError ("No function to test defined in the cell" )
89
-
90
- # Run the tests
91
- for name , function in functions_to_run .items ():
92
- with redirect_stdout (io .StringIO ()) as pytest_stdout :
93
- result = pytest .main (
94
- [
95
- "-q" ,
96
- f"{ module_file } ::test_{ name } " ,
97
- ],
98
- plugins = [FunctionInjectionPlugin (function )],
238
+ result = self .shell .run_cell (cell )
239
+
240
+ try :
241
+ result .raise_error ()
242
+
243
+ # Retrieve the functions names defined in the current cell
244
+ # Only functions with names starting with `solution_` will be candidates for tests
245
+ functions_names = re .findall (r"^def\s+(solution_.*?)\s*\(" , cell , re .M )
246
+
247
+ # Get the functions objects from user namespace
248
+ functions_to_run = {}
249
+ for name , function in self .shell .user_ns .items ():
250
+ if name in functions_names and callable (function ):
251
+ functions_to_run [name .removeprefix ("solution_" )] = function
252
+
253
+ if not functions_to_run :
254
+ raise ValueError ("No function to test defined in the cell" )
255
+
256
+ outputs = []
257
+ for name , function in functions_to_run .items ():
258
+ # Create the test collector
259
+ result_collector = ResultCollector ()
260
+ # Run the tests
261
+ with redirect_stderr (io .StringIO ()) as pytest_stderr , redirect_stdout (
262
+ io .StringIO ()
263
+ ) as pytest_stdout :
264
+ result = pytest .main (
265
+ [
266
+ "-q" ,
267
+ f"{ module_file } ::test_{ name } " ,
268
+ ],
269
+ plugins = [
270
+ FunctionInjectionPlugin (function ),
271
+ result_collector ,
272
+ ],
273
+ )
274
+ # Read pytest output to prevent it from being displayed
275
+ pytest_output = pytest_stdout .getvalue ()
276
+ pytest_error = pytest_stderr .getvalue ()
277
+
278
+ outputs .append (
279
+ TestResultOutput (
280
+ name ,
281
+ False ,
282
+ result == pytest .ExitCode .OK ,
283
+ list (result_collector .tests .values ()),
284
+ )
99
285
)
100
286
101
- # Read pytest output
102
- pytest_output = pytest_stdout .getvalue ()
287
+ display (* outputs )
103
288
104
- if result == pytest .ExitCode .OK :
105
- color , title , test_result = (
106
- "alert-success" ,
107
- f"Tests <strong>PASSED</strong> for the function <code>{ name } </code>" ,
108
- "🙌 Congratulations, your solution was correct!" ,
289
+ # hide cell outputs that were not generated by a function
290
+ display (
291
+ Javascript (
292
+ """
293
+ var output_divs = document.querySelectorAll(".jp-OutputArea-executeResult");
294
+ for (let div of output_divs) {
295
+ div.setAttribute("style", "display: none;");
296
+ }
297
+ """
109
298
)
110
- else :
111
- color , title , test_result = (
112
- "alert-danger" ,
113
- f"Tests <strong>FAILED</strong> for the function <code>{ name } </code>" ,
114
- "😱 Your solution was not correct!" ,
299
+ )
300
+
301
+ # remove syntax error styling
302
+ display (
303
+ Javascript (
304
+ """
305
+ var output_divs = document.querySelectorAll(".jp-Cell-outputArea");
306
+ for (let div of output_divs) {
307
+ var div_str = String(div.innerHTML);
308
+ if (div_str.includes("alert-success") | div_str.includes("alert-danger")) {
309
+ div.setAttribute("style", "padding-bottom: 0;");
310
+ }
311
+ }
312
+ """
115
313
)
314
+ )
116
315
117
- # Print all pytest output
118
- print (pytest_output )
316
+ except Exception :
317
+ # Catches syntax errors and creates a custom warning
318
+ display (
319
+ TestResultOutput (
320
+ syntax_error = True ,
321
+ success = False ,
322
+ )
323
+ )
119
324
120
325
display (
121
- HTML (
122
- f"""<div class="alert alert-box { color } "><h4>{ title } </h4>{ test_result } </div>"""
326
+ Javascript (
327
+ """
328
+ var syntax_error_containers = document.querySelectorAll('div[data-mime-type="application/vnd.jupyter.stderr"]');
329
+ for (let container of syntax_error_containers) {
330
+ var syntax_error_div = container.parentNode;
331
+ var container_div = syntax_error_div.parentNode;
332
+ const container_style = "position: relative; padding-bottom: " + syntax_error_div.clientHeight + "px;";
333
+ container_div.setAttribute("style", container_style);
334
+ syntax_error_div.setAttribute("style", "position: absolute; bottom: 10px;");
335
+ }
336
+ """
123
337
)
124
338
)
125
339
0 commit comments