jupyter-python-tutor 0.3.1__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.1 → jupyter_python_tutor-0.3.3}/PKG-INFO +1 -1
  2. {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor/__init__.py +1 -1
  3. {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor/renderer.py +56 -6
  4. {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor/tracer.py +62 -12
  5. {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/PKG-INFO +1 -1
  6. {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.3}/pyproject.toml +1 -1
  7. {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.3}/tests/test_renderer.py +45 -0
  8. {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.3}/tests/test_tracer.py +89 -0
  9. {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.3}/LICENSE +0 -0
  10. {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.3}/README.md +0 -0
  11. {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor/magics.py +0 -0
  12. {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/SOURCES.txt +0 -0
  13. {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/dependency_links.txt +0 -0
  14. {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/requires.txt +0 -0
  15. {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.3}/jupyter_python_tutor.egg-info/top_level.txt +0 -0
  16. {jupyter_python_tutor-0.3.1 → jupyter_python_tutor-0.3.3}/setup.cfg +0 -0
  17. {jupyter_python_tutor-0.3.1 → 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.1
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.3.1"
14
+ __version__ = "0.3.3"
15
15
 
16
16
  __all__ = ["trace_code", "render_trace", "load_ipython_extension"]
@@ -68,6 +68,8 @@ def _generate_inner_html(steps: list[dict], source_code: str, height: int = 500)
68
68
  "stdout": step.get("stdout", ""),
69
69
  "call_stack": [],
70
70
  "return_value": step.get("return_value"),
71
+ "error": step.get("error"),
72
+ "func": step.get("func"),
71
73
  "event": step.get("event", ""),
72
74
  "input_prompt": step.get("input_prompt", ""),
73
75
  "input_index": step.get("input_index", -1),
@@ -250,6 +252,31 @@ body {{
250
252
  background: #fce7f3;
251
253
  border-left: 3px solid #e10c65;
252
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
+ }}
253
280
  .jpt-arrow {{
254
281
  width: 16px; flex: 0 0 16px; text-align: center;
255
282
  color: transparent; font-weight: bold; user-select: none;
@@ -278,6 +305,10 @@ body {{
278
305
  display: flex; justify-content: space-between;
279
306
  }}
280
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
+ }}
281
312
  .jpt-frame-body {{ padding: 3px 8px; }}
282
313
  .jpt-var-row {{
283
314
  display: flex; align-items: center; padding: 1px 0; font-size: 12px; gap: 2px;
@@ -400,6 +431,7 @@ body {{
400
431
  <div class="jpt-output-section">
401
432
  <div class="jpt-output-label">Program Output</div>
402
433
  <div class="jpt-stdout" id="stdout"></div>
434
+ <div class="jpt-error-banner" id="errorBanner" style="display:none;"></div>
403
435
  <div class="jpt-input-badge" id="inputBadge" style="display:none;"></div>
404
436
  </div>
405
437
  <div class="jpt-main">
@@ -435,6 +467,7 @@ body {{
435
467
  var slider = document.getElementById('slider');
436
468
  var stepCounter = document.getElementById('stepCounter');
437
469
  var stdout = document.getElementById('stdout');
470
+ var errorBanner = document.getElementById('errorBanner');
438
471
  var codeDisplay = document.getElementById('codeDisplay');
439
472
  var framesSection = document.getElementById('framesSection');
440
473
  var heapContent = document.getElementById('heapContent');
@@ -543,7 +576,7 @@ body {{
543
576
  return result;
544
577
  }}
545
578
 
546
- function renderCode(lineNo, prevLineNo) {{
579
+ function renderCode(lineNo, prevLineNo, errorLineNo) {{
547
580
  if (!lineNo || lineNo < 1) lineNo = 1;
548
581
  if (!prevLineNo) prevLineNo = 0;
549
582
  var html = '';
@@ -552,10 +585,11 @@ body {{
552
585
  var line = sourceLines[i] || '';
553
586
  var highlighted = line.trim() ? highlightPython(line) : '&nbsp;';
554
587
  var cls = '';
555
- if (ln === lineNo) cls = ' jpt-current';
588
+ if (errorLineNo && ln === errorLineNo) cls = ' jpt-error';
589
+ else if (ln === lineNo) cls = ' jpt-current';
556
590
  else if (ln === prevLineNo) cls = ' jpt-executed';
557
591
  html += '<div class="jpt-code-line' + cls + '">' +
558
- '<span class="jpt-arrow">▶</span>' +
592
+ '<span class="jpt-arrow">' + (errorLineNo && ln === errorLineNo ? '✗' : '▶') + '</span>' +
559
593
  '<span class="jpt-lineno">' + ln + '</span>' +
560
594
  '<span class="jpt-code-text">' + highlighted + '</span>' +
561
595
  '</div>';
@@ -761,12 +795,19 @@ body {{
761
795
  curStep = idx;
762
796
  var step = steps[idx];
763
797
  var prevLine = (idx > 0) ? steps[idx - 1].line : 0;
764
- renderCode(step.line || 0, prevLine);
798
+ var isError = (step.event === 'exception' && step.error);
799
+ renderCode(step.line || 0, prevLine, isError ? step.line : 0);
765
800
  renderFrames(step);
766
801
  renderHeap(step);
767
802
  stdout.textContent = step.stdout || '';
768
803
  if (!step.stdout) {{ stdout.style.color = '#86efac'; stdout.style.fontStyle = 'italic'; stdout.textContent = '(no output yet)'; }}
769
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
+ }}
770
811
  updateInputBadge(step);
771
812
  stepCounter.textContent = 'Step ' + (idx + 1) + ' of ' + nSteps;
772
813
  slider.value = idx;
@@ -899,8 +940,17 @@ body {{
899
940
  divider1.addEventListener('dblclick', toggleCollapse(divider1, codeSection));
900
941
  divider2.addEventListener('dblclick', toggleCollapse(divider2, framesSection));
901
942
 
902
- // Initial render
903
- renderStep(0);
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);
904
954
  }})();
905
955
  </script>
906
956
  </body>
@@ -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.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.1"
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"