Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 8d43e73

Browse filesBrowse files
committed
Merge branch 'main' into fix/extend-toc-cli
2 parents 75d4235 + 525b2e2 commit 8d43e73
Copy full SHA for 8d43e73

File tree

Expand file treeCollapse file tree

2 files changed

+270
-54
lines changed
Filter options
Expand file treeCollapse file tree

2 files changed

+270
-54
lines changed

‎magic_example.ipynb

Copy file name to clipboardExpand all lines: magic_example.ipynb
+8-6Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"cells": [
33
{
44
"cell_type": "code",
5-
"execution_count": null,
5+
"execution_count": 2,
66
"metadata": {
77
"tags": []
88
},
@@ -22,11 +22,13 @@
2222
"%%ipytest\n",
2323
"# or %%ipytest test_module_name\n",
2424
"\n",
25+
"len('a')\n",
2526
"def solution_power2(x: int) -> int:\n",
26-
" print(\"running\")\n",
27-
" return x * 2\n",
28-
"\n",
29-
"len([1,2,3,4])"
27+
" print('hi')\n",
28+
" len('b')\n",
29+
" print(len('bb'))\n",
30+
" return x ** 2\n",
31+
"len('aaa')"
3032
]
3133
},
3234
{
@@ -72,7 +74,7 @@
7274
"name": "python",
7375
"nbconvert_exporter": "python",
7476
"pygments_lexer": "ipython3",
75-
"version": "3.11.0"
77+
"version": "3.11.4"
7678
},
7779
"vscode": {
7880
"interpreter": {

‎tutorial/tests/testsuite.py

Copy file name to clipboardExpand all lines: tutorial/tests/testsuite.py
+262-48Lines changed: 262 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1+
"""A module to define the `%%ipytest` cell magic"""
12
import io
23
import pathlib
34
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
68

79
import ipynbname
10+
import ipywidgets
811
import pytest
12+
from IPython.core.display import HTML, Javascript
913
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
1217

1318

1419
def _name_from_line(line: str = None):
@@ -23,16 +28,23 @@ def _name_from_ipynbname() -> str | None:
2328

2429

2530
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
2733
return pathlib.Path(module_path).stem if module_path else None
2834

2935

3036
def get_module_name(line: str, globals_dict: Dict = None) -> str:
3137
"""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+
)
3343

3444
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+
)
3648

3749
return module_name
3850

@@ -49,9 +61,160 @@ def pytest_generate_tests(self, metafunc: pytest.Metafunc) -> None:
4961
metafunc.parametrize("function_to_test", [self.function_to_test])
5062

5163

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+
"&#129300 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+
"&#x1F631 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+
"&#x1F64C 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>{"&#10004" if test.success else "&#10060"} 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+
52215
@pytest.fixture
53216
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"""
55218

56219

57220
@magics_class
@@ -72,54 +235,105 @@ def ipytest(self, line: str, cell: str):
72235
raise FileNotFoundError(f"Module file '{module_file}' does not exist")
73236

74237
# 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+
)
99285
)
100286

101-
# Read pytest output
102-
pytest_output = pytest_stdout.getvalue()
287+
display(*outputs)
103288

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-
"&#x1F64C 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+
"""
109298
)
110-
else:
111-
color, title, test_result = (
112-
"alert-danger",
113-
f"Tests <strong>FAILED</strong> for the function <code>{name}</code>",
114-
"&#x1F631 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+
"""
115313
)
314+
)
116315

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+
)
119324

120325
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+
"""
123337
)
124338
)
125339

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.