jupyter-python-tutor 0.3.1__tar.gz → 0.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/PKG-INFO +1 -1
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor/__init__.py +1 -1
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor/magics.py +83 -5
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor/renderer.py +132 -10
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor/tracer.py +62 -12
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor.egg-info/PKG-INFO +1 -1
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/pyproject.toml +1 -1
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/tests/test_magics.py +52 -0
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/tests/test_renderer.py +99 -0
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/tests/test_tracer.py +89 -0
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/LICENSE +0 -0
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/README.md +0 -0
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor.egg-info/SOURCES.txt +0 -0
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor.egg-info/dependency_links.txt +0 -0
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor.egg-info/requires.txt +0 -0
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor.egg-info/top_level.txt +0 -0
- {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/setup.cfg +0 -0
|
@@ -12,20 +12,31 @@ Options:
|
|
|
12
12
|
%%pytutor --raw — output the trace as JSON instead of HTML
|
|
13
13
|
%%pytutor --height 400 — set max-height of the variables panel (px)
|
|
14
14
|
%%pytutor --input 2026 — pre-collected input for input() calls
|
|
15
|
+
%%pytutor --no-scroll — window fits all content (no inner scrollbars)
|
|
16
|
+
|
|
17
|
+
Configure scrolling for the entire notebook:
|
|
18
|
+
|
|
19
|
+
%pytutor_config --no-scroll # default to fit-all (no inner scroll)
|
|
20
|
+
%pytutor_config --scroll # default to fixed window with inner scroll
|
|
21
|
+
%pytutor_config --reset # back to defaults
|
|
22
|
+
%pytutor_config # show current settings
|
|
15
23
|
"""
|
|
16
24
|
from __future__ import annotations
|
|
17
25
|
|
|
18
26
|
import argparse
|
|
19
27
|
import typing
|
|
20
28
|
|
|
21
|
-
from IPython.core.magic import Magics, magics_class, cell_magic
|
|
22
|
-
from IPython.display import HTML, JSON
|
|
29
|
+
from IPython.core.magic import Magics, magics_class, cell_magic, line_magic
|
|
30
|
+
from IPython.display import HTML, JSON, display
|
|
23
31
|
|
|
24
32
|
from .tracer import trace_code
|
|
25
33
|
from .renderer import render_trace
|
|
26
34
|
|
|
27
35
|
__all__ = ["TutorMagics", "load_ipython_extension", "unload_ipython_extension"]
|
|
28
36
|
|
|
37
|
+
# Defaults
|
|
38
|
+
_DEFAULT_SCROLL = True
|
|
39
|
+
|
|
29
40
|
|
|
30
41
|
def _make_parser() -> argparse.ArgumentParser:
|
|
31
42
|
parser = argparse.ArgumentParser(
|
|
@@ -50,19 +61,78 @@ def _make_parser() -> argparse.ArgumentParser:
|
|
|
50
61
|
"Repeat --input for multiple values: --input 2026 --input John. "
|
|
51
62
|
"If omitted and code contains input(), a dialog prompts for values.",
|
|
52
63
|
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--no-scroll", dest="no_scroll", action="store_true",
|
|
66
|
+
help="Fit-all for this cell: window grows to show all content, no inner scroll",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--scroll", dest="use_scroll", action="store_true",
|
|
70
|
+
help="Fixed window with inner scroll for this cell (overrides --no-scroll config)",
|
|
71
|
+
)
|
|
53
72
|
return parser
|
|
54
73
|
|
|
55
74
|
|
|
56
75
|
@magics_class
|
|
57
76
|
class TutorMagics(Magics):
|
|
58
|
-
"""Register the %%pytutor cell magic."""
|
|
77
|
+
"""Register the %%pytutor cell magic and %pytutor_config line magic."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, shell):
|
|
80
|
+
super().__init__(shell)
|
|
81
|
+
# Per-notebook config stored on the magics instance.
|
|
82
|
+
self._scroll = _DEFAULT_SCROLL
|
|
83
|
+
|
|
84
|
+
# ── %pytutor_config ──────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
@line_magic("pytutor_config")
|
|
87
|
+
def pytutor_config(self, line: str):
|
|
88
|
+
"""Configure how %%pytutor visualizations behave for this notebook.
|
|
89
|
+
|
|
90
|
+
Examples::
|
|
91
|
+
|
|
92
|
+
%pytutor_config --no-scroll # default to fit-all (no inner scroll)
|
|
93
|
+
%pytutor_config --scroll # default to fixed window with inner scroll
|
|
94
|
+
%pytutor_config --reset # back to defaults
|
|
95
|
+
%pytutor_config # show current settings
|
|
96
|
+
"""
|
|
97
|
+
parser = argparse.ArgumentParser(prog="%pytutor_config", add_help=False)
|
|
98
|
+
parser.add_argument("--no-scroll", dest="no_scroll", action="store_true",
|
|
99
|
+
help="Default to fit-all: window grows to show all content, no inner scroll")
|
|
100
|
+
parser.add_argument("--scroll", dest="use_scroll", action="store_true",
|
|
101
|
+
help="Default to fixed window with inner scroll (the default)")
|
|
102
|
+
parser.add_argument("--reset", action="store_true",
|
|
103
|
+
help="Reset all settings to defaults")
|
|
104
|
+
try:
|
|
105
|
+
args = parser.parse_known_args(line.split() if line else [])[0]
|
|
106
|
+
except SystemExit:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
if args.reset:
|
|
110
|
+
self._scroll = _DEFAULT_SCROLL
|
|
111
|
+
display(HTML("<p>pytutor config reset to defaults.</p>"))
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
# --no-scroll / --scroll are both store_true; --no-scroll wins if both given.
|
|
115
|
+
if args.no_scroll:
|
|
116
|
+
self._scroll = False
|
|
117
|
+
elif args.use_scroll:
|
|
118
|
+
self._scroll = True
|
|
119
|
+
|
|
120
|
+
# Show current settings
|
|
121
|
+
display(HTML(
|
|
122
|
+
"<table style='font-family:monospace;font-size:13px;'>"
|
|
123
|
+
f"<tr><td><b>Scrolling</b></td>"
|
|
124
|
+
f"<td>{'fixed window (inner scroll)' if self._scroll else 'fit-all (no inner scroll)'}</td></tr>"
|
|
125
|
+
"</table>"
|
|
126
|
+
))
|
|
127
|
+
|
|
128
|
+
# ── %%pytutor ────────────────────────────────────────────────────
|
|
59
129
|
|
|
60
130
|
@cell_magic("pytutor")
|
|
61
131
|
def pytutor(self, line: str, cell: str):
|
|
62
132
|
"""Trace the cell code and display an interactive visualization."""
|
|
63
133
|
parser = _make_parser()
|
|
64
134
|
try:
|
|
65
|
-
args = parser.
|
|
135
|
+
args, _unknown = parser.parse_known_args(line.split() if line else [])
|
|
66
136
|
except SystemExit:
|
|
67
137
|
return
|
|
68
138
|
|
|
@@ -87,6 +157,14 @@ class TutorMagics(Magics):
|
|
|
87
157
|
inputs=inputs,
|
|
88
158
|
)
|
|
89
159
|
|
|
160
|
+
# Scrolling: a cell flag wins; otherwise fall back to the notebook default.
|
|
161
|
+
if args.no_scroll:
|
|
162
|
+
scroll = False
|
|
163
|
+
elif args.use_scroll:
|
|
164
|
+
scroll = True
|
|
165
|
+
else:
|
|
166
|
+
scroll = self._scroll
|
|
167
|
+
|
|
90
168
|
if args.raw:
|
|
91
169
|
return JSON(steps)
|
|
92
170
|
else:
|
|
@@ -96,7 +174,7 @@ class TutorMagics(Magics):
|
|
|
96
174
|
# strips <style>, <script>, <iframe>, <input>, etc. from untrusted
|
|
97
175
|
# outputs. When the user runs the cell live, the output is trusted
|
|
98
176
|
# and the iframe renders with full interactivity (slider, SVG arrows).
|
|
99
|
-
result = render_trace(steps, source_code=cell, height=args.height)
|
|
177
|
+
result = render_trace(steps, source_code=cell, height=args.height, scroll=scroll)
|
|
100
178
|
import warnings
|
|
101
179
|
with warnings.catch_warnings():
|
|
102
180
|
# Suppress IPython's "Consider using IFrame instead" warning —
|
|
@@ -42,10 +42,22 @@ import json
|
|
|
42
42
|
__all__ = ["render_trace"]
|
|
43
43
|
|
|
44
44
|
|
|
45
|
-
def _generate_inner_html(
|
|
46
|
-
|
|
45
|
+
def _generate_inner_html(
|
|
46
|
+
steps: list[dict], source_code: str, height: int = 500, scroll: bool = True
|
|
47
|
+
) -> str:
|
|
48
|
+
"""Generate the full HTML page that goes inside the iframe srcdoc.
|
|
49
|
+
|
|
50
|
+
``scroll`` controls the initial scrolling mode:
|
|
51
|
+
- True (default): fixed-height window, the columns scroll internally.
|
|
52
|
+
- False: the window grows to fit ALL content (no inner scrollbars); the
|
|
53
|
+
parent page's auto-resize watcher stretches the iframe to match.
|
|
54
|
+
The mode can also be toggled at runtime via the "⤢ Fit all / ⇅ Scroll"
|
|
55
|
+
button in the navbar.
|
|
56
|
+
"""
|
|
47
57
|
if not steps:
|
|
48
58
|
return '<!DOCTYPE html><html><body><p style="color:red;">No trace steps to display.</p></body></html>'
|
|
59
|
+
initial_scroll_js = "true" if scroll else "false"
|
|
60
|
+
widget_cls = "jpt-widget" if scroll else "jpt-widget no-scroll"
|
|
49
61
|
|
|
50
62
|
# Determine source lines
|
|
51
63
|
if source_code:
|
|
@@ -68,6 +80,8 @@ def _generate_inner_html(steps: list[dict], source_code: str, height: int = 500)
|
|
|
68
80
|
"stdout": step.get("stdout", ""),
|
|
69
81
|
"call_stack": [],
|
|
70
82
|
"return_value": step.get("return_value"),
|
|
83
|
+
"error": step.get("error"),
|
|
84
|
+
"func": step.get("func"),
|
|
71
85
|
"event": step.get("event", ""),
|
|
72
86
|
"input_prompt": step.get("input_prompt", ""),
|
|
73
87
|
"input_index": step.get("input_index", -1),
|
|
@@ -142,6 +156,13 @@ body {{
|
|
|
142
156
|
}}
|
|
143
157
|
.jpt-navbar button:hover {{ background: #f0f0f0; }}
|
|
144
158
|
.jpt-navbar button:disabled {{ opacity: 0.4; cursor: default; }}
|
|
159
|
+
.jpt-mode-btn {{
|
|
160
|
+
margin-left: auto;
|
|
161
|
+
font-weight: 600;
|
|
162
|
+
background: #fff;
|
|
163
|
+
}}
|
|
164
|
+
.jpt-mode-btn.active {{ background: #3b82f6; color: #fff; border-color: #2563eb; }}
|
|
165
|
+
.jpt-mode-btn.active:hover {{ background: #2563eb; }}
|
|
145
166
|
.jpt-slider {{
|
|
146
167
|
flex: 1;
|
|
147
168
|
min-width: 100px;
|
|
@@ -188,6 +209,24 @@ body {{
|
|
|
188
209
|
overflow: hidden;
|
|
189
210
|
min-height: 0;
|
|
190
211
|
}}
|
|
212
|
+
/* No-scroll ("fit all") mode: the widget grows to fit its full content and the
|
|
213
|
+
columns stop scrolling internally. The parent auto-resize watcher then
|
|
214
|
+
stretches the iframe to the new height. Placed after the base rules so it
|
|
215
|
+
wins the cascade. */
|
|
216
|
+
.jpt-widget.no-scroll {{
|
|
217
|
+
height: auto !important;
|
|
218
|
+
min-height: 120px;
|
|
219
|
+
}}
|
|
220
|
+
.jpt-widget.no-scroll .jpt-main,
|
|
221
|
+
.jpt-widget.no-scroll .jpt-right-panel {{
|
|
222
|
+
overflow: visible;
|
|
223
|
+
min-height: 0;
|
|
224
|
+
}}
|
|
225
|
+
.jpt-widget.no-scroll .jpt-code-section,
|
|
226
|
+
.jpt-widget.no-scroll .jpt-frames-section,
|
|
227
|
+
.jpt-widget.no-scroll .jpt-heap-section {{
|
|
228
|
+
overflow: visible;
|
|
229
|
+
}}
|
|
191
230
|
.jpt-code-section {{
|
|
192
231
|
flex: 0 0 40%;
|
|
193
232
|
min-width: 30px;
|
|
@@ -250,6 +289,31 @@ body {{
|
|
|
250
289
|
background: #fce7f3;
|
|
251
290
|
border-left: 3px solid #e10c65;
|
|
252
291
|
}}
|
|
292
|
+
.jpt-code-line.jpt-error {{
|
|
293
|
+
background: #fee2e2;
|
|
294
|
+
border-left: 3px solid #dc2626;
|
|
295
|
+
}}
|
|
296
|
+
.jpt-error .jpt-arrow {{ color: #dc2626; }}
|
|
297
|
+
.jpt-error .jpt-code-text {{ color: #b91c1c; font-weight: 600; }}
|
|
298
|
+
/* Error banner shown in the Program Output panel on exception steps */
|
|
299
|
+
.jpt-error-banner {{
|
|
300
|
+
margin-top: 4px;
|
|
301
|
+
background: #fee2e2;
|
|
302
|
+
border: 1px solid #fca5a5;
|
|
303
|
+
border-left: 4px solid #dc2626;
|
|
304
|
+
border-radius: 4px;
|
|
305
|
+
padding: 6px 10px;
|
|
306
|
+
color: #991b1b;
|
|
307
|
+
font-family: 'DejaVu Sans Mono', monospace;
|
|
308
|
+
font-size: 12px;
|
|
309
|
+
white-space: pre-wrap;
|
|
310
|
+
word-break: break-word;
|
|
311
|
+
}}
|
|
312
|
+
.jpt-error-banner .jpt-error-banner-title {{
|
|
313
|
+
display: block;
|
|
314
|
+
font-weight: 700;
|
|
315
|
+
margin-bottom: 2px;
|
|
316
|
+
}}
|
|
253
317
|
.jpt-arrow {{
|
|
254
318
|
width: 16px; flex: 0 0 16px; text-align: center;
|
|
255
319
|
color: transparent; font-weight: bold; user-select: none;
|
|
@@ -278,6 +342,10 @@ body {{
|
|
|
278
342
|
display: flex; justify-content: space-between;
|
|
279
343
|
}}
|
|
280
344
|
.jpt-frame-line {{ font-size: 10px; font-weight: normal; color: #64748b; }}
|
|
345
|
+
.jpt-return-badge {{
|
|
346
|
+
font-size: 10px; font-weight: 600; color: #065f46; background: #d1fae5;
|
|
347
|
+
border-radius: 3px; padding: 0 5px; font-family: 'DejaVu Sans Mono', monospace;
|
|
348
|
+
}}
|
|
281
349
|
.jpt-frame-body {{ padding: 3px 8px; }}
|
|
282
350
|
.jpt-var-row {{
|
|
283
351
|
display: flex; align-items: center; padding: 1px 0; font-size: 12px; gap: 2px;
|
|
@@ -380,7 +448,7 @@ body {{
|
|
|
380
448
|
</style>
|
|
381
449
|
</head>
|
|
382
450
|
<body>
|
|
383
|
-
<div class="
|
|
451
|
+
<div class="{widget_cls}" id="jptWidget" style="height:{height}px;position:relative;">
|
|
384
452
|
<!-- Input dialog overlay (hidden by default) -->
|
|
385
453
|
<div class="jpt-input-overlay" id="inputOverlay" style="display:none;">
|
|
386
454
|
<div class="jpt-input-dialog">
|
|
@@ -396,10 +464,12 @@ body {{
|
|
|
396
464
|
<button id="btnNext" title="Next">▶</button>
|
|
397
465
|
<button id="btnLast" title="Last">⏭</button>
|
|
398
466
|
<span class="jpt-step-counter" id="stepCounter">Step 1 of {n_steps}</span>
|
|
467
|
+
<button id="btn-scroll-mode" class="jpt-mode-btn" title="Toggle scrolling: off = window fits all content (no inner scroll); on = fixed window with inner scroll">⇅ Scroll</button>
|
|
399
468
|
</div>
|
|
400
469
|
<div class="jpt-output-section">
|
|
401
470
|
<div class="jpt-output-label">Program Output</div>
|
|
402
471
|
<div class="jpt-stdout" id="stdout"></div>
|
|
472
|
+
<div class="jpt-error-banner" id="errorBanner" style="display:none;"></div>
|
|
403
473
|
<div class="jpt-input-badge" id="inputBadge" style="display:none;"></div>
|
|
404
474
|
</div>
|
|
405
475
|
<div class="jpt-main">
|
|
@@ -435,6 +505,7 @@ body {{
|
|
|
435
505
|
var slider = document.getElementById('slider');
|
|
436
506
|
var stepCounter = document.getElementById('stepCounter');
|
|
437
507
|
var stdout = document.getElementById('stdout');
|
|
508
|
+
var errorBanner = document.getElementById('errorBanner');
|
|
438
509
|
var codeDisplay = document.getElementById('codeDisplay');
|
|
439
510
|
var framesSection = document.getElementById('framesSection');
|
|
440
511
|
var heapContent = document.getElementById('heapContent');
|
|
@@ -543,7 +614,7 @@ body {{
|
|
|
543
614
|
return result;
|
|
544
615
|
}}
|
|
545
616
|
|
|
546
|
-
function renderCode(lineNo, prevLineNo) {{
|
|
617
|
+
function renderCode(lineNo, prevLineNo, errorLineNo) {{
|
|
547
618
|
if (!lineNo || lineNo < 1) lineNo = 1;
|
|
548
619
|
if (!prevLineNo) prevLineNo = 0;
|
|
549
620
|
var html = '';
|
|
@@ -552,10 +623,11 @@ body {{
|
|
|
552
623
|
var line = sourceLines[i] || '';
|
|
553
624
|
var highlighted = line.trim() ? highlightPython(line) : ' ';
|
|
554
625
|
var cls = '';
|
|
555
|
-
if (ln ===
|
|
626
|
+
if (errorLineNo && ln === errorLineNo) cls = ' jpt-error';
|
|
627
|
+
else if (ln === lineNo) cls = ' jpt-current';
|
|
556
628
|
else if (ln === prevLineNo) cls = ' jpt-executed';
|
|
557
629
|
html += '<div class="jpt-code-line' + cls + '">' +
|
|
558
|
-
'<span class="jpt-arrow"
|
|
630
|
+
'<span class="jpt-arrow">' + (errorLineNo && ln === errorLineNo ? '✗' : '▶') + '</span>' +
|
|
559
631
|
'<span class="jpt-lineno">' + ln + '</span>' +
|
|
560
632
|
'<span class="jpt-code-text">' + highlighted + '</span>' +
|
|
561
633
|
'</div>';
|
|
@@ -761,12 +833,19 @@ body {{
|
|
|
761
833
|
curStep = idx;
|
|
762
834
|
var step = steps[idx];
|
|
763
835
|
var prevLine = (idx > 0) ? steps[idx - 1].line : 0;
|
|
764
|
-
|
|
836
|
+
var isError = (step.event === 'exception' && step.error);
|
|
837
|
+
renderCode(step.line || 0, prevLine, isError ? step.line : 0);
|
|
765
838
|
renderFrames(step);
|
|
766
839
|
renderHeap(step);
|
|
767
840
|
stdout.textContent = step.stdout || '';
|
|
768
841
|
if (!step.stdout) {{ stdout.style.color = '#86efac'; stdout.style.fontStyle = 'italic'; stdout.textContent = '(no output yet)'; }}
|
|
769
842
|
else {{ stdout.style.color = '#166534'; stdout.style.fontStyle = 'normal'; }}
|
|
843
|
+
if (isError) {{
|
|
844
|
+
errorBanner.innerHTML = '<span class="jpt-error-banner-title">Uncaught exception</span>' + escapeHtml(step.error);
|
|
845
|
+
errorBanner.style.display = 'block';
|
|
846
|
+
}} else {{
|
|
847
|
+
errorBanner.style.display = 'none';
|
|
848
|
+
}}
|
|
770
849
|
updateInputBadge(step);
|
|
771
850
|
stepCounter.textContent = 'Step ' + (idx + 1) + ' of ' + nSteps;
|
|
772
851
|
slider.value = idx;
|
|
@@ -899,8 +978,44 @@ body {{
|
|
|
899
978
|
divider1.addEventListener('dblclick', toggleCollapse(divider1, codeSection));
|
|
900
979
|
divider2.addEventListener('dblclick', toggleCollapse(divider2, framesSection));
|
|
901
980
|
|
|
902
|
-
//
|
|
903
|
-
|
|
981
|
+
// ── Scroll mode (fixed window + inner scroll ⇄ fit-all, no inner scroll) ──
|
|
982
|
+
// scrollMode true = fixed-height window, columns scroll internally.
|
|
983
|
+
// scrollMode false = window grows to fit ALL content (no inner scrollbars);
|
|
984
|
+
// the parent page's auto-resize watcher stretches the iframe to match.
|
|
985
|
+
var scrollMode = {initial_scroll_js};
|
|
986
|
+
var scrollBtn = document.getElementById('btn-scroll-mode');
|
|
987
|
+
var jptWidgetEl = document.querySelector('.jpt-widget');
|
|
988
|
+
function applyScrollMode() {{
|
|
989
|
+
jptWidgetEl.classList.toggle('no-scroll', !scrollMode);
|
|
990
|
+
if (scrollMode) {{
|
|
991
|
+
scrollBtn.textContent = '⇅ Scroll';
|
|
992
|
+
scrollBtn.classList.remove('active');
|
|
993
|
+
scrollBtn.title = 'Scrolling ON: fixed window, columns scroll internally. Click to fit all content (no inner scroll).';
|
|
994
|
+
}} else {{
|
|
995
|
+
scrollBtn.textContent = '⤢ Fit all';
|
|
996
|
+
scrollBtn.classList.add('active');
|
|
997
|
+
scrollBtn.title = 'Scrolling OFF: window fits all content. Click to return to fixed window with inner scroll.';
|
|
998
|
+
}}
|
|
999
|
+
// Re-lay arrows once the box has settled at its new size.
|
|
1000
|
+
requestAnimationFrame(function() {{ requestAnimationFrame(drawArrows); }});
|
|
1001
|
+
}}
|
|
1002
|
+
scrollBtn.onclick = function() {{
|
|
1003
|
+
scrollMode = !scrollMode;
|
|
1004
|
+
applyScrollMode();
|
|
1005
|
+
}};
|
|
1006
|
+
if (!scrollMode) {{ applyScrollMode(); }}
|
|
1007
|
+
|
|
1008
|
+
// Initial render — if the program raises an error, start on the
|
|
1009
|
+
// first exception step (like Python Tutor, which steps to the error),
|
|
1010
|
+
// so the red line + error message are visible immediately.
|
|
1011
|
+
var initialStep = 0;
|
|
1012
|
+
for (var si0 = 0; si0 < nSteps; si0++) {{
|
|
1013
|
+
if (steps[si0].event === 'exception' && steps[si0].error) {{
|
|
1014
|
+
initialStep = si0;
|
|
1015
|
+
break;
|
|
1016
|
+
}}
|
|
1017
|
+
}}
|
|
1018
|
+
renderStep(initialStep);
|
|
904
1019
|
}})();
|
|
905
1020
|
</script>
|
|
906
1021
|
</body>
|
|
@@ -911,12 +1026,19 @@ def render_trace(
|
|
|
911
1026
|
steps: list[dict],
|
|
912
1027
|
source_code: str | None = None,
|
|
913
1028
|
height: int = 500,
|
|
1029
|
+
scroll: bool = True,
|
|
914
1030
|
) -> str:
|
|
915
1031
|
"""Render trace steps as interactive HTML inside an iframe.
|
|
916
1032
|
|
|
917
1033
|
The entire visualization (HTML + CSS + JS) is wrapped in
|
|
918
1034
|
``<iframe srcdoc="...">`` which works in trusted JupyterLab notebooks.
|
|
919
1035
|
|
|
1036
|
+
``height`` sets the fixed window height in *scrolling* mode. ``scroll``
|
|
1037
|
+
sets the initial scrolling mode: ``True`` (default) = fixed window with
|
|
1038
|
+
inner column scrollbars; ``False`` = the window grows to fit all content
|
|
1039
|
+
(no inner scrollbars). Students can flip it at any time with the
|
|
1040
|
+
"⤢ Fit all / ⇅ Scroll" button in the navbar.
|
|
1041
|
+
|
|
920
1042
|
Features:
|
|
921
1043
|
- Fixed widget height with internal scrolling — no "dancing" cell
|
|
922
1044
|
- Slider navigation (``<input type="range">``) — drag to scrub through steps
|
|
@@ -927,7 +1049,7 @@ def render_trace(
|
|
|
927
1049
|
- Heap only shows objects reachable from the current call stack
|
|
928
1050
|
- Keyboard navigation (← → arrows)
|
|
929
1051
|
"""
|
|
930
|
-
inner = _generate_inner_html(steps, source_code or "", height)
|
|
1052
|
+
inner = _generate_inner_html(steps, source_code or "", height, scroll=scroll)
|
|
931
1053
|
# Escape for srcdoc attribute
|
|
932
1054
|
escaped = html.escape(inner, quote=True)
|
|
933
1055
|
# Match cpp-tutor's approach exactly to prevent "dancing":
|
|
@@ -514,14 +514,35 @@ def _trace_code_internal(
|
|
|
514
514
|
# Same object always gets the same label across all steps.
|
|
515
515
|
id_map: dict[int, str] = {}
|
|
516
516
|
|
|
517
|
+
# Frames that have seen an 'exception' event but not yet resumed
|
|
518
|
+
# (no intervening 'line'/'call' in the same frame). A 'return' event
|
|
519
|
+
# from such a frame is a spurious interpreter artifact of exception
|
|
520
|
+
# unwinding (Python fires 'return' with arg=None as each frame is
|
|
521
|
+
# popped), NOT a real return — we suppress it so the student isn't
|
|
522
|
+
# shown a misleading "Return: None" step after an error.
|
|
523
|
+
pending_exc_frames: set[int] = set()
|
|
524
|
+
|
|
517
525
|
def _trace(frame, event, arg):
|
|
518
526
|
nonlocal step_count, _current_input_prompt, _current_input_index
|
|
519
527
|
if frame.f_code.co_filename != filename:
|
|
520
528
|
return _trace # skip frames from other files
|
|
521
529
|
if step_count >= max_steps:
|
|
522
530
|
raise _StepLimitExceeded() # stop execution, not just tracing
|
|
531
|
+
|
|
532
|
+
# Suppress the synthetic module-frame 'call' event. CPython fires it
|
|
533
|
+
# with f_lineno == 0 before the first line runs, which rendered a
|
|
534
|
+
# phantom "line 0" step. The first real 'line' event is step 1, so
|
|
535
|
+
# the visualization now starts at line 1 (matching Python Tutor).
|
|
536
|
+
if frame.f_code.co_name == "<module>" and event == "call":
|
|
537
|
+
return _trace
|
|
538
|
+
|
|
523
539
|
step_count += 1
|
|
524
540
|
|
|
541
|
+
if event in ("line", "call"):
|
|
542
|
+
# Execution resumed in this frame → any earlier exception was
|
|
543
|
+
# caught here, so clear its unwinding marker.
|
|
544
|
+
pending_exc_frames.discard(id(frame))
|
|
545
|
+
|
|
525
546
|
# Capture input info from the PREVIOUS step's execution.
|
|
526
547
|
# input() runs during line execution (between trace callbacks),
|
|
527
548
|
# so _current_input_prompt was set by the line that just ran.
|
|
@@ -561,7 +582,22 @@ def _trace_code_internal(
|
|
|
561
582
|
"input_index": step_input_index,
|
|
562
583
|
})
|
|
563
584
|
elif event == "return":
|
|
564
|
-
|
|
585
|
+
# Suppress the spurious 'return' (arg=None) that CPython emits
|
|
586
|
+
# when an exception unwinds a frame that did NOT catch it.
|
|
587
|
+
# (During unwinding even the module frame reports line 0, so
|
|
588
|
+
# this also keeps the phantom "line 0" out of error traces.)
|
|
589
|
+
if id(frame) in pending_exc_frames:
|
|
590
|
+
pending_exc_frames.discard(id(frame))
|
|
591
|
+
return _trace
|
|
592
|
+
pending_exc_frames.discard(id(frame))
|
|
593
|
+
if frame.f_code.co_name == "<module>":
|
|
594
|
+
# Scripts don't return values. Keep this step — it is the
|
|
595
|
+
# one that carries the FINAL state (all variables bound,
|
|
596
|
+
# complete stdout, since 'line' events fire before each
|
|
597
|
+
# statement) — but present no return value.
|
|
598
|
+
ret_repr = None
|
|
599
|
+
else:
|
|
600
|
+
ret_repr = _safe_repr(arg) if arg is not None else "None"
|
|
565
601
|
steps.append({
|
|
566
602
|
"line": lineno,
|
|
567
603
|
"source": source,
|
|
@@ -578,6 +614,15 @@ def _trace_code_internal(
|
|
|
578
614
|
# Don't record our internal exception
|
|
579
615
|
if exc_type is _StepLimitExceeded:
|
|
580
616
|
return None
|
|
617
|
+
if lineno == 0:
|
|
618
|
+
# The interpreter re-fires the 'exception' event as a frame
|
|
619
|
+
# finishes unwinding; that second event reports line 0
|
|
620
|
+
# (no source line). Only the first event — with the real
|
|
621
|
+
# line number — is worth showing.
|
|
622
|
+
return _trace
|
|
623
|
+
# Mark this frame as mid-unwind; a 'return' or re-fired
|
|
624
|
+
# 'exception' from it before resuming is spurious.
|
|
625
|
+
pending_exc_frames.add(id(frame))
|
|
581
626
|
steps.append({
|
|
582
627
|
"line": lineno,
|
|
583
628
|
"source": source,
|
|
@@ -603,17 +648,22 @@ def _trace_code_internal(
|
|
|
603
648
|
except _StepLimitExceeded:
|
|
604
649
|
pass # execution stopped due to step limit
|
|
605
650
|
except Exception as e:
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
651
|
+
# Safety net for exceptions the trace callback can't see (e.g.
|
|
652
|
+
# SyntaxError, which is raised before execution starts). If the
|
|
653
|
+
# trace already recorded the exception with its real line number,
|
|
654
|
+
# don't append a duplicate "line 0" step.
|
|
655
|
+
if not any(s.get("event") == "exception" for s in steps):
|
|
656
|
+
steps.append({
|
|
657
|
+
"line": 0,
|
|
658
|
+
"source": f"# {type(e).__name__}: {e}",
|
|
659
|
+
"call_stack": [],
|
|
660
|
+
"event": "exception",
|
|
661
|
+
"func": "<module>",
|
|
662
|
+
"error": f"{type(e).__name__}: {e}",
|
|
663
|
+
"stdout": captured.getvalue(),
|
|
664
|
+
"input_prompt": "",
|
|
665
|
+
"input_index": -1,
|
|
666
|
+
})
|
|
617
667
|
finally:
|
|
618
668
|
sys.settrace(old_trace)
|
|
619
669
|
sys.stdout = old_stdout
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "jupyter-python-tutor"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.4"
|
|
8
8
|
description = "Python Tutor-style step-by-step code visualization for JupyterLab 4.x and Notebook 7"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -77,3 +77,55 @@ c = Circle('red', 5)
|
|
|
77
77
|
def test_height_option(self, ipython):
|
|
78
78
|
result = ipython.run_cell_magic("pytutor", "--height 500", "x = 3")
|
|
79
79
|
assert result is None or hasattr(result, 'children') or "jpt" in str(result)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestScrollModeMagic:
|
|
83
|
+
"""Test the --no-scroll/--scroll cell flag and the %pytutor_config line magic."""
|
|
84
|
+
|
|
85
|
+
def _run_cell_capturing_scroll(self, ipython, line=""):
|
|
86
|
+
"""Run %%pytutor with render_trace mocked; return the scroll kwarg passed."""
|
|
87
|
+
from unittest import mock
|
|
88
|
+
with mock.patch(
|
|
89
|
+
"jupyter_python_tutor.magics.render_trace",
|
|
90
|
+
return_value="<html></html>",
|
|
91
|
+
) as mr:
|
|
92
|
+
ipython.run_cell_magic("pytutor", line, "x = 3")
|
|
93
|
+
assert mr.called, "render_trace was not called"
|
|
94
|
+
return mr.call_args.kwargs.get("scroll")
|
|
95
|
+
|
|
96
|
+
def test_config_magic_registered(self, ipython):
|
|
97
|
+
assert "pytutor_config" in ipython.magics_manager.magics["line"]
|
|
98
|
+
|
|
99
|
+
def test_default_scroll_true(self, ipython):
|
|
100
|
+
ipython.run_line_magic("pytutor_config", "--reset")
|
|
101
|
+
assert self._run_cell_capturing_scroll(ipython) is True
|
|
102
|
+
|
|
103
|
+
def test_cell_no_scroll_flag(self, ipython):
|
|
104
|
+
ipython.run_line_magic("pytutor_config", "--reset")
|
|
105
|
+
assert self._run_cell_capturing_scroll(ipython, line="--no-scroll") is False
|
|
106
|
+
|
|
107
|
+
def test_cell_scroll_overrides_config(self, ipython):
|
|
108
|
+
ipython.run_line_magic("pytutor_config", "--no-scroll")
|
|
109
|
+
# a cell --scroll wins over the notebook --no-scroll default
|
|
110
|
+
assert self._run_cell_capturing_scroll(ipython, line="--scroll") is True
|
|
111
|
+
|
|
112
|
+
def test_config_no_scroll(self, ipython):
|
|
113
|
+
ipython.run_line_magic("pytutor_config", "--no-scroll")
|
|
114
|
+
assert self._run_cell_capturing_scroll(ipython) is False
|
|
115
|
+
|
|
116
|
+
def test_config_scroll_restores(self, ipython):
|
|
117
|
+
ipython.run_line_magic("pytutor_config", "--no-scroll")
|
|
118
|
+
ipython.run_line_magic("pytutor_config", "--scroll")
|
|
119
|
+
assert self._run_cell_capturing_scroll(ipython) is True
|
|
120
|
+
|
|
121
|
+
def test_config_reset(self, ipython):
|
|
122
|
+
ipython.run_line_magic("pytutor_config", "--no-scroll")
|
|
123
|
+
ipython.run_line_magic("pytutor_config", "--reset")
|
|
124
|
+
assert self._run_cell_capturing_scroll(ipython) is True
|
|
125
|
+
|
|
126
|
+
def test_config_no_args_shows_setting(self, ipython):
|
|
127
|
+
ipython.run_line_magic("pytutor_config", "--no-scroll")
|
|
128
|
+
# running with no args displays the current setting (no exception)
|
|
129
|
+
ipython.run_line_magic("pytutor_config", "")
|
|
130
|
+
# and it is still no-scroll
|
|
131
|
+
assert self._run_cell_capturing_scroll(ipython) is False
|
|
@@ -371,3 +371,102 @@ outer(5)
|
|
|
371
371
|
result = render_trace(steps, source_code=code)
|
|
372
372
|
assert "<iframe" in result
|
|
373
373
|
assert "outer" in result or "inner" in result
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class TestErrorDisplay:
|
|
377
|
+
"""Error steps must be delivered to and displayed by the frontend."""
|
|
378
|
+
|
|
379
|
+
def test_error_field_delivered_to_frontend(self):
|
|
380
|
+
code = 'x = 1+"1"\nprint(x)'
|
|
381
|
+
steps = trace_code(code)
|
|
382
|
+
result = render_trace(steps, source_code=code)
|
|
383
|
+
# The error message must survive into the embedded step JSON
|
|
384
|
+
# (it was previously dropped by the clean_step projection).
|
|
385
|
+
# (quotes are HTML-escaped inside the srcdoc, so match a stable
|
|
386
|
+
# unquoted substring)
|
|
387
|
+
assert "unsupported operand type(s) for +" in result
|
|
388
|
+
assert "int" in result and "str" in result
|
|
389
|
+
|
|
390
|
+
def test_frontend_has_error_rendering(self):
|
|
391
|
+
code = 'x = 1+"1"'
|
|
392
|
+
steps = trace_code(code)
|
|
393
|
+
inner = _get_srcdoc_content(render_trace(steps, source_code=code))
|
|
394
|
+
assert 'id="errorBanner"' in inner # banner element
|
|
395
|
+
assert ".jpt-code-line.jpt-error" in inner # red line CSS
|
|
396
|
+
assert "Uncaught exception" in inner # banner text
|
|
397
|
+
assert "jpt-error" in inner # line gets class
|
|
398
|
+
|
|
399
|
+
def test_frontend_starts_at_error_step(self):
|
|
400
|
+
code = 'x = 1+"1"\nprint(x)'
|
|
401
|
+
steps = trace_code(code)
|
|
402
|
+
inner = _get_srcdoc_content(render_trace(steps, source_code=code))
|
|
403
|
+
# Auto-jump: initial step is the first exception step
|
|
404
|
+
assert "initialStep" in inner
|
|
405
|
+
assert "event === 'exception'" in inner
|
|
406
|
+
|
|
407
|
+
def test_no_error_banner_for_successful_code(self):
|
|
408
|
+
steps = trace_code("x = 1\nprint(x)")
|
|
409
|
+
result = render_trace(steps, source_code="x = 1\nprint(x)")
|
|
410
|
+
# No exception steps → no error data in the embedded JSON
|
|
411
|
+
assert "Uncaught exception" in _get_srcdoc_content(result) # CSS/JS strings
|
|
412
|
+
# but no step carries an error payload
|
|
413
|
+
import html as H
|
|
414
|
+
src = _get_srcdoc_content(result)
|
|
415
|
+
jsline = [l for l in src.splitlines() if l.strip().startswith("var steps = ")][0]
|
|
416
|
+
import json
|
|
417
|
+
data = json.loads(jsline.strip()[len("var steps = "):-1])
|
|
418
|
+
assert not any(s.get("error") for s in data)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class TestScrollMode:
|
|
422
|
+
"""Test the optional scrolling (fixed window ⇄ fit-all) feature."""
|
|
423
|
+
|
|
424
|
+
def _inner(self, scroll: bool) -> str:
|
|
425
|
+
steps = trace_code("x = 1\ny = 2")
|
|
426
|
+
return _get_srcdoc_content(
|
|
427
|
+
render_trace(steps, source_code="x = 1\ny = 2", scroll=scroll)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
def test_default_is_scroll_mode(self):
|
|
431
|
+
inner = _get_srcdoc_content(render_trace(trace_code("x = 1"), source_code="x = 1"))
|
|
432
|
+
# Default scroll=True: no 'no-scroll' class on the widget
|
|
433
|
+
assert 'class="jpt-widget no-scroll"' not in inner
|
|
434
|
+
assert "var scrollMode = true;" in inner
|
|
435
|
+
|
|
436
|
+
def test_no_scroll_mode_class(self):
|
|
437
|
+
inner = self._inner(False)
|
|
438
|
+
assert 'class="jpt-widget no-scroll"' in inner
|
|
439
|
+
assert "var scrollMode = false;" in inner
|
|
440
|
+
|
|
441
|
+
def test_no_scroll_css_present(self):
|
|
442
|
+
inner = self._inner(False)
|
|
443
|
+
assert ".jpt-widget.no-scroll" in inner
|
|
444
|
+
# columns stop scrolling in fit-all
|
|
445
|
+
assert ".jpt-widget.no-scroll .jpt-code-section" in inner
|
|
446
|
+
|
|
447
|
+
def test_mode_button_present(self):
|
|
448
|
+
inner = self._inner(False)
|
|
449
|
+
assert 'id="btn-scroll-mode"' in inner
|
|
450
|
+
assert "jpt-mode-btn" in inner
|
|
451
|
+
|
|
452
|
+
def test_mode_button_active_state_in_js(self):
|
|
453
|
+
inner = self._inner(False)
|
|
454
|
+
# applyScrollMode swaps label + active class
|
|
455
|
+
assert "applyScrollMode" in inner
|
|
456
|
+
assert "Fit all" in inner
|
|
457
|
+
assert "⇅ Scroll" in inner
|
|
458
|
+
|
|
459
|
+
def test_toggle_redraws_arrows(self):
|
|
460
|
+
inner = self._inner(False)
|
|
461
|
+
# fitting all changes layout, so arrows must be re-laid out
|
|
462
|
+
assert "requestAnimationFrame(drawArrows)" in inner
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class TestScrollParamSignature:
|
|
466
|
+
"""render_trace / _generate_inner_html accept a scroll parameter."""
|
|
467
|
+
|
|
468
|
+
def test_render_trace_has_scroll_param(self):
|
|
469
|
+
import inspect
|
|
470
|
+
sig = inspect.signature(render_trace)
|
|
471
|
+
assert "scroll" in sig.parameters
|
|
472
|
+
assert sig.parameters["scroll"].default is True
|
|
@@ -13,6 +13,10 @@ class TestBasicTracing:
|
|
|
13
13
|
def test_simple_assignment(self):
|
|
14
14
|
steps = get_trace("x = 3")
|
|
15
15
|
assert len(steps) >= 1
|
|
16
|
+
# No synthetic "call <module>" (line 0) step — the first step is
|
|
17
|
+
# the first real line.
|
|
18
|
+
assert steps[0]["event"] == "line"
|
|
19
|
+
assert steps[0]["line"] == 1
|
|
16
20
|
assert steps[-1]["event"] in ("line", "return")
|
|
17
21
|
|
|
18
22
|
def test_step_has_line_number(self):
|
|
@@ -282,6 +286,91 @@ class TestErrorHandling:
|
|
|
282
286
|
steps = get_trace("1/0")
|
|
283
287
|
assert any(s.get("event") == "exception" for s in steps)
|
|
284
288
|
|
|
289
|
+
def test_uncaught_error_has_correct_line_and_message(self):
|
|
290
|
+
steps = get_trace('x = 1+"1"\nprint(x)')
|
|
291
|
+
excs = [s for s in steps if s.get("event") == "exception"]
|
|
292
|
+
assert len(excs) >= 1
|
|
293
|
+
# The failing line is highlighted
|
|
294
|
+
assert any(s["line"] == 1 for s in excs)
|
|
295
|
+
# The error message matches Python's
|
|
296
|
+
first = excs[0]
|
|
297
|
+
assert "TypeError" in first["error"]
|
|
298
|
+
assert "unsupported operand type(s) for +: 'int' and 'str'" in first["error"]
|
|
299
|
+
|
|
300
|
+
def test_no_phantom_return_after_uncaught_error(self):
|
|
301
|
+
"""CPython fires a spurious 'return' (arg=None) when an uncaught
|
|
302
|
+
exception unwinds a frame. The tracer must not surface it as a
|
|
303
|
+
'Return: None' step."""
|
|
304
|
+
steps = get_trace('x = 1+"1"\nprint(x)')
|
|
305
|
+
for s in steps:
|
|
306
|
+
if s["event"] == "return":
|
|
307
|
+
assert s.get("return_value") != "None", \
|
|
308
|
+
f"phantom None-return step leaked: {s}"
|
|
309
|
+
# Exact step sequence: line 1 -> exception(s); no call/return steps
|
|
310
|
+
events = [s["event"] for s in steps]
|
|
311
|
+
assert events[0] == "line"
|
|
312
|
+
assert steps[0]["line"] == 1 # no phantom "call <module> line 0"
|
|
313
|
+
assert all(e == "exception" for e in events[1:])
|
|
314
|
+
|
|
315
|
+
def test_no_phantom_return_uncaught_in_nested_function(self):
|
|
316
|
+
steps = get_trace('def f():\n return 1/0\n\ny = f()\nprint(y)')
|
|
317
|
+
for s in steps:
|
|
318
|
+
if s["event"] == "return":
|
|
319
|
+
assert s.get("return_value") != "None"
|
|
320
|
+
assert any(s["event"] == "exception" and "division by zero" in s["error"]
|
|
321
|
+
for s in steps)
|
|
322
|
+
|
|
323
|
+
def test_real_return_kept_when_error_caught_in_same_frame(self):
|
|
324
|
+
code = ("def f():\n"
|
|
325
|
+
" try:\n"
|
|
326
|
+
" x = 1/0\n"
|
|
327
|
+
" except ZeroDivisionError:\n"
|
|
328
|
+
" x = 5\n"
|
|
329
|
+
" return x\n"
|
|
330
|
+
"\ny = f()\nprint(y)")
|
|
331
|
+
steps = get_trace(code)
|
|
332
|
+
returns = [s for s in steps if s["event"] == "return"]
|
|
333
|
+
# f() really returns 5 — that step must survive
|
|
334
|
+
assert any(s.get("return_value") == "5" for s in returns)
|
|
335
|
+
assert any(s["event"] == "exception" for s in steps)
|
|
336
|
+
|
|
337
|
+
def test_real_return_kept_when_error_caught_in_caller(self):
|
|
338
|
+
code = ("def f():\n"
|
|
339
|
+
" return 1/0\n"
|
|
340
|
+
"\ntry:\n"
|
|
341
|
+
" f()\n"
|
|
342
|
+
"except ZeroDivisionError:\n"
|
|
343
|
+
" pass\n"
|
|
344
|
+
'print("done")')
|
|
345
|
+
steps = get_trace(code)
|
|
346
|
+
# module finally returns None as a normal (non-unwind) return
|
|
347
|
+
assert any(s["event"] == "return" and s["func"] == "<module>"
|
|
348
|
+
for s in steps)
|
|
349
|
+
assert "done" in steps[-1]["stdout"]
|
|
350
|
+
|
|
351
|
+
def test_no_line0_step_and_final_state_preserved(self):
|
|
352
|
+
"""The synthetic module 'call' (line 0) must not be a step, and the
|
|
353
|
+
final module 'return' step must carry the complete final state
|
|
354
|
+
(all variables + all stdout) with NO return_value on the module."""
|
|
355
|
+
steps = get_trace("x = 3\ny = 4\nprint(x + y)")
|
|
356
|
+
assert steps[0]["line"] == 1
|
|
357
|
+
assert all(s["line"] >= 1 for s in steps)
|
|
358
|
+
last = steps[-1]
|
|
359
|
+
assert last["event"] == "return"
|
|
360
|
+
assert last["func"] == "<module>"
|
|
361
|
+
assert last.get("return_value") is None # scripts don't return values
|
|
362
|
+
gv = last["call_stack"][0]
|
|
363
|
+
assert gv["simple_vars"].get("x") == "3"
|
|
364
|
+
assert gv["simple_vars"].get("y") == "4"
|
|
365
|
+
assert "7" in last["stdout"]
|
|
366
|
+
|
|
367
|
+
def test_module_return_has_no_return_value(self):
|
|
368
|
+
steps = get_trace("x = 1")
|
|
369
|
+
for s in steps:
|
|
370
|
+
if s["event"] == "return" and s["func"] == "<module>":
|
|
371
|
+
assert s.get("return_value") is None, \
|
|
372
|
+
f"module frame must not show a return value: {s.get('return_value')!r}"
|
|
373
|
+
|
|
285
374
|
def test_max_steps_limit(self):
|
|
286
375
|
# Infinite loop should be stopped
|
|
287
376
|
code = "while True:\n pass"
|
|
File without changes
|
|
File without changes
|
{jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor.egg-info/requires.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|