jupyter-python-tutor 0.3.1__tar.gz → 0.3.4__tar.gz

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