jupyter-python-tutor 0.3.3__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.3 → jupyter_python_tutor-0.3.4}/PKG-INFO +1 -1
  2. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor/__init__.py +1 -1
  3. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor/magics.py +83 -5
  4. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor/renderer.py +76 -4
  5. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor.egg-info/PKG-INFO +1 -1
  6. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/pyproject.toml +1 -1
  7. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/tests/test_magics.py +52 -0
  8. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/tests/test_renderer.py +54 -0
  9. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/LICENSE +0 -0
  10. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/README.md +0 -0
  11. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor/tracer.py +0 -0
  12. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor.egg-info/SOURCES.txt +0 -0
  13. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor.egg-info/dependency_links.txt +0 -0
  14. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor.egg-info/requires.txt +0 -0
  15. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/jupyter_python_tutor.egg-info/top_level.txt +0 -0
  16. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/setup.cfg +0 -0
  17. {jupyter_python_tutor-0.3.3 → jupyter_python_tutor-0.3.4}/tests/test_tracer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jupyter-python-tutor
3
- Version: 0.3.3
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.3"
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:
@@ -144,6 +156,13 @@ body {{
144
156
  }}
145
157
  .jpt-navbar button:hover {{ background: #f0f0f0; }}
146
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; }}
147
166
  .jpt-slider {{
148
167
  flex: 1;
149
168
  min-width: 100px;
@@ -190,6 +209,24 @@ body {{
190
209
  overflow: hidden;
191
210
  min-height: 0;
192
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
+ }}
193
230
  .jpt-code-section {{
194
231
  flex: 0 0 40%;
195
232
  min-width: 30px;
@@ -411,7 +448,7 @@ body {{
411
448
  </style>
412
449
  </head>
413
450
  <body>
414
- <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;">
415
452
  <!-- Input dialog overlay (hidden by default) -->
416
453
  <div class="jpt-input-overlay" id="inputOverlay" style="display:none;">
417
454
  <div class="jpt-input-dialog">
@@ -427,6 +464,7 @@ body {{
427
464
  <button id="btnNext" title="Next">▶</button>
428
465
  <button id="btnLast" title="Last">⏭</button>
429
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>
430
468
  </div>
431
469
  <div class="jpt-output-section">
432
470
  <div class="jpt-output-label">Program Output</div>
@@ -940,6 +978,33 @@ body {{
940
978
  divider1.addEventListener('dblclick', toggleCollapse(divider1, codeSection));
941
979
  divider2.addEventListener('dblclick', toggleCollapse(divider2, framesSection));
942
980
 
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
+
943
1008
  // Initial render — if the program raises an error, start on the
944
1009
  // first exception step (like Python Tutor, which steps to the error),
945
1010
  // so the red line + error message are visible immediately.
@@ -961,12 +1026,19 @@ def render_trace(
961
1026
  steps: list[dict],
962
1027
  source_code: str | None = None,
963
1028
  height: int = 500,
1029
+ scroll: bool = True,
964
1030
  ) -> str:
965
1031
  """Render trace steps as interactive HTML inside an iframe.
966
1032
 
967
1033
  The entire visualization (HTML + CSS + JS) is wrapped in
968
1034
  ``<iframe srcdoc="...">`` which works in trusted JupyterLab notebooks.
969
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
+
970
1042
  Features:
971
1043
  - Fixed widget height with internal scrolling — no "dancing" cell
972
1044
  - Slider navigation (``<input type="range">``) — drag to scrub through steps
@@ -977,7 +1049,7 @@ def render_trace(
977
1049
  - Heap only shows objects reachable from the current call stack
978
1050
  - Keyboard navigation (← → arrows)
979
1051
  """
980
- inner = _generate_inner_html(steps, source_code or "", height)
1052
+ inner = _generate_inner_html(steps, source_code or "", height, scroll=scroll)
981
1053
  # Escape for srcdoc attribute
982
1054
  escaped = html.escape(inner, quote=True)
983
1055
  # Match cpp-tutor's approach exactly to prevent "dancing":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jupyter-python-tutor
3
- Version: 0.3.3
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.3"
7
+ version = "0.3.4"
8
8
  description = "Python Tutor-style step-by-step code visualization for JupyterLab 4.x and Notebook 7"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -77,3 +77,55 @@ c = Circle('red', 5)
77
77
  def test_height_option(self, ipython):
78
78
  result = ipython.run_cell_magic("pytutor", "--height 500", "x = 3")
79
79
  assert result is None or hasattr(result, 'children') or "jpt" in str(result)
80
+
81
+
82
+ class TestScrollModeMagic:
83
+ """Test the --no-scroll/--scroll cell flag and the %pytutor_config line magic."""
84
+
85
+ def _run_cell_capturing_scroll(self, ipython, line=""):
86
+ """Run %%pytutor with render_trace mocked; return the scroll kwarg passed."""
87
+ from unittest import mock
88
+ with mock.patch(
89
+ "jupyter_python_tutor.magics.render_trace",
90
+ return_value="<html></html>",
91
+ ) as mr:
92
+ ipython.run_cell_magic("pytutor", line, "x = 3")
93
+ assert mr.called, "render_trace was not called"
94
+ return mr.call_args.kwargs.get("scroll")
95
+
96
+ def test_config_magic_registered(self, ipython):
97
+ assert "pytutor_config" in ipython.magics_manager.magics["line"]
98
+
99
+ def test_default_scroll_true(self, ipython):
100
+ ipython.run_line_magic("pytutor_config", "--reset")
101
+ assert self._run_cell_capturing_scroll(ipython) is True
102
+
103
+ def test_cell_no_scroll_flag(self, ipython):
104
+ ipython.run_line_magic("pytutor_config", "--reset")
105
+ assert self._run_cell_capturing_scroll(ipython, line="--no-scroll") is False
106
+
107
+ def test_cell_scroll_overrides_config(self, ipython):
108
+ ipython.run_line_magic("pytutor_config", "--no-scroll")
109
+ # a cell --scroll wins over the notebook --no-scroll default
110
+ assert self._run_cell_capturing_scroll(ipython, line="--scroll") is True
111
+
112
+ def test_config_no_scroll(self, ipython):
113
+ ipython.run_line_magic("pytutor_config", "--no-scroll")
114
+ assert self._run_cell_capturing_scroll(ipython) is False
115
+
116
+ def test_config_scroll_restores(self, ipython):
117
+ ipython.run_line_magic("pytutor_config", "--no-scroll")
118
+ ipython.run_line_magic("pytutor_config", "--scroll")
119
+ assert self._run_cell_capturing_scroll(ipython) is True
120
+
121
+ def test_config_reset(self, ipython):
122
+ ipython.run_line_magic("pytutor_config", "--no-scroll")
123
+ ipython.run_line_magic("pytutor_config", "--reset")
124
+ assert self._run_cell_capturing_scroll(ipython) is True
125
+
126
+ def test_config_no_args_shows_setting(self, ipython):
127
+ ipython.run_line_magic("pytutor_config", "--no-scroll")
128
+ # running with no args displays the current setting (no exception)
129
+ ipython.run_line_magic("pytutor_config", "")
130
+ # and it is still no-scroll
131
+ assert self._run_cell_capturing_scroll(ipython) is False
@@ -416,3 +416,57 @@ class TestErrorDisplay:
416
416
  import json
417
417
  data = json.loads(jsline.strip()[len("var steps = "):-1])
418
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