jupyter-python-tutor 0.3.0__tar.gz → 0.3.3__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.0 → jupyter_python_tutor-0.3.3}/PKG-INFO +1 -1
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor/__init__.py +1 -1
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor/renderer.py +130 -150
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor/tracer.py +62 -12
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/PKG-INFO +1 -1
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/pyproject.toml +1 -1
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/tests/test_renderer.py +45 -0
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/tests/test_tracer.py +89 -0
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/LICENSE +0 -0
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/README.md +0 -0
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor/magics.py +0 -0
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/SOURCES.txt +0 -0
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/dependency_links.txt +0 -0
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/requires.txt +0 -0
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/top_level.txt +0 -0
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/setup.cfg +0 -0
- {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/tests/test_magics.py +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
"""Render a trace as interactive HTML inside an iframe srcdoc.
|
|
1
|
+
"""Render a Python trace as interactive HTML inside an iframe srcdoc.
|
|
2
2
|
|
|
3
3
|
JupyterLab 4 sanitizes HTML output from untrusted notebooks, stripping
|
|
4
4
|
``<style>``, ``<script>``, ``<iframe>``, ``<button>``, ``<input>``, etc.
|
|
5
5
|
However, when a notebook is **trusted** (either run live by the user, or
|
|
6
6
|
signed with ``jupyter trust``), all HTML passes through unsanitized.
|
|
7
7
|
|
|
8
|
-
This renderer wraps the entire visualization in ``<iframe srcdoc="...">``,
|
|
9
|
-
which:
|
|
8
|
+
This renderer wraps the entire visualization in ``<iframe srcdoc="...">``, which:
|
|
10
9
|
- Works in trusted notebooks (live execution + signed notebooks)
|
|
11
10
|
- Contains full HTML with ``<style>``, ``<script>``, ``<input type="range">``
|
|
12
11
|
- Is fully isolated from JupyterLab's CSS/JS
|
|
@@ -26,11 +25,12 @@ Layout (OPT_Mentor / Python Tutor style):
|
|
|
26
25
|
│ │ └──┘ │ └──┘ │
|
|
27
26
|
└──────────────┴───────────────────────────────────────────┘
|
|
28
27
|
|
|
29
|
-
Key design:
|
|
30
|
-
-
|
|
31
|
-
-
|
|
28
|
+
Key design (matches jupyter-cpp-tutor):
|
|
29
|
+
- Fixed widget height with internal scrolling — no "dancing" cell
|
|
30
|
+
- Slider navigation (``<input type="range">``)
|
|
31
|
+
- SVG arrows from frame variables to heap objects
|
|
32
32
|
- Single-step view — only current step shown, JS swaps content
|
|
33
|
-
-
|
|
33
|
+
- Draggable dividers between columns (double-click to collapse)
|
|
34
34
|
- Functions show TRUE name (co_name), not __name__ (functools.wraps-safe)
|
|
35
35
|
- Heap only shows objects reachable from the current call stack
|
|
36
36
|
"""
|
|
@@ -39,17 +39,11 @@ from __future__ import annotations
|
|
|
39
39
|
import html
|
|
40
40
|
import json
|
|
41
41
|
|
|
42
|
-
__all__ = ["render_trace"
|
|
42
|
+
__all__ = ["render_trace"]
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
def _generate_inner_html(steps: list[dict], source_code: str, height: int = 500) -> str:
|
|
46
|
-
"""Generate the full HTML page that goes inside the iframe srcdoc.
|
|
47
|
-
|
|
48
|
-
This is a self-contained HTML page with:
|
|
49
|
-
- CSS for the visualization layout
|
|
50
|
-
- All step data embedded as JSON
|
|
51
|
-
- JavaScript for step navigation, SVG arrows, resizing
|
|
52
|
-
"""
|
|
46
|
+
"""Generate the full HTML page that goes inside the iframe srcdoc."""
|
|
53
47
|
if not steps:
|
|
54
48
|
return '<!DOCTYPE html><html><body><p style="color:red;">No trace steps to display.</p></body></html>'
|
|
55
49
|
|
|
@@ -74,6 +68,8 @@ def _generate_inner_html(steps: list[dict], source_code: str, height: int = 500)
|
|
|
74
68
|
"stdout": step.get("stdout", ""),
|
|
75
69
|
"call_stack": [],
|
|
76
70
|
"return_value": step.get("return_value"),
|
|
71
|
+
"error": step.get("error"),
|
|
72
|
+
"func": step.get("func"),
|
|
77
73
|
"event": step.get("event", ""),
|
|
78
74
|
"input_prompt": step.get("input_prompt", ""),
|
|
79
75
|
"input_index": step.get("input_index", -1),
|
|
@@ -107,7 +103,7 @@ def _generate_inner_html(steps: list[dict], source_code: str, height: int = 500)
|
|
|
107
103
|
steps_json = json.dumps(clean_steps, ensure_ascii=False)
|
|
108
104
|
source_json = json.dumps(source_lines, ensure_ascii=False)
|
|
109
105
|
|
|
110
|
-
return
|
|
106
|
+
return rf"""<!DOCTYPE html>
|
|
111
107
|
<html lang="en">
|
|
112
108
|
<head>
|
|
113
109
|
<meta charset="utf-8">
|
|
@@ -187,55 +183,58 @@ body {{
|
|
|
187
183
|
overflow-y: auto;
|
|
188
184
|
min-height: 20px;
|
|
189
185
|
}}
|
|
190
|
-
/* Main area */
|
|
186
|
+
/* Main content area — flex, internal scroll, fixed height */
|
|
191
187
|
.jpt-main {{
|
|
192
188
|
display: flex;
|
|
193
|
-
|
|
189
|
+
flex: 1;
|
|
190
|
+
overflow: hidden;
|
|
191
|
+
min-height: 0;
|
|
194
192
|
}}
|
|
195
193
|
.jpt-code-section {{
|
|
196
194
|
flex: 0 0 40%;
|
|
197
195
|
min-width: 30px;
|
|
198
196
|
overflow: auto;
|
|
199
197
|
background: #fafafa;
|
|
200
|
-
|
|
198
|
+
border-right: 1px solid #d4d4d4;
|
|
201
199
|
}}
|
|
202
200
|
.jpt-code-section::-webkit-scrollbar {{ width: 8px; height: 8px; }}
|
|
203
201
|
.jpt-code-section::-webkit-scrollbar-thumb {{ background: #cbd5e1; border-radius: 4px; }}
|
|
204
|
-
/* Draggable
|
|
202
|
+
/* Draggable dividers */
|
|
205
203
|
.jpt-divider {{
|
|
206
204
|
flex: 0 0 6px;
|
|
207
205
|
cursor: col-resize;
|
|
208
206
|
background: #d4d4d4;
|
|
209
|
-
position: relative;
|
|
210
207
|
user-select: none;
|
|
211
208
|
-webkit-user-select: none;
|
|
212
209
|
transition: background 0.15s;
|
|
210
|
+
flex-shrink: 0;
|
|
213
211
|
}}
|
|
214
212
|
.jpt-divider:hover {{ background: #3b82f6; }}
|
|
215
213
|
.jpt-divider.jpt-dragging {{ background: #2563eb; }}
|
|
216
214
|
.jpt-divider.jpt-collapsed {{ background: #9ca3af; cursor: pointer; }}
|
|
217
|
-
|
|
218
|
-
/* Right panel holds frames-divider-heap, no overflow so SVG arrows stay aligned */
|
|
215
|
+
/* Right panel holds frames + heap, with SVG overlay */
|
|
219
216
|
.jpt-right-panel {{
|
|
220
217
|
flex: 1;
|
|
221
218
|
display: flex;
|
|
222
219
|
min-width: 0;
|
|
223
220
|
position: relative;
|
|
221
|
+
overflow: hidden;
|
|
224
222
|
}}
|
|
225
223
|
.jpt-frames-section {{
|
|
226
224
|
flex: 0 0 35%;
|
|
227
225
|
min-width: 30px;
|
|
226
|
+
overflow: auto;
|
|
228
227
|
padding: 6px;
|
|
229
228
|
background: #fafafa;
|
|
230
229
|
}}
|
|
231
230
|
.jpt-heap-section {{
|
|
232
231
|
flex: 1;
|
|
232
|
+
overflow: auto;
|
|
233
233
|
padding: 6px;
|
|
234
234
|
background: #f5f5f5;
|
|
235
|
-
position: relative;
|
|
236
235
|
min-width: 30px;
|
|
237
236
|
}}
|
|
238
|
-
/* Code */
|
|
237
|
+
/* Code lines */
|
|
239
238
|
.jpt-code-line {{
|
|
240
239
|
display: flex;
|
|
241
240
|
align-items: baseline;
|
|
@@ -253,6 +252,31 @@ body {{
|
|
|
253
252
|
background: #fce7f3;
|
|
254
253
|
border-left: 3px solid #e10c65;
|
|
255
254
|
}}
|
|
255
|
+
.jpt-code-line.jpt-error {{
|
|
256
|
+
background: #fee2e2;
|
|
257
|
+
border-left: 3px solid #dc2626;
|
|
258
|
+
}}
|
|
259
|
+
.jpt-error .jpt-arrow {{ color: #dc2626; }}
|
|
260
|
+
.jpt-error .jpt-code-text {{ color: #b91c1c; font-weight: 600; }}
|
|
261
|
+
/* Error banner shown in the Program Output panel on exception steps */
|
|
262
|
+
.jpt-error-banner {{
|
|
263
|
+
margin-top: 4px;
|
|
264
|
+
background: #fee2e2;
|
|
265
|
+
border: 1px solid #fca5a5;
|
|
266
|
+
border-left: 4px solid #dc2626;
|
|
267
|
+
border-radius: 4px;
|
|
268
|
+
padding: 6px 10px;
|
|
269
|
+
color: #991b1b;
|
|
270
|
+
font-family: 'DejaVu Sans Mono', monospace;
|
|
271
|
+
font-size: 12px;
|
|
272
|
+
white-space: pre-wrap;
|
|
273
|
+
word-break: break-word;
|
|
274
|
+
}}
|
|
275
|
+
.jpt-error-banner .jpt-error-banner-title {{
|
|
276
|
+
display: block;
|
|
277
|
+
font-weight: 700;
|
|
278
|
+
margin-bottom: 2px;
|
|
279
|
+
}}
|
|
256
280
|
.jpt-arrow {{
|
|
257
281
|
width: 16px; flex: 0 0 16px; text-align: center;
|
|
258
282
|
color: transparent; font-weight: bold; user-select: none;
|
|
@@ -264,12 +288,12 @@ body {{
|
|
|
264
288
|
}}
|
|
265
289
|
.jpt-code-text {{ flex: 1; padding-left: 4px; }}
|
|
266
290
|
/* Python syntax highlighting */
|
|
267
|
-
.jpt-tok-kw {{ color: #c678dd; font-weight: 600; }}
|
|
268
|
-
.jpt-tok-str {{ color: #98c379; }}
|
|
269
|
-
.jpt-tok-num {{ color: #d19a66; }}
|
|
270
|
-
.jpt-tok-com {{ color: #7c7c7c; font-style: italic; }}
|
|
271
|
-
.jpt-tok-fn {{ color: #61afef; }}
|
|
272
|
-
.jpt-tok-builtin {{ color: #56b6c2; }}
|
|
291
|
+
.jpt-tok-kw {{ color: #c678dd; font-weight: 600; }}
|
|
292
|
+
.jpt-tok-str {{ color: #98c379; }}
|
|
293
|
+
.jpt-tok-num {{ color: #d19a66; }}
|
|
294
|
+
.jpt-tok-com {{ color: #7c7c7c; font-style: italic; }}
|
|
295
|
+
.jpt-tok-fn {{ color: #61afef; }}
|
|
296
|
+
.jpt-tok-builtin {{ color: #56b6c2; }}
|
|
273
297
|
/* Frames */
|
|
274
298
|
.jpt-frame {{
|
|
275
299
|
border: 1px solid #bae6fd; border-radius: 4px; background: #f0f9ff;
|
|
@@ -281,6 +305,10 @@ body {{
|
|
|
281
305
|
display: flex; justify-content: space-between;
|
|
282
306
|
}}
|
|
283
307
|
.jpt-frame-line {{ font-size: 10px; font-weight: normal; color: #64748b; }}
|
|
308
|
+
.jpt-return-badge {{
|
|
309
|
+
font-size: 10px; font-weight: 600; color: #065f46; background: #d1fae5;
|
|
310
|
+
border-radius: 3px; padding: 0 5px; font-family: 'DejaVu Sans Mono', monospace;
|
|
311
|
+
}}
|
|
284
312
|
.jpt-frame-body {{ padding: 3px 8px; }}
|
|
285
313
|
.jpt-var-row {{
|
|
286
314
|
display: flex; align-items: center; padding: 1px 0; font-size: 12px; gap: 2px;
|
|
@@ -348,7 +376,6 @@ body {{
|
|
|
348
376
|
pointer-events: none; z-index: 10;
|
|
349
377
|
}}
|
|
350
378
|
.jpt-arrow-path {{ stroke: #6b7280; stroke-width: 1.5; fill: none; marker-end: url(#arrowhead); }}
|
|
351
|
-
.jpt-arrow-path-highlight {{ stroke: #3b82f6; stroke-width: 2; }}
|
|
352
379
|
/* Input dialog overlay */
|
|
353
380
|
.jpt-input-overlay {{
|
|
354
381
|
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
|
@@ -384,7 +411,7 @@ body {{
|
|
|
384
411
|
</style>
|
|
385
412
|
</head>
|
|
386
413
|
<body>
|
|
387
|
-
<div class="jpt-widget" id="jptWidget">
|
|
414
|
+
<div class="jpt-widget" id="jptWidget" style="height:{height}px;position:relative;">
|
|
388
415
|
<!-- Input dialog overlay (hidden by default) -->
|
|
389
416
|
<div class="jpt-input-overlay" id="inputOverlay" style="display:none;">
|
|
390
417
|
<div class="jpt-input-dialog">
|
|
@@ -404,6 +431,7 @@ body {{
|
|
|
404
431
|
<div class="jpt-output-section">
|
|
405
432
|
<div class="jpt-output-label">Program Output</div>
|
|
406
433
|
<div class="jpt-stdout" id="stdout"></div>
|
|
434
|
+
<div class="jpt-error-banner" id="errorBanner" style="display:none;"></div>
|
|
407
435
|
<div class="jpt-input-badge" id="inputBadge" style="display:none;"></div>
|
|
408
436
|
</div>
|
|
409
437
|
<div class="jpt-main">
|
|
@@ -439,6 +467,7 @@ body {{
|
|
|
439
467
|
var slider = document.getElementById('slider');
|
|
440
468
|
var stepCounter = document.getElementById('stepCounter');
|
|
441
469
|
var stdout = document.getElementById('stdout');
|
|
470
|
+
var errorBanner = document.getElementById('errorBanner');
|
|
442
471
|
var codeDisplay = document.getElementById('codeDisplay');
|
|
443
472
|
var framesSection = document.getElementById('framesSection');
|
|
444
473
|
var heapContent = document.getElementById('heapContent');
|
|
@@ -454,8 +483,7 @@ body {{
|
|
|
454
483
|
}}
|
|
455
484
|
|
|
456
485
|
function funcDisplayName(ref) {{
|
|
457
|
-
|
|
458
|
-
return ref.name || 'func';
|
|
486
|
+
return ref.true_name || ref.name;
|
|
459
487
|
}}
|
|
460
488
|
|
|
461
489
|
// ─── Python syntax highlighting ──────────────────────────────────
|
|
@@ -472,41 +500,33 @@ body {{
|
|
|
472
500
|
var builtinSet = {{}}; PY_BUILTINS.forEach(function(b){{ builtinSet[b] = 1; }});
|
|
473
501
|
|
|
474
502
|
function highlightPython(line) {{
|
|
475
|
-
// Tokenize: comments, strings, numbers, identifiers, operators
|
|
476
503
|
var result = '';
|
|
477
504
|
var i = 0;
|
|
478
505
|
while (i < line.length) {{
|
|
479
506
|
var ch = line[i];
|
|
480
|
-
// Comment
|
|
481
507
|
if (ch === '#') {{
|
|
482
508
|
result += '<span class="jpt-tok-com">' + escapeHtml(line.substring(i)) + '</span>';
|
|
483
509
|
break;
|
|
484
510
|
}}
|
|
485
|
-
// String (single, double, triple)
|
|
486
511
|
if (ch === '"' || ch === "'") {{
|
|
487
512
|
var quote = ch;
|
|
488
|
-
// Check triple-quote
|
|
489
513
|
if (line.substring(i, i+3) === quote+quote+quote) {{
|
|
490
514
|
var end = line.indexOf(quote+quote+quote, i+3);
|
|
491
515
|
if (end < 0) end = line.length - 3;
|
|
492
|
-
|
|
493
|
-
result += '<span class="jpt-tok-str">' + escapeHtml(s) + '</span>';
|
|
516
|
+
result += '<span class="jpt-tok-str">' + escapeHtml(line.substring(i, end+3)) + '</span>';
|
|
494
517
|
i = end + 3;
|
|
495
518
|
continue;
|
|
496
519
|
}}
|
|
497
|
-
// Single/double quote — find matching close (handle escapes)
|
|
498
520
|
var j = i + 1;
|
|
499
521
|
while (j < line.length) {{
|
|
500
522
|
if (line[j] === '\\\\') {{ j += 2; continue; }}
|
|
501
523
|
if (line[j] === quote) {{ j++; break; }}
|
|
502
524
|
j++;
|
|
503
525
|
}}
|
|
504
|
-
|
|
505
|
-
result += '<span class="jpt-tok-str">' + escapeHtml(s) + '</span>';
|
|
526
|
+
result += '<span class="jpt-tok-str">' + escapeHtml(line.substring(i, j)) + '</span>';
|
|
506
527
|
i = j;
|
|
507
528
|
continue;
|
|
508
529
|
}}
|
|
509
|
-
// f-string prefix (f, r, b, fr, rf, etc. before quote)
|
|
510
530
|
if (/[frbFRB]/.test(ch) && i+1 < line.length && (line[i+1] === '"' || line[i+1] === "'")) {{
|
|
511
531
|
var j = i;
|
|
512
532
|
while (j < line.length && /[frbFRB]/.test(line[j])) j++;
|
|
@@ -518,17 +538,14 @@ body {{
|
|
|
518
538
|
if (line[k] === quote) {{ k++; break; }}
|
|
519
539
|
k++;
|
|
520
540
|
}}
|
|
521
|
-
|
|
522
|
-
result += '<span class="jpt-tok-str">' + escapeHtml(s) + '</span>';
|
|
541
|
+
result += '<span class="jpt-tok-str">' + escapeHtml(line.substring(i, k)) + '</span>';
|
|
523
542
|
i = k;
|
|
524
543
|
continue;
|
|
525
544
|
}}
|
|
526
545
|
}}
|
|
527
|
-
// Number
|
|
528
546
|
if (/[0-9]/.test(ch) || (ch === '.' && i+1 < line.length && /[0-9]/.test(line[i+1]))) {{
|
|
529
547
|
var j = i;
|
|
530
|
-
while (j < line.length && /[0-9._xXeE
|
|
531
|
-
// stop on +/- if not part of exponent
|
|
548
|
+
while (j < line.length && /[0-9._xXeE+\\-a-fA-F]/.test(line[j])) {{
|
|
532
549
|
if ((line[j] === '+' || line[j] === '-') && !/[eE]/.test(line[j-1])) break;
|
|
533
550
|
j++;
|
|
534
551
|
}}
|
|
@@ -536,12 +553,10 @@ body {{
|
|
|
536
553
|
i = j;
|
|
537
554
|
continue;
|
|
538
555
|
}}
|
|
539
|
-
// Identifier / keyword / builtin
|
|
540
556
|
if (/[a-zA-Z_]/.test(ch)) {{
|
|
541
557
|
var j = i;
|
|
542
558
|
while (j < line.length && /[a-zA-Z0-9_]/.test(line[j])) j++;
|
|
543
559
|
var word = line.substring(i, j);
|
|
544
|
-
// Check if followed by ( → function call
|
|
545
560
|
var afterWord = line.substring(j).trim();
|
|
546
561
|
if (kwSet[word]) {{
|
|
547
562
|
result += '<span class="jpt-tok-kw">' + escapeHtml(word) + '</span>';
|
|
@@ -555,14 +570,13 @@ body {{
|
|
|
555
570
|
i = j;
|
|
556
571
|
continue;
|
|
557
572
|
}}
|
|
558
|
-
// Default: escape and output
|
|
559
573
|
result += escapeHtml(ch);
|
|
560
574
|
i++;
|
|
561
575
|
}}
|
|
562
576
|
return result;
|
|
563
577
|
}}
|
|
564
578
|
|
|
565
|
-
function renderCode(lineNo, prevLineNo) {{
|
|
579
|
+
function renderCode(lineNo, prevLineNo, errorLineNo) {{
|
|
566
580
|
if (!lineNo || lineNo < 1) lineNo = 1;
|
|
567
581
|
if (!prevLineNo) prevLineNo = 0;
|
|
568
582
|
var html = '';
|
|
@@ -571,10 +585,11 @@ body {{
|
|
|
571
585
|
var line = sourceLines[i] || '';
|
|
572
586
|
var highlighted = line.trim() ? highlightPython(line) : ' ';
|
|
573
587
|
var cls = '';
|
|
574
|
-
if (ln ===
|
|
588
|
+
if (errorLineNo && ln === errorLineNo) cls = ' jpt-error';
|
|
589
|
+
else if (ln === lineNo) cls = ' jpt-current';
|
|
575
590
|
else if (ln === prevLineNo) cls = ' jpt-executed';
|
|
576
591
|
html += '<div class="jpt-code-line' + cls + '">' +
|
|
577
|
-
'<span class="jpt-arrow"
|
|
592
|
+
'<span class="jpt-arrow">' + (errorLineNo && ln === errorLineNo ? '✗' : '▶') + '</span>' +
|
|
578
593
|
'<span class="jpt-lineno">' + ln + '</span>' +
|
|
579
594
|
'<span class="jpt-code-text">' + highlighted + '</span>' +
|
|
580
595
|
'</div>';
|
|
@@ -672,7 +687,7 @@ body {{
|
|
|
672
687
|
html += '<div class="jpt-heap-object jpt-heap-module" data-heap-id="' + heapId + '">' +
|
|
673
688
|
'<div class="jpt-heap-type-label">module ' + escapeHtml(ref.name) + '</div></div>';
|
|
674
689
|
}} else if (ref.is_function) {{
|
|
675
|
-
var tn = ref
|
|
690
|
+
var tn = funcDisplayName(ref);
|
|
676
691
|
var dn = ref.display_name || ref.name;
|
|
677
692
|
var attrs = details.attrs || [];
|
|
678
693
|
var attrHtml = '';
|
|
@@ -710,14 +725,11 @@ body {{
|
|
|
710
725
|
}}
|
|
711
726
|
|
|
712
727
|
function drawArrows() {{
|
|
713
|
-
// Clear existing arrows (keep defs)
|
|
714
728
|
while (svgOverlay.lastChild && svgOverlay.lastChild.tagName !== 'defs') {{
|
|
715
729
|
svgOverlay.removeChild(svgOverlay.lastChild);
|
|
716
730
|
}}
|
|
717
|
-
// SVG overlay lives in .jpt-right-panel, spanning both frames and heap
|
|
718
731
|
var rightPanel = document.getElementById('rightPanel');
|
|
719
732
|
var panelRect = rightPanel.getBoundingClientRect();
|
|
720
|
-
// Find all pointer boxes in frames and matching heap objects
|
|
721
733
|
var pointers = framesSection.querySelectorAll('.jpt-var-pointer[data-heap-id]');
|
|
722
734
|
for (var i = 0; i < pointers.length; i++) {{
|
|
723
735
|
var ptr = pointers[i];
|
|
@@ -727,12 +739,10 @@ body {{
|
|
|
727
739
|
if (!heapObj) continue;
|
|
728
740
|
var ptrRect = ptr.getBoundingClientRect();
|
|
729
741
|
var objRect = heapObj.getBoundingClientRect();
|
|
730
|
-
// Convert to SVG coordinates (relative to rightPanel)
|
|
731
742
|
var x1 = ptrRect.right - panelRect.left;
|
|
732
743
|
var y1 = ptrRect.top + ptrRect.height / 2 - panelRect.top;
|
|
733
744
|
var x2 = objRect.left - panelRect.left;
|
|
734
745
|
var y2 = objRect.top + objRect.height / 2 - panelRect.top;
|
|
735
|
-
// Draw a curved path from pointer to heap object
|
|
736
746
|
var midX = x1 + (x2 - x1) * 0.5;
|
|
737
747
|
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
738
748
|
path.setAttribute('d', 'M ' + x1 + ' ' + y1 + ' C ' + midX + ' ' + y1 + ', ' + midX + ' ' + y2 + ', ' + x2 + ' ' + y2);
|
|
@@ -741,45 +751,21 @@ body {{
|
|
|
741
751
|
}}
|
|
742
752
|
}}
|
|
743
753
|
|
|
744
|
-
function autoResize() {{
|
|
745
|
-
// Auto-resize widget to fit content — the widget's height follows
|
|
746
|
-
// the tallest of code/frames/heap so there is no internal scroll
|
|
747
|
-
// and arrows never detach from their targets.
|
|
748
|
-
// The parent page polls the widget height and resizes the iframe.
|
|
749
|
-
var codeH = codeDisplay.scrollHeight;
|
|
750
|
-
var framesH = framesSection.scrollHeight;
|
|
751
|
-
var heapH = heapContent.scrollHeight;
|
|
752
|
-
var contentH = Math.max(codeH, framesH, heapH, {height});
|
|
753
|
-
// Add navbar + output section heights
|
|
754
|
-
var navbarH = document.querySelector('.jpt-navbar').offsetHeight;
|
|
755
|
-
var outputH = document.querySelector('.jpt-output-section').offsetHeight;
|
|
756
|
-
var totalH = contentH + navbarH + outputH + 12; // +12 for borders/padding
|
|
757
|
-
// Set widget height so parent script can read it
|
|
758
|
-
document.getElementById('jptWidget').style.height = totalH + 'px';
|
|
759
|
-
}}
|
|
760
|
-
|
|
761
754
|
// ─── Input handling ──────────────────────────────────────────────
|
|
762
|
-
|
|
763
|
-
// Once collected, navigation back/forth never re-prompts.
|
|
764
|
-
var inputsCollected = {{}}; // input_index → value string
|
|
755
|
+
var inputsCollected = {{}};
|
|
765
756
|
var inputOverlay = document.getElementById('inputOverlay');
|
|
766
757
|
var inputDialogLabel = document.getElementById('inputDialogLabel');
|
|
767
758
|
var inputDialogInput = document.getElementById('inputDialogInput');
|
|
768
759
|
var inputDialogBtn = document.getElementById('inputDialogBtn');
|
|
769
760
|
|
|
770
|
-
// Pre-populate from step data (inputs already provided during tracing)
|
|
771
761
|
for (var si = 0; si < nSteps; si++) {{
|
|
772
762
|
var ss = steps[si];
|
|
773
763
|
if (ss.input_index >= 0 && ss.input_index !== undefined) {{
|
|
774
|
-
// Extract the input value from stdout: prompt + value + newline
|
|
775
|
-
// The value is what the user typed (echoed after the prompt)
|
|
776
764
|
if (!(ss.input_index in inputsCollected)) {{
|
|
777
765
|
var prompt = ss.input_prompt || '';
|
|
778
766
|
var out = ss.stdout || '';
|
|
779
|
-
// stdout contains prompt + value + newline, find the value
|
|
780
767
|
if (out.includes(prompt)) {{
|
|
781
768
|
var afterPrompt = out.substring(out.indexOf(prompt) + prompt.length);
|
|
782
|
-
// The value is everything up to the first newline
|
|
783
769
|
var nl = afterPrompt.indexOf(String.fromCharCode(10));
|
|
784
770
|
if (nl >= 0) {{
|
|
785
771
|
inputsCollected[ss.input_index] = afterPrompt.substring(0, nl);
|
|
@@ -791,7 +777,6 @@ body {{
|
|
|
791
777
|
}}
|
|
792
778
|
}}
|
|
793
779
|
|
|
794
|
-
// Show input badge in output section if this step has input
|
|
795
780
|
function updateInputBadge(step) {{
|
|
796
781
|
var badge = document.getElementById('inputBadge');
|
|
797
782
|
if (!badge) return;
|
|
@@ -810,12 +795,19 @@ body {{
|
|
|
810
795
|
curStep = idx;
|
|
811
796
|
var step = steps[idx];
|
|
812
797
|
var prevLine = (idx > 0) ? steps[idx - 1].line : 0;
|
|
813
|
-
|
|
798
|
+
var isError = (step.event === 'exception' && step.error);
|
|
799
|
+
renderCode(step.line || 0, prevLine, isError ? step.line : 0);
|
|
814
800
|
renderFrames(step);
|
|
815
801
|
renderHeap(step);
|
|
816
802
|
stdout.textContent = step.stdout || '';
|
|
817
|
-
if (!step.stdout) stdout.style.color = '#86efac'
|
|
818
|
-
else stdout.style.color = '#166534'
|
|
803
|
+
if (!step.stdout) {{ stdout.style.color = '#86efac'; stdout.style.fontStyle = 'italic'; stdout.textContent = '(no output yet)'; }}
|
|
804
|
+
else {{ stdout.style.color = '#166534'; stdout.style.fontStyle = 'normal'; }}
|
|
805
|
+
if (isError) {{
|
|
806
|
+
errorBanner.innerHTML = '<span class="jpt-error-banner-title">Uncaught exception</span>' + escapeHtml(step.error);
|
|
807
|
+
errorBanner.style.display = 'block';
|
|
808
|
+
}} else {{
|
|
809
|
+
errorBanner.style.display = 'none';
|
|
810
|
+
}}
|
|
819
811
|
updateInputBadge(step);
|
|
820
812
|
stepCounter.textContent = 'Step ' + (idx + 1) + ' of ' + nSteps;
|
|
821
813
|
slider.value = idx;
|
|
@@ -823,18 +815,12 @@ body {{
|
|
|
823
815
|
btnPrev.disabled = (idx === 0);
|
|
824
816
|
btnNext.disabled = (idx === nSteps - 1);
|
|
825
817
|
btnLast.disabled = (idx === nSteps - 1);
|
|
826
|
-
// Draw arrows
|
|
818
|
+
// Draw arrows after DOM is updated (height is fixed, no resize needed)
|
|
827
819
|
requestAnimationFrame(function() {{
|
|
828
820
|
requestAnimationFrame(function() {{
|
|
829
|
-
autoResize();
|
|
830
821
|
drawArrows();
|
|
831
822
|
}});
|
|
832
823
|
}});
|
|
833
|
-
// Safety: also redraw after a longer delay in case rAF fires too early
|
|
834
|
-
setTimeout(function() {{
|
|
835
|
-
autoResize();
|
|
836
|
-
drawArrows();
|
|
837
|
-
}}, 100);
|
|
838
824
|
}}
|
|
839
825
|
|
|
840
826
|
// Event handlers
|
|
@@ -850,14 +836,12 @@ body {{
|
|
|
850
836
|
else if (e.key === 'ArrowRight') {{ renderStep(curStep + 1); e.preventDefault(); }}
|
|
851
837
|
}});
|
|
852
838
|
|
|
853
|
-
// Redraw arrows
|
|
854
|
-
window.addEventListener('resize',
|
|
839
|
+
// Redraw arrows on resize/scroll (height is fixed, just reposition arrows)
|
|
840
|
+
window.addEventListener('resize', drawArrows);
|
|
855
841
|
framesSection.addEventListener('scroll', drawArrows);
|
|
856
842
|
heapContent.addEventListener('scroll', drawArrows);
|
|
857
843
|
|
|
858
844
|
// ─── Draggable dividers ──────────────────────────────────────────
|
|
859
|
-
// Divider 1: between code section and right panel
|
|
860
|
-
// Divider 2: between frames section and heap section
|
|
861
845
|
var divider1 = document.getElementById('divider1');
|
|
862
846
|
var divider2 = document.getElementById('divider2');
|
|
863
847
|
var codeSection = document.getElementById('codeSection');
|
|
@@ -865,13 +849,11 @@ body {{
|
|
|
865
849
|
var mainEl = document.querySelector('.jpt-main');
|
|
866
850
|
var rightPanelEl = document.getElementById('rightPanel');
|
|
867
851
|
|
|
868
|
-
// Remember pre-collapse flex values so we can restore
|
|
869
852
|
var savedCodeFlex = '0 0 40%';
|
|
870
853
|
var savedFramesFlex = '0 0 35%';
|
|
871
854
|
|
|
872
|
-
function startDrag(divider
|
|
855
|
+
function startDrag(divider) {{
|
|
873
856
|
return function(e) {{
|
|
874
|
-
// Don't start drag if it was a double-click (collapse toggle)
|
|
875
857
|
if (divider._justCollapsed) {{
|
|
876
858
|
divider._justCollapsed = false;
|
|
877
859
|
return;
|
|
@@ -881,43 +863,35 @@ body {{
|
|
|
881
863
|
var startX = e.clientX;
|
|
882
864
|
var startCodeW = codeSection.offsetWidth;
|
|
883
865
|
var startFramesW = framesSection.offsetWidth;
|
|
884
|
-
var startHeapW = heapSection.offsetWidth;
|
|
885
866
|
var mainW = mainEl.offsetWidth;
|
|
886
867
|
var panelW = rightPanelEl.offsetWidth;
|
|
887
868
|
|
|
888
869
|
function onMove(ev) {{
|
|
889
870
|
var dx = ev.clientX - startX;
|
|
890
871
|
if (divider === divider1) {{
|
|
891
|
-
// Resize code vs (divider + right panel)
|
|
892
872
|
var newCodeW = Math.max(30, Math.min(mainW - 60, startCodeW + dx));
|
|
893
873
|
var pct = (newCodeW / mainW) * 100;
|
|
894
874
|
codeSection.style.flex = '0 0 ' + pct + '%';
|
|
895
|
-
// Ensure right panel takes remaining space
|
|
896
875
|
rightPanelEl.style.flex = '1';
|
|
897
876
|
}} else {{
|
|
898
|
-
// Resize frames vs heap within right panel
|
|
899
877
|
var newFramesW = Math.max(30, Math.min(panelW - 36, startFramesW + dx));
|
|
900
878
|
var fPct = (newFramesW / panelW) * 100;
|
|
901
879
|
framesSection.style.flex = '0 0 ' + fPct + '%';
|
|
902
880
|
heapSection.style.flex = '1';
|
|
903
881
|
}}
|
|
904
|
-
// Live redraw: arrows must follow the new column positions
|
|
905
882
|
drawArrows();
|
|
906
|
-
autoResize();
|
|
907
883
|
}}
|
|
908
884
|
|
|
909
|
-
function onUp(
|
|
885
|
+
function onUp() {{
|
|
910
886
|
divider.classList.remove('jpt-dragging');
|
|
911
887
|
document.removeEventListener('mousemove', onMove);
|
|
912
888
|
document.removeEventListener('mouseup', onUp);
|
|
913
|
-
// Save flex values for collapse restore
|
|
914
889
|
if (divider === divider1) {{
|
|
915
890
|
savedCodeFlex = codeSection.style.flex || '0 0 40%';
|
|
916
891
|
}} else {{
|
|
917
892
|
savedFramesFlex = framesSection.style.flex || '0 0 35%';
|
|
918
893
|
}}
|
|
919
894
|
drawArrows();
|
|
920
|
-
autoResize();
|
|
921
895
|
}}
|
|
922
896
|
|
|
923
897
|
document.addEventListener('mousemove', onMove);
|
|
@@ -925,14 +899,12 @@ body {{
|
|
|
925
899
|
}};
|
|
926
900
|
}}
|
|
927
901
|
|
|
928
|
-
|
|
929
|
-
function toggleCollapse(divider, sectionToHide, savedFlexVar, flexVarName) {{
|
|
902
|
+
function toggleCollapse(divider, sectionToHide) {{
|
|
930
903
|
return function(e) {{
|
|
931
904
|
e.preventDefault();
|
|
932
905
|
e.stopPropagation();
|
|
933
906
|
var isCollapsed = divider.classList.contains('jpt-collapsed');
|
|
934
907
|
if (isCollapsed) {{
|
|
935
|
-
// Expand: restore saved flex
|
|
936
908
|
divider.classList.remove('jpt-collapsed');
|
|
937
909
|
sectionToHide.style.display = '';
|
|
938
910
|
if (divider === divider1) {{
|
|
@@ -943,7 +915,6 @@ body {{
|
|
|
943
915
|
heapSection.style.flex = '1';
|
|
944
916
|
}}
|
|
945
917
|
}} else {{
|
|
946
|
-
// Collapse: save current flex then hide
|
|
947
918
|
if (divider === divider1) {{
|
|
948
919
|
savedCodeFlex = codeSection.style.flex || '0 0 40%';
|
|
949
920
|
}} else {{
|
|
@@ -951,7 +922,6 @@ body {{
|
|
|
951
922
|
}}
|
|
952
923
|
divider.classList.add('jpt-collapsed');
|
|
953
924
|
sectionToHide.style.display = 'none';
|
|
954
|
-
// Let the other side take full space
|
|
955
925
|
if (divider === divider1) {{
|
|
956
926
|
codeSection.style.flex = '0 0 0';
|
|
957
927
|
rightPanelEl.style.flex = '1';
|
|
@@ -962,17 +932,25 @@ body {{
|
|
|
962
932
|
}}
|
|
963
933
|
divider._justCollapsed = true;
|
|
964
934
|
drawArrows();
|
|
965
|
-
autoResize();
|
|
966
935
|
}};
|
|
967
936
|
}}
|
|
968
937
|
|
|
969
|
-
divider1.addEventListener('mousedown', startDrag(divider1
|
|
970
|
-
divider2.addEventListener('mousedown', startDrag(divider2
|
|
971
|
-
divider1.addEventListener('dblclick', toggleCollapse(divider1, codeSection
|
|
972
|
-
divider2.addEventListener('dblclick', toggleCollapse(divider2, framesSection
|
|
973
|
-
|
|
974
|
-
// Initial render
|
|
975
|
-
|
|
938
|
+
divider1.addEventListener('mousedown', startDrag(divider1));
|
|
939
|
+
divider2.addEventListener('mousedown', startDrag(divider2));
|
|
940
|
+
divider1.addEventListener('dblclick', toggleCollapse(divider1, codeSection));
|
|
941
|
+
divider2.addEventListener('dblclick', toggleCollapse(divider2, framesSection));
|
|
942
|
+
|
|
943
|
+
// Initial render — if the program raises an error, start on the
|
|
944
|
+
// first exception step (like Python Tutor, which steps to the error),
|
|
945
|
+
// so the red line + error message are visible immediately.
|
|
946
|
+
var initialStep = 0;
|
|
947
|
+
for (var si0 = 0; si0 < nSteps; si0++) {{
|
|
948
|
+
if (steps[si0].event === 'exception' && steps[si0].error) {{
|
|
949
|
+
initialStep = si0;
|
|
950
|
+
break;
|
|
951
|
+
}}
|
|
952
|
+
}}
|
|
953
|
+
renderStep(initialStep);
|
|
976
954
|
}})();
|
|
977
955
|
</script>
|
|
978
956
|
</body>
|
|
@@ -983,7 +961,6 @@ def render_trace(
|
|
|
983
961
|
steps: list[dict],
|
|
984
962
|
source_code: str | None = None,
|
|
985
963
|
height: int = 500,
|
|
986
|
-
widget_id: str | None = None,
|
|
987
964
|
) -> str:
|
|
988
965
|
"""Render trace steps as interactive HTML inside an iframe.
|
|
989
966
|
|
|
@@ -991,10 +968,11 @@ def render_trace(
|
|
|
991
968
|
``<iframe srcdoc="...">`` which works in trusted JupyterLab notebooks.
|
|
992
969
|
|
|
993
970
|
Features:
|
|
971
|
+
- Fixed widget height with internal scrolling — no "dancing" cell
|
|
994
972
|
- Slider navigation (``<input type="range">``) — drag to scrub through steps
|
|
995
973
|
- SVG arrows from frame variables to heap objects (like OPT_Mentor)
|
|
996
974
|
- Single-step view — only current step shown, JS swaps content
|
|
997
|
-
-
|
|
975
|
+
- Draggable dividers between columns (double-click to collapse)
|
|
998
976
|
- Functions show TRUE name (co_name), not __name__ (functools.wraps-safe)
|
|
999
977
|
- Heap only shows objects reachable from the current call stack
|
|
1000
978
|
- Keyboard navigation (← → arrows)
|
|
@@ -1002,31 +980,33 @@ def render_trace(
|
|
|
1002
980
|
inner = _generate_inner_html(steps, source_code or "", height)
|
|
1003
981
|
# Escape for srcdoc attribute
|
|
1004
982
|
escaped = html.escape(inner, quote=True)
|
|
1005
|
-
#
|
|
1006
|
-
#
|
|
1007
|
-
#
|
|
983
|
+
# Match cpp-tutor's approach exactly to prevent "dancing":
|
|
984
|
+
# 1. border:none so offsetHeight === style.height (no mismatch loop)
|
|
985
|
+
# 2. display:block + margin:0 to avoid inline baseline gap
|
|
986
|
+
# 3. Script reads body.scrollHeight + 20 (idempotent — always writes
|
|
987
|
+
# the same value, so no reflow)
|
|
988
|
+
# 4. onload for initial sizing (stripped by DOMPurify when untrusted,
|
|
989
|
+
# but setInterval catches up within 200ms)
|
|
1008
990
|
return (
|
|
1009
991
|
f'<iframe srcdoc="{escaped}" class="jpt-iframe" '
|
|
1010
|
-
f'style="width:100%;border:
|
|
1011
|
-
f'
|
|
1012
|
-
f'
|
|
1013
|
-
f'
|
|
992
|
+
f'style="width:100%;border:none;overflow:hidden;'
|
|
993
|
+
f'display:block;margin:0;" '
|
|
994
|
+
f'onload="this.style.height=this.contentDocument.body.scrollHeight+20+\'px\'">'
|
|
995
|
+
f'</iframe>'
|
|
996
|
+
f'<script>'
|
|
997
|
+
f'(function(){{'
|
|
998
|
+
f'function autoResize(){{'
|
|
999
|
+
f'document.querySelectorAll(\'iframe.jpt-iframe\').forEach(function(frame){{'
|
|
1014
1000
|
f'try{{'
|
|
1015
|
-
f'var
|
|
1016
|
-
f'if(
|
|
1017
|
-
f'var w=d.getElementById("jptWidget");'
|
|
1018
|
-
f'if(!w)return;'
|
|
1019
|
-
f'var h=w.offsetHeight+20;'
|
|
1020
|
-
f'if(h>0&&frame.offsetHeight!==h){{frame.style.height=h+"px";}}'
|
|
1001
|
+
f'var h=frame.contentDocument.body.scrollHeight;'
|
|
1002
|
+
f'if(h>0)frame.style.height=(h+20)+\'px\';'
|
|
1021
1003
|
f'}}catch(e){{}}'
|
|
1004
|
+
f'}});'
|
|
1022
1005
|
f'}}'
|
|
1023
|
-
f'
|
|
1024
|
-
f'
|
|
1025
|
-
f'
|
|
1006
|
+
f'if(!window._jptPollStarted){{'
|
|
1007
|
+
f'window._jptPollStarted=true;'
|
|
1008
|
+
f'setInterval(autoResize,200);'
|
|
1009
|
+
f'}}'
|
|
1010
|
+
f'}})();'
|
|
1026
1011
|
f'</script>'
|
|
1027
1012
|
)
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
def render_trace_widget(steps: list[dict], source_code: str | None = None) -> str:
|
|
1031
|
-
"""Alias for render_trace (returns HTML string)."""
|
|
1032
|
-
return render_trace(steps, source_code=source_code)
|
|
@@ -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.3"
|
|
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"
|
|
@@ -371,3 +371,48 @@ 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)
|
|
@@ -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
|
|
File without changes
|
{jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/requires.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|