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.
Files changed (17) hide show
  1. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/PKG-INFO +1 -1
  2. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor/__init__.py +1 -1
  3. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor/renderer.py +130 -150
  4. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor/tracer.py +62 -12
  5. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/PKG-INFO +1 -1
  6. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/pyproject.toml +1 -1
  7. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/tests/test_renderer.py +45 -0
  8. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/tests/test_tracer.py +89 -0
  9. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/LICENSE +0 -0
  10. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/README.md +0 -0
  11. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor/magics.py +0 -0
  12. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/SOURCES.txt +0 -0
  13. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/dependency_links.txt +0 -0
  14. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/requires.txt +0 -0
  15. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/top_level.txt +0 -0
  16. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/setup.cfg +0 -0
  17. {jupyter_python_tutor-0.3.0 → jupyter_python_tutor-0.3.3}/tests/test_magics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jupyter-python-tutor
3
- Version: 0.3.0
3
+ Version: 0.3.3
4
4
  Summary: Python Tutor-style step-by-step code visualization for JupyterLab 4.x and Notebook 7
5
5
  Author: dive4dec
6
6
  License-Expression: MIT
@@ -11,6 +11,6 @@ from .tracer import trace_code
11
11
  from .renderer import render_trace
12
12
  from .magics import load_ipython_extension
13
13
 
14
- __version__ = "0.2.0"
14
+ __version__ = "0.3.3"
15
15
 
16
16
  __all__ = ["trace_code", "render_trace", "load_ipython_extension"]
@@ -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
- - Slider navigation (``<input type="range">``) — not number buttons
31
- - SVG arrows from frame variables to heap objects (like OPT_Mentor/jsPlumb)
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
- - Resizable code panel (draggable divider)
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", "render_trace_widget"]
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 f"""<!DOCTYPE html>
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
- align-items: stretch;
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
- position: relative;
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 vertical dividers between columns */
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
- .jpt-divider.jpt-collapsed:hover {{ background: #3b82f6; }}
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; }} /* keywords: def, if, for... */
268
- .jpt-tok-str {{ color: #98c379; }} /* strings */
269
- .jpt-tok-num {{ color: #d19a66; }} /* numbers */
270
- .jpt-tok-com {{ color: #7c7c7c; font-style: italic; }} /* comments */
271
- .jpt-tok-fn {{ color: #61afef; }} /* function names */
272
- .jpt-tok-builtin {{ color: #56b6c2; }} /* builtins: print, len... */
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
- if (ref.true_name) return ref.true_name;
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
- var s = line.substring(i, end+3);
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
- var s = line.substring(i, j);
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
- var s = line.substring(i, k);
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+\-a-fA-F]/.test(line[j])) {{
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) : '&nbsp;';
573
587
  var cls = '';
574
- if (ln === lineNo) cls = ' jpt-current';
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">▶</span>' +
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.true_name || ref.name;
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
- // inputsCollected: cached values for each input_index
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
- renderCode(step.line || 0, prevLine);
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', stdout.style.fontStyle = 'italic', stdout.textContent = '(no output yet)';
818
- else stdout.style.color = '#166534', stdout.style.fontStyle = 'normal';
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 + auto-resize after DOM is updated
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 + auto-resize on window resize and scroll
854
- window.addEventListener('resize', function() {{ autoResize(); drawArrows(); }});
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, opts) {{
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(ev) {{
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
- // Double-click to collapse/expand
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, savedCodeFlex, 'savedCodeFlex'));
972
- divider2.addEventListener('dblclick', toggleCollapse(divider2, framesSection, savedFramesFlex, 'savedFramesFlex'));
973
-
974
- // Initial render
975
- renderStep(0);
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
- - Resizable code panel (CSS ``resize: horizontal``)
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
- # The iframe auto-resizes via a polling script in the parent that checks
1006
- # each iframe's content height and adjusts. This works in trusted notebooks
1007
- # where <script> survives sanitization.
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:1px solid #d4d4d4;border-radius:6px;"></iframe>'
1011
- f'<script>(function(){{'
1012
- f'var frame=this.previousElementSibling;'
1013
- f'function resize(){{'
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 d=frame.contentDocument;'
1016
- f'if(!d)return;'
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'resize();'
1024
- f'setInterval(resize,200);'
1025
- f'}}).call(document.currentScript);'
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
- ret_repr = _safe_repr(arg) if arg is not None else "None"
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
- steps.append({
607
- "line": 0,
608
- "source": f"# {type(e).__name__}: {e}",
609
- "call_stack": [],
610
- "event": "exception",
611
- "func": "<module>",
612
- "error": f"{type(e).__name__}: {e}",
613
- "stdout": captured.getvalue(),
614
- "input_prompt": "",
615
- "input_index": -1,
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jupyter-python-tutor
3
- Version: 0.3.0
3
+ Version: 0.3.3
4
4
  Summary: Python Tutor-style step-by-step code visualization for JupyterLab 4.x and Notebook 7
5
5
  Author: dive4dec
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "jupyter-python-tutor"
7
- version = "0.3.0"
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"