jupyter-python-tutor 0.2.0__tar.gz → 0.3.0__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.2.0 → jupyter_python_tutor-0.3.0}/PKG-INFO +12 -11
  2. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/README.md +11 -10
  3. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/jupyter_python_tutor/__init__.py +1 -1
  4. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/jupyter_python_tutor/magics.py +24 -10
  5. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/jupyter_python_tutor/renderer.py +229 -34
  6. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/jupyter_python_tutor/tracer.py +68 -4
  7. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/jupyter_python_tutor.egg-info/PKG-INFO +12 -11
  8. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/pyproject.toml +1 -1
  9. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/tests/test_magics.py +11 -11
  10. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/tests/test_tracer.py +1 -1
  11. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/LICENSE +0 -0
  12. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/jupyter_python_tutor.egg-info/SOURCES.txt +0 -0
  13. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/jupyter_python_tutor.egg-info/dependency_links.txt +0 -0
  14. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/jupyter_python_tutor.egg-info/requires.txt +0 -0
  15. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/jupyter_python_tutor.egg-info/top_level.txt +0 -0
  16. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/setup.cfg +0 -0
  17. {jupyter_python_tutor-0.2.0 → jupyter_python_tutor-0.3.0}/tests/test_renderer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jupyter-python-tutor
3
- Version: 0.2.0
3
+ Version: 0.3.0
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
@@ -34,10 +34,10 @@ In a Jupyter cell:
34
34
  %load_ext jupyter_python_tutor
35
35
  ```
36
36
 
37
- Then use the `%%tutor` cell magic:
37
+ Then use the `%%pytutor` cell magic:
38
38
 
39
39
  ```python
40
- %%tutor
40
+ %%pytutor
41
41
  x = 3
42
42
  y = x + 4
43
43
  print(y)
@@ -66,9 +66,10 @@ from frame variables to heap objects (like [Python Tutor](https://pythontutor.co
66
66
  ## Options
67
67
 
68
68
  ```
69
- %%tutor --max-steps 1000 # Limit trace steps (default: 5000)
70
- %%tutor --raw # Output trace as JSON instead of HTML
71
- %%tutor --height 400 # Set minimum widget height (default: 500)
69
+ %%pytutor --max-steps 1000 # Limit trace steps (default: 5000)
70
+ %%pytutor --raw # Output trace as JSON instead of HTML
71
+ %%pytutor --height 400 # Set minimum widget height (default: 500)
72
+ %%pytutor --input 2026 # Pre-collected input for input() calls
72
73
  ```
73
74
 
74
75
  ## Examples
@@ -76,7 +77,7 @@ from frame variables to heap objects (like [Python Tutor](https://pythontutor.co
76
77
  ### Loops
77
78
 
78
79
  ```python
79
- %%tutor
80
+ %%pytutor
80
81
  total = 0
81
82
  for i in range(5):
82
83
  total += i
@@ -86,7 +87,7 @@ print(f"Sum: {total}")
86
87
  ### Functions with recursion
87
88
 
88
89
  ```python
89
- %%tutor
90
+ %%pytutor
90
91
  def factorial(n):
91
92
  if n <= 1:
92
93
  return 1
@@ -99,7 +100,7 @@ print(result)
99
100
  ### Classes and inheritance
100
101
 
101
102
  ```python
102
- %%tutor
103
+ %%pytutor
103
104
  class Animal:
104
105
  def __init__(self, name):
105
106
  self.name = name
@@ -116,7 +117,7 @@ print(d.speak())
116
117
 
117
118
  ## How It Works
118
119
 
119
- 1. **Tracing**: The `%%tutor` magic compiles the cell code and executes it under
120
+ 1. **Tracing**: The `%%pytutor` magic compiles the cell code and executes it under
120
121
  `sys.settrace()`. At each line/call/return event, a snapshot of all local and
121
122
  global variables is captured. AST analysis (`ast.parse`) extracts the set of
122
123
  names the traced code touches, so only code-relevant variables are shown
@@ -136,7 +137,7 @@ print(d.speak())
136
137
  4. **JupyterLab 4 compatibility**: JupyterLab 4's HTML sanitizer (DOMPurify)
137
138
  strips JavaScript and external URLs from untrusted notebooks. The iframe
138
139
  `srcdoc` approach bypasses this entirely in trusted notebooks. Run
139
- `jupyter trust your_notebook.ipynb` after executing cells with `%%tutor`
140
+ `jupyter trust your_notebook.ipynb` after executing cells with `%%pytutor`
140
141
  to ensure the visualization renders correctly.
141
142
 
142
143
  ## Limitations
@@ -14,10 +14,10 @@ In a Jupyter cell:
14
14
  %load_ext jupyter_python_tutor
15
15
  ```
16
16
 
17
- Then use the `%%tutor` cell magic:
17
+ Then use the `%%pytutor` cell magic:
18
18
 
19
19
  ```python
20
- %%tutor
20
+ %%pytutor
21
21
  x = 3
22
22
  y = x + 4
23
23
  print(y)
@@ -46,9 +46,10 @@ from frame variables to heap objects (like [Python Tutor](https://pythontutor.co
46
46
  ## Options
47
47
 
48
48
  ```
49
- %%tutor --max-steps 1000 # Limit trace steps (default: 5000)
50
- %%tutor --raw # Output trace as JSON instead of HTML
51
- %%tutor --height 400 # Set minimum widget height (default: 500)
49
+ %%pytutor --max-steps 1000 # Limit trace steps (default: 5000)
50
+ %%pytutor --raw # Output trace as JSON instead of HTML
51
+ %%pytutor --height 400 # Set minimum widget height (default: 500)
52
+ %%pytutor --input 2026 # Pre-collected input for input() calls
52
53
  ```
53
54
 
54
55
  ## Examples
@@ -56,7 +57,7 @@ from frame variables to heap objects (like [Python Tutor](https://pythontutor.co
56
57
  ### Loops
57
58
 
58
59
  ```python
59
- %%tutor
60
+ %%pytutor
60
61
  total = 0
61
62
  for i in range(5):
62
63
  total += i
@@ -66,7 +67,7 @@ print(f"Sum: {total}")
66
67
  ### Functions with recursion
67
68
 
68
69
  ```python
69
- %%tutor
70
+ %%pytutor
70
71
  def factorial(n):
71
72
  if n <= 1:
72
73
  return 1
@@ -79,7 +80,7 @@ print(result)
79
80
  ### Classes and inheritance
80
81
 
81
82
  ```python
82
- %%tutor
83
+ %%pytutor
83
84
  class Animal:
84
85
  def __init__(self, name):
85
86
  self.name = name
@@ -96,7 +97,7 @@ print(d.speak())
96
97
 
97
98
  ## How It Works
98
99
 
99
- 1. **Tracing**: The `%%tutor` magic compiles the cell code and executes it under
100
+ 1. **Tracing**: The `%%pytutor` magic compiles the cell code and executes it under
100
101
  `sys.settrace()`. At each line/call/return event, a snapshot of all local and
101
102
  global variables is captured. AST analysis (`ast.parse`) extracts the set of
102
103
  names the traced code touches, so only code-relevant variables are shown
@@ -116,7 +117,7 @@ print(d.speak())
116
117
  4. **JupyterLab 4 compatibility**: JupyterLab 4's HTML sanitizer (DOMPurify)
117
118
  strips JavaScript and external URLs from untrusted notebooks. The iframe
118
119
  `srcdoc` approach bypasses this entirely in trusted notebooks. Run
119
- `jupyter trust your_notebook.ipynb` after executing cells with `%%tutor`
120
+ `jupyter trust your_notebook.ipynb` after executing cells with `%%pytutor`
120
121
  to ensure the visualization renders correctly.
121
122
 
122
123
  ## Limitations
@@ -2,7 +2,7 @@
2
2
 
3
3
  Usage in a Jupyter cell:
4
4
 
5
- %%tutor
5
+ %%pytutor
6
6
  x = 3
7
7
  y = x + 4
8
8
  print(y)
@@ -2,15 +2,16 @@
2
2
 
3
3
  Usage in a Jupyter cell:
4
4
 
5
- %%tutor
5
+ %%pytutor
6
6
  x = 3
7
7
  y = x + 4
8
8
  print(y)
9
9
 
10
10
  Options:
11
- %%tutor --max-steps 1000 — limit the number of trace steps
12
- %%tutor --raw — output the trace as JSON instead of HTML
13
- %%tutor --height 400 — set max-height of the variables panel (px)
11
+ %%pytutor --max-steps 1000 — limit the number of trace steps
12
+ %%pytutor --raw — output the trace as JSON instead of HTML
13
+ %%pytutor --height 400 — set max-height of the variables panel (px)
14
+ %%pytutor --input 2026 — pre-collected input for input() calls
14
15
  """
15
16
  from __future__ import annotations
16
17
 
@@ -28,7 +29,7 @@ __all__ = ["TutorMagics", "load_ipython_extension", "unload_ipython_extension"]
28
29
 
29
30
  def _make_parser() -> argparse.ArgumentParser:
30
31
  parser = argparse.ArgumentParser(
31
- prog="%%tutor",
32
+ prog="%%pytutor",
32
33
  description="Trace Python code and display an interactive visualization.",
33
34
  )
34
35
  parser.add_argument(
@@ -43,15 +44,21 @@ def _make_parser() -> argparse.ArgumentParser:
43
44
  "--height", type=int, default=300,
44
45
  help="Max height of the variables panel in pixels (default: 300)",
45
46
  )
47
+ parser.add_argument(
48
+ "--input", action="append", default=[], metavar="VALUE",
49
+ help="Pre-collected input for input() calls. "
50
+ "Repeat --input for multiple values: --input 2026 --input John. "
51
+ "If omitted and code contains input(), a dialog prompts for values.",
52
+ )
46
53
  return parser
47
54
 
48
55
 
49
56
  @magics_class
50
57
  class TutorMagics(Magics):
51
- """Register the %%tutor cell magic."""
58
+ """Register the %%pytutor cell magic."""
52
59
 
53
- @cell_magic("tutor")
54
- def tutor(self, line: str, cell: str):
60
+ @cell_magic("pytutor")
61
+ def pytutor(self, line: str, cell: str):
55
62
  """Trace the cell code and display an interactive visualization."""
56
63
  parser = _make_parser()
57
64
  try:
@@ -67,10 +74,17 @@ class TutorMagics(Magics):
67
74
  # debugger operates.
68
75
  user_ns = self.shell.user_ns
69
76
 
77
+ # Pre-collected inputs for input() calls.
78
+ # If --input is provided, use those values.
79
+ # If not provided but code contains input(), use empty list
80
+ # (input() returns empty string instead of blocking).
81
+ inputs = args.input if args.input else None
82
+
70
83
  steps = trace_code(
71
84
  cell,
72
85
  max_steps=args.max_steps,
73
86
  user_globals=user_ns,
87
+ inputs=inputs,
74
88
  )
75
89
 
76
90
  if args.raw:
@@ -105,5 +119,5 @@ def load_ipython_extension(ipython):
105
119
 
106
120
  def unload_ipython_extension(ipython):
107
121
  """Unload the extension."""
108
- if "tutor" in ipython.magics_manager.magics["cell"]:
109
- ipython.magics_manager.magics["cell"].pop("tutor", None)
122
+ if "pytutor" in ipython.magics_manager.magics["cell"]:
123
+ ipython.magics_manager.magics["cell"].pop("pytutor", None)
@@ -68,13 +68,16 @@ def _generate_inner_html(steps: list[dict], source_code: str, height: int = 500)
68
68
 
69
69
  # Prepare step data as JSON — strip any non-serializable fields
70
70
  clean_steps = []
71
- for step in steps:
71
+ for i, step in enumerate(steps):
72
72
  clean_step = {
73
73
  "line": step.get("line", 0),
74
74
  "stdout": step.get("stdout", ""),
75
75
  "call_stack": [],
76
76
  "return_value": step.get("return_value"),
77
77
  "event": step.get("event", ""),
78
+ "input_prompt": step.get("input_prompt", ""),
79
+ "input_index": step.get("input_index", -1),
80
+ "prev_line": steps[i - 1].get("line", 0) if i > 0 else 0,
78
81
  }
79
82
  for frame_info in step.get("call_stack", []):
80
83
  clean_frame = {
@@ -161,19 +164,19 @@ body {{
161
164
  /* Print output */
162
165
  .jpt-output-section {{
163
166
  border-bottom: 1px solid #d4d4d4;
164
- background: #fafafa;
167
+ background: #dcfce7;
165
168
  flex-shrink: 0;
166
169
  }}
167
170
  .jpt-output-label {{
168
171
  font-size: 10px;
169
- color: #6b7280;
172
+ color: #166534;
170
173
  padding: 3px 10px 1px;
171
174
  font-weight: 600;
172
175
  text-transform: uppercase;
173
176
  }}
174
177
  .jpt-stdout {{
175
- background: #1e1e1e;
176
- color: #d4d4d4;
178
+ background: #f0fdf4;
179
+ color: #166534;
177
180
  font-family: 'DejaVu Sans Mono', monospace;
178
181
  font-size: 11px;
179
182
  padding: 4px 10px;
@@ -182,7 +185,7 @@ body {{
182
185
  white-space: pre-wrap;
183
186
  max-height: 80px;
184
187
  overflow-y: auto;
185
- min-height: 16px;
188
+ min-height: 20px;
186
189
  }}
187
190
  /* Main area */
188
191
  .jpt-main {{
@@ -242,20 +245,31 @@ body {{
242
245
  min-height: 18px;
243
246
  border-left: 3px solid transparent;
244
247
  }}
248
+ .jpt-code-line.jpt-executed {{
249
+ background: #dcfce7;
250
+ border-left: 3px solid #22c55e;
251
+ }}
245
252
  .jpt-code-line.jpt-current {{
246
- background: #fef9c3;
247
- border-left: 3px solid #facc15;
253
+ background: #fce7f3;
254
+ border-left: 3px solid #e10c65;
248
255
  }}
249
256
  .jpt-arrow {{
250
257
  width: 16px; flex: 0 0 16px; text-align: center;
251
258
  color: transparent; font-weight: bold; user-select: none;
252
259
  }}
253
- .jpt-current .jpt-arrow {{ color: #dc2626; }}
260
+ .jpt-current .jpt-arrow {{ color: #e10c65; }}
254
261
  .jpt-lineno {{
255
262
  width: 28px; flex: 0 0 28px; text-align: right;
256
263
  padding-right: 8px; color: #6b7280; font-size: 11px; user-select: none;
257
264
  }}
258
265
  .jpt-code-text {{ flex: 1; padding-left: 4px; }}
266
+ /* 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... */
259
273
  /* Frames */
260
274
  .jpt-frame {{
261
275
  border: 1px solid #bae6fd; border-radius: 4px; background: #f0f9ff;
@@ -288,8 +302,7 @@ body {{
288
302
  cursor: pointer; max-width: 100%; overflow: hidden; text-overflow: ellipsis;
289
303
  white-space: nowrap; position: relative;
290
304
  }}
291
- .jpt-pointer-arrow {{ color: #4f46e5; font-weight: bold; margin-right: 3px; }}
292
- .jpt-pointer-label {{ color: #3730a3; overflow: hidden; text-overflow: ellipsis; }}
305
+ .jpt-pointer-arrow {{ color: #4f46e5; font-weight: bold; }}
293
306
  .jpt-frame.jpt-global {{
294
307
  border-color: #fde047; background: #fefce8;
295
308
  }}
@@ -336,10 +349,50 @@ body {{
336
349
  }}
337
350
  .jpt-arrow-path {{ stroke: #6b7280; stroke-width: 1.5; fill: none; marker-end: url(#arrowhead); }}
338
351
  .jpt-arrow-path-highlight {{ stroke: #3b82f6; stroke-width: 2; }}
352
+ /* Input dialog overlay */
353
+ .jpt-input-overlay {{
354
+ position: absolute; top: 0; left: 0; width: 100%; height: 100%;
355
+ background: rgba(0,0,0,0.3); z-index: 20;
356
+ display: flex; align-items: center; justify-content: center;
357
+ }}
358
+ .jpt-input-dialog {{
359
+ background: #fff; border-radius: 8px; padding: 16px 20px;
360
+ box-shadow: 0 4px 20px rgba(0,0,0,0.15); max-width: 420px; width: 90%;
361
+ font-size: 13px;
362
+ }}
363
+ .jpt-input-dialog-label {{
364
+ font-weight: 600; color: #1f2937; margin-bottom: 8px;
365
+ font-family: 'DejaVu Sans Mono', monospace; font-size: 12px;
366
+ }}
367
+ .jpt-input-dialog-input {{
368
+ width: 100%; padding: 8px 10px; border: 1px solid #d4d4d4;
369
+ border-radius: 4px; font-size: 13px; font-family: 'DejaVu Sans Mono', monospace;
370
+ box-sizing: border-box;
371
+ }}
372
+ .jpt-input-dialog-input:focus {{ outline: none; border-color: #3b82f6; }}
373
+ .jpt-input-dialog-btn {{
374
+ margin-top: 10px; padding: 6px 16px; background: #3b82f6; color: #fff;
375
+ border: none; border-radius: 4px; font-size: 13px; cursor: pointer;
376
+ font-weight: 500;
377
+ }}
378
+ .jpt-input-dialog-btn:hover {{ background: #2563eb; }}
379
+ .jpt-input-badge {{
380
+ display: inline-block; background: #fef3c7; color: #92400e;
381
+ padding: 1px 6px; border-radius: 3px; font-size: 11px;
382
+ font-family: 'DejaVu Sans Mono', monospace; margin-top: 2px;
383
+ }}
339
384
  </style>
340
385
  </head>
341
386
  <body>
342
387
  <div class="jpt-widget" id="jptWidget">
388
+ <!-- Input dialog overlay (hidden by default) -->
389
+ <div class="jpt-input-overlay" id="inputOverlay" style="display:none;">
390
+ <div class="jpt-input-dialog">
391
+ <div class="jpt-input-dialog-label" id="inputDialogLabel">Enter input:</div>
392
+ <input type="text" class="jpt-input-dialog-input" id="inputDialogInput" autofocus />
393
+ <button class="jpt-input-dialog-btn" id="inputDialogBtn">Submit</button>
394
+ </div>
395
+ </div>
343
396
  <div class="jpt-navbar">
344
397
  <button id="btnFirst" title="First">⏮</button>
345
398
  <button id="btnPrev" title="Previous">◀</button>
@@ -351,6 +404,7 @@ body {{
351
404
  <div class="jpt-output-section">
352
405
  <div class="jpt-output-label">Program Output</div>
353
406
  <div class="jpt-stdout" id="stdout"></div>
407
+ <div class="jpt-input-badge" id="inputBadge" style="display:none;"></div>
354
408
  </div>
355
409
  <div class="jpt-main">
356
410
  <div class="jpt-code-section" id="codeSection">
@@ -404,18 +458,125 @@ body {{
404
458
  return ref.name || 'func';
405
459
  }}
406
460
 
407
- function renderCode(lineNo) {{
461
+ // ─── Python syntax highlighting ──────────────────────────────────
462
+ var PY_KEYWORDS = ['def','return','if','elif','else','for','while','break','continue',
463
+ 'import','from','as','class','try','except','finally','with','lambda','yield',
464
+ 'global','nonlocal','pass','del','raise','assert','in','not','and','or','is','None',
465
+ 'True','False','async','await'];
466
+ var PY_BUILTINS = ['print','len','range','input','int','float','str','list','dict',
467
+ 'set','tuple','bool','type','isinstance','enumerate','zip','map','filter','sorted',
468
+ 'sum','min','max','abs','round','open','format','repr','chr','ord','hex','oct','bin',
469
+ 'super','property','staticmethod','classmethod','iter','next','reversed','any','all',
470
+ 'getattr','setattr','hasattr','delattr','id','hash','dir','vars','globals','locals'];
471
+ var kwSet = {{}}; PY_KEYWORDS.forEach(function(k){{ kwSet[k] = 1; }});
472
+ var builtinSet = {{}}; PY_BUILTINS.forEach(function(b){{ builtinSet[b] = 1; }});
473
+
474
+ function highlightPython(line) {{
475
+ // Tokenize: comments, strings, numbers, identifiers, operators
476
+ var result = '';
477
+ var i = 0;
478
+ while (i < line.length) {{
479
+ var ch = line[i];
480
+ // Comment
481
+ if (ch === '#') {{
482
+ result += '<span class="jpt-tok-com">' + escapeHtml(line.substring(i)) + '</span>';
483
+ break;
484
+ }}
485
+ // String (single, double, triple)
486
+ if (ch === '"' || ch === "'") {{
487
+ var quote = ch;
488
+ // Check triple-quote
489
+ if (line.substring(i, i+3) === quote+quote+quote) {{
490
+ var end = line.indexOf(quote+quote+quote, i+3);
491
+ 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>';
494
+ i = end + 3;
495
+ continue;
496
+ }}
497
+ // Single/double quote — find matching close (handle escapes)
498
+ var j = i + 1;
499
+ while (j < line.length) {{
500
+ if (line[j] === '\\\\') {{ j += 2; continue; }}
501
+ if (line[j] === quote) {{ j++; break; }}
502
+ j++;
503
+ }}
504
+ var s = line.substring(i, j);
505
+ result += '<span class="jpt-tok-str">' + escapeHtml(s) + '</span>';
506
+ i = j;
507
+ continue;
508
+ }}
509
+ // f-string prefix (f, r, b, fr, rf, etc. before quote)
510
+ if (/[frbFRB]/.test(ch) && i+1 < line.length && (line[i+1] === '"' || line[i+1] === "'")) {{
511
+ var j = i;
512
+ while (j < line.length && /[frbFRB]/.test(line[j])) j++;
513
+ if (j < line.length && (line[j] === '"' || line[j] === "'")) {{
514
+ var quote = line[j];
515
+ var k = j + 1;
516
+ while (k < line.length) {{
517
+ if (line[k] === '\\\\') {{ k += 2; continue; }}
518
+ if (line[k] === quote) {{ k++; break; }}
519
+ k++;
520
+ }}
521
+ var s = line.substring(i, k);
522
+ result += '<span class="jpt-tok-str">' + escapeHtml(s) + '</span>';
523
+ i = k;
524
+ continue;
525
+ }}
526
+ }}
527
+ // Number
528
+ if (/[0-9]/.test(ch) || (ch === '.' && i+1 < line.length && /[0-9]/.test(line[i+1]))) {{
529
+ 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
532
+ if ((line[j] === '+' || line[j] === '-') && !/[eE]/.test(line[j-1])) break;
533
+ j++;
534
+ }}
535
+ result += '<span class="jpt-tok-num">' + escapeHtml(line.substring(i, j)) + '</span>';
536
+ i = j;
537
+ continue;
538
+ }}
539
+ // Identifier / keyword / builtin
540
+ if (/[a-zA-Z_]/.test(ch)) {{
541
+ var j = i;
542
+ while (j < line.length && /[a-zA-Z0-9_]/.test(line[j])) j++;
543
+ var word = line.substring(i, j);
544
+ // Check if followed by ( → function call
545
+ var afterWord = line.substring(j).trim();
546
+ if (kwSet[word]) {{
547
+ result += '<span class="jpt-tok-kw">' + escapeHtml(word) + '</span>';
548
+ }} else if (builtinSet[word]) {{
549
+ result += '<span class="jpt-tok-builtin">' + escapeHtml(word) + '</span>';
550
+ }} else if (afterWord.charAt(0) === '(') {{
551
+ result += '<span class="jpt-tok-fn">' + escapeHtml(word) + '</span>';
552
+ }} else {{
553
+ result += escapeHtml(word);
554
+ }}
555
+ i = j;
556
+ continue;
557
+ }}
558
+ // Default: escape and output
559
+ result += escapeHtml(ch);
560
+ i++;
561
+ }}
562
+ return result;
563
+ }}
564
+
565
+ function renderCode(lineNo, prevLineNo) {{
408
566
  if (!lineNo || lineNo < 1) lineNo = 1;
567
+ if (!prevLineNo) prevLineNo = 0;
409
568
  var html = '';
410
569
  for (var i = 0; i < sourceLines.length; i++) {{
411
570
  var ln = i + 1;
412
571
  var line = sourceLines[i] || '';
413
- var escaped = line.trim() ? escapeHtml(line) : '&nbsp;';
414
- var cur = (ln === lineNo) ? ' jpt-current' : '';
415
- html += '<div class="jpt-code-line' + cur + '">' +
572
+ var highlighted = line.trim() ? highlightPython(line) : '&nbsp;';
573
+ var cls = '';
574
+ if (ln === lineNo) cls = ' jpt-current';
575
+ else if (ln === prevLineNo) cls = ' jpt-executed';
576
+ html += '<div class="jpt-code-line' + cls + '">' +
416
577
  '<span class="jpt-arrow">▶</span>' +
417
578
  '<span class="jpt-lineno">' + ln + '</span>' +
418
- '<span class="jpt-code-text">' + escaped + '</span>' +
579
+ '<span class="jpt-code-text">' + highlighted + '</span>' +
419
580
  '</div>';
420
581
  }}
421
582
  codeDisplay.innerHTML = html;
@@ -450,24 +611,10 @@ body {{
450
611
  for (var ri = 0; ri < refs.length; ri++) {{
451
612
  var ref = refs[ri];
452
613
  var heapId = escapeHtml(ref.heap_id || '');
453
- var typeLabel;
454
- if (ref.is_function) {{
455
- typeLabel = 'func ' + escapeHtml(funcDisplayName(ref));
456
- }} else if (ref.is_class) {{
457
- typeLabel = 'class ' + escapeHtml(ref.name);
458
- }} else if (ref.is_module) {{
459
- typeLabel = 'module ' + escapeHtml(ref.name);
460
- }} else if (ref.is_instance) {{
461
- typeLabel = escapeHtml(ref.type || 'instance');
462
- }} else {{
463
- typeLabel = escapeHtml(ref.type || 'object');
464
- }}
465
614
  html += '<div class="jpt-var-row">' +
466
615
  '<span class="jpt-var-name">' + escapeHtml(ref.name) + '</span>' +
467
616
  '<span class="jpt-var-eq">=</span>' +
468
- '<span class="jpt-var-pointer" data-heap-id="' + heapId + '" title="' + escapeHtml(ref.repr) + '">' +
469
- '<span class="jpt-pointer-arrow">→</span>' +
470
- '<span class="jpt-pointer-label">' + typeLabel + '</span></span></div>';
617
+ '<span class="jpt-var-pointer" data-heap-id="' + heapId + '" title="' + escapeHtml(ref.repr) + '"><span class="jpt-pointer-arrow">→</span></span></div>';
471
618
  }}
472
619
  if (keys.length === 0 && refs.length === 0) {{
473
620
  html += '<div class="jpt-var-row" style="color:#9ca3af;font-style:italic;">(no variables)</div>';
@@ -611,17 +758,65 @@ body {{
611
758
  document.getElementById('jptWidget').style.height = totalH + 'px';
612
759
  }}
613
760
 
761
+ // ─── 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
765
+ var inputOverlay = document.getElementById('inputOverlay');
766
+ var inputDialogLabel = document.getElementById('inputDialogLabel');
767
+ var inputDialogInput = document.getElementById('inputDialogInput');
768
+ var inputDialogBtn = document.getElementById('inputDialogBtn');
769
+
770
+ // Pre-populate from step data (inputs already provided during tracing)
771
+ for (var si = 0; si < nSteps; si++) {{
772
+ var ss = steps[si];
773
+ 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
+ if (!(ss.input_index in inputsCollected)) {{
777
+ var prompt = ss.input_prompt || '';
778
+ var out = ss.stdout || '';
779
+ // stdout contains prompt + value + newline, find the value
780
+ if (out.includes(prompt)) {{
781
+ var afterPrompt = out.substring(out.indexOf(prompt) + prompt.length);
782
+ // The value is everything up to the first newline
783
+ var nl = afterPrompt.indexOf(String.fromCharCode(10));
784
+ if (nl >= 0) {{
785
+ inputsCollected[ss.input_index] = afterPrompt.substring(0, nl);
786
+ }} else {{
787
+ inputsCollected[ss.input_index] = afterPrompt;
788
+ }}
789
+ }}
790
+ }}
791
+ }}
792
+ }}
793
+
794
+ // Show input badge in output section if this step has input
795
+ function updateInputBadge(step) {{
796
+ var badge = document.getElementById('inputBadge');
797
+ if (!badge) return;
798
+ if (step.input_index >= 0 && step.input_index !== undefined && step.input_prompt) {{
799
+ var val = inputsCollected[step.input_index] || '';
800
+ badge.textContent = 'input() → ' + val;
801
+ badge.style.display = 'inline-block';
802
+ }} else {{
803
+ badge.style.display = 'none';
804
+ }}
805
+ }}
806
+
614
807
  function renderStep(idx) {{
615
808
  if (idx < 0) idx = 0;
616
809
  if (idx >= nSteps) idx = nSteps - 1;
617
810
  curStep = idx;
618
811
  var step = steps[idx];
619
- renderCode(step.line || 0);
812
+ var prevLine = (idx > 0) ? steps[idx - 1].line : 0;
813
+ renderCode(step.line || 0, prevLine);
620
814
  renderFrames(step);
621
815
  renderHeap(step);
622
816
  stdout.textContent = step.stdout || '';
623
- if (!step.stdout) stdout.style.color = '#6b7280', stdout.style.fontStyle = 'italic', stdout.textContent = '(no output yet)';
624
- else stdout.style.color = '#d4d4d4', stdout.style.fontStyle = 'normal';
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';
819
+ updateInputBadge(step);
625
820
  stepCounter.textContent = 'Step ' + (idx + 1) + ' of ' + nSteps;
626
821
  slider.value = idx;
627
822
  btnFirst.disabled = (idx === 0);
@@ -401,6 +401,7 @@ def _trace_code_internal(
401
401
  code_str: str,
402
402
  user_globals: dict[str, typing.Any] | None = None,
403
403
  max_steps: int = 5000,
404
+ inputs: list[str] | None = None,
404
405
  ) -> list[dict]:
405
406
  """Execute *code_str* under a trace and return the list of steps.
406
407
 
@@ -411,12 +412,17 @@ def _trace_code_internal(
411
412
  event: str — "call", "line", or "return"
412
413
  func: str — current function name
413
414
  stdout: str — output printed up to THIS step (incremental)
415
+ input_prompt: str — prompt text if this step calls input(), else ""
416
+ input_index: int — index into inputs list for this input() call, or -1
417
+
418
+ If *inputs* is provided, input() calls during tracing return successive
419
+ values from the list instead of reading stdin.
414
420
  """
415
421
  steps: list[dict] = []
416
422
  step_count = 0
417
423
  # Execute directly in the user's namespace so that classes, functions,
418
424
  # and imports from previous cells are available. This is how a normal
419
- # Jupyter cell works — the %%tutor magic should behave the same way,
425
+ # Jupyter cell works — the %%pytutor magic should behave the same way,
420
426
  # just with tracing enabled. Variables defined by the traced code WILL
421
427
  # persist in the kernel (like a normal cell), but we must not overwrite
422
428
  # __name__ or __builtins__.
@@ -474,18 +480,59 @@ def _trace_code_internal(
474
480
 
475
481
  tracking_stream = _StepTrackingStream(captured)
476
482
 
483
+ # ─── input() override ─────────────────────────────────────────────
484
+ # During tracing, input() should return pre-collected values from the
485
+ # *inputs* list instead of prompting stdin. The prompt text is still
486
+ # written to stdout (matching Python's built-in input() behavior).
487
+ # We also record which step triggered input() so the renderer can show
488
+ # an input UI at the right point.
489
+ import builtins
490
+ _real_input = builtins.input
491
+ _input_queue = list(inputs) if inputs else []
492
+ _input_idx = 0 # how many inputs have been consumed
493
+ # Track the input prompt for the CURRENT step being traced
494
+ _current_input_prompt: str | None = None
495
+ _current_input_index: int = -1
496
+
497
+ def _traced_input(prompt: str = "") -> str:
498
+ nonlocal _input_idx, _current_input_prompt, _current_input_index
499
+ # Write prompt to stdout (like real input() does)
500
+ if prompt:
501
+ tracking_stream.write(prompt)
502
+ _current_input_prompt = prompt
503
+ _current_input_index = _input_idx
504
+ if _input_idx < len(_input_queue):
505
+ val = _input_queue[_input_idx]
506
+ else:
507
+ val = "" # empty string if no more inputs
508
+ _input_idx += 1
509
+ # Echo the input value to stdout (like a terminal would)
510
+ tracking_stream.write(val + "\n")
511
+ return val
512
+
477
513
  # Shared ID map: maps id(obj) → "id1", "id2", etc.
478
514
  # Same object always gets the same label across all steps.
479
515
  id_map: dict[int, str] = {}
480
516
 
481
517
  def _trace(frame, event, arg):
482
- nonlocal step_count
518
+ nonlocal step_count, _current_input_prompt, _current_input_index
483
519
  if frame.f_code.co_filename != filename:
484
520
  return _trace # skip frames from other files
485
521
  if step_count >= max_steps:
486
522
  raise _StepLimitExceeded() # stop execution, not just tracing
487
523
  step_count += 1
488
524
 
525
+ # Capture input info from the PREVIOUS step's execution.
526
+ # input() runs during line execution (between trace callbacks),
527
+ # so _current_input_prompt was set by the line that just ran.
528
+ # We record it on THIS step (the next line) so the renderer knows
529
+ # input happened during the previous step.
530
+ step_input_prompt = _current_input_prompt
531
+ step_input_index = _current_input_index
532
+ # Reset for the next execution phase
533
+ _current_input_prompt = None
534
+ _current_input_index = -1
535
+
489
536
  lineno = frame.f_lineno
490
537
  source = source_lines[lineno - 1].strip() if 1 <= lineno <= len(source_lines) else ""
491
538
  call_stack = _snapshot_call_stack(frame, id_map, code_names)
@@ -499,6 +546,8 @@ def _trace_code_internal(
499
546
  "event": "line",
500
547
  "func": frame.f_code.co_name,
501
548
  "stdout": current_stdout,
549
+ "input_prompt": step_input_prompt or "",
550
+ "input_index": step_input_index,
502
551
  })
503
552
  elif event == "call":
504
553
  steps.append({
@@ -508,6 +557,8 @@ def _trace_code_internal(
508
557
  "event": "call",
509
558
  "func": frame.f_code.co_name,
510
559
  "stdout": current_stdout,
560
+ "input_prompt": step_input_prompt or "",
561
+ "input_index": step_input_index,
511
562
  })
512
563
  elif event == "return":
513
564
  ret_repr = _safe_repr(arg) if arg is not None else "None"
@@ -519,6 +570,8 @@ def _trace_code_internal(
519
570
  "func": frame.f_code.co_name,
520
571
  "return_value": ret_repr,
521
572
  "stdout": current_stdout,
573
+ "input_prompt": step_input_prompt or "",
574
+ "input_index": step_input_index,
522
575
  })
523
576
  elif event == "exception":
524
577
  exc_type, exc_val, _ = arg
@@ -533,6 +586,8 @@ def _trace_code_internal(
533
586
  "func": frame.f_code.co_name,
534
587
  "error": f"{exc_type.__name__}: {exc_val}",
535
588
  "stdout": current_stdout,
589
+ "input_prompt": step_input_prompt or "",
590
+ "input_index": step_input_index,
536
591
  })
537
592
 
538
593
  return _trace
@@ -542,6 +597,7 @@ def _trace_code_internal(
542
597
 
543
598
  try:
544
599
  sys.stdout = tracking_stream
600
+ builtins.input = _traced_input # override input() during tracing
545
601
  sys.settrace(_trace)
546
602
  exec(compiled, g)
547
603
  except _StepLimitExceeded:
@@ -555,10 +611,13 @@ def _trace_code_internal(
555
611
  "func": "<module>",
556
612
  "error": f"{type(e).__name__}: {e}",
557
613
  "stdout": captured.getvalue(),
614
+ "input_prompt": "",
615
+ "input_index": -1,
558
616
  })
559
617
  finally:
560
618
  sys.settrace(old_trace)
561
619
  sys.stdout = old_stdout
620
+ builtins.input = _real_input # restore real input()
562
621
 
563
622
  return steps
564
623
 
@@ -567,6 +626,7 @@ def trace_code(
567
626
  code_str: str,
568
627
  max_steps: int = 5000,
569
628
  user_globals: dict[str, typing.Any] | None = None,
629
+ inputs: list[str] | None = None,
570
630
  ) -> list[dict]:
571
631
  """Trace Python code and return step-by-step execution state.
572
632
 
@@ -581,11 +641,15 @@ def trace_code(
581
641
  variables are available to the traced code). The code is executed
582
642
  directly in this namespace — variables defined by the traced code
583
643
  persist in the kernel (like a normal Jupyter cell).
644
+ inputs : list[str], optional
645
+ Pre-collected user inputs for input() calls. Each input() call
646
+ during tracing returns the next value from this list, instead of
647
+ reading from stdin.
584
648
 
585
649
  Returns
586
650
  -------
587
651
  list[dict]
588
652
  Each dict has keys: line, source, call_stack, event, func.
589
- May also include: return_value, error, stdout.
653
+ May also include: return_value, error, stdout, input_prompt, input_index.
590
654
  """
591
- return _trace_code_internal(code_str, user_globals, max_steps)
655
+ return _trace_code_internal(code_str, user_globals, max_steps, inputs)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jupyter-python-tutor
3
- Version: 0.2.0
3
+ Version: 0.3.0
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
@@ -34,10 +34,10 @@ In a Jupyter cell:
34
34
  %load_ext jupyter_python_tutor
35
35
  ```
36
36
 
37
- Then use the `%%tutor` cell magic:
37
+ Then use the `%%pytutor` cell magic:
38
38
 
39
39
  ```python
40
- %%tutor
40
+ %%pytutor
41
41
  x = 3
42
42
  y = x + 4
43
43
  print(y)
@@ -66,9 +66,10 @@ from frame variables to heap objects (like [Python Tutor](https://pythontutor.co
66
66
  ## Options
67
67
 
68
68
  ```
69
- %%tutor --max-steps 1000 # Limit trace steps (default: 5000)
70
- %%tutor --raw # Output trace as JSON instead of HTML
71
- %%tutor --height 400 # Set minimum widget height (default: 500)
69
+ %%pytutor --max-steps 1000 # Limit trace steps (default: 5000)
70
+ %%pytutor --raw # Output trace as JSON instead of HTML
71
+ %%pytutor --height 400 # Set minimum widget height (default: 500)
72
+ %%pytutor --input 2026 # Pre-collected input for input() calls
72
73
  ```
73
74
 
74
75
  ## Examples
@@ -76,7 +77,7 @@ from frame variables to heap objects (like [Python Tutor](https://pythontutor.co
76
77
  ### Loops
77
78
 
78
79
  ```python
79
- %%tutor
80
+ %%pytutor
80
81
  total = 0
81
82
  for i in range(5):
82
83
  total += i
@@ -86,7 +87,7 @@ print(f"Sum: {total}")
86
87
  ### Functions with recursion
87
88
 
88
89
  ```python
89
- %%tutor
90
+ %%pytutor
90
91
  def factorial(n):
91
92
  if n <= 1:
92
93
  return 1
@@ -99,7 +100,7 @@ print(result)
99
100
  ### Classes and inheritance
100
101
 
101
102
  ```python
102
- %%tutor
103
+ %%pytutor
103
104
  class Animal:
104
105
  def __init__(self, name):
105
106
  self.name = name
@@ -116,7 +117,7 @@ print(d.speak())
116
117
 
117
118
  ## How It Works
118
119
 
119
- 1. **Tracing**: The `%%tutor` magic compiles the cell code and executes it under
120
+ 1. **Tracing**: The `%%pytutor` magic compiles the cell code and executes it under
120
121
  `sys.settrace()`. At each line/call/return event, a snapshot of all local and
121
122
  global variables is captured. AST analysis (`ast.parse`) extracts the set of
122
123
  names the traced code touches, so only code-relevant variables are shown
@@ -136,7 +137,7 @@ print(d.speak())
136
137
  4. **JupyterLab 4 compatibility**: JupyterLab 4's HTML sanitizer (DOMPurify)
137
138
  strips JavaScript and external URLs from untrusted notebooks. The iframe
138
139
  `srcdoc` approach bypasses this entirely in trusted notebooks. Run
139
- `jupyter trust your_notebook.ipynb` after executing cells with `%%tutor`
140
+ `jupyter trust your_notebook.ipynb` after executing cells with `%%pytutor`
140
141
  to ensure the visualization renders correctly.
141
142
 
142
143
  ## Limitations
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "jupyter-python-tutor"
7
- version = "0.2.0"
7
+ version = "0.3.0"
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"
@@ -13,28 +13,28 @@ def ipython():
13
13
 
14
14
 
15
15
  class TestTutorMagic:
16
- """Test the %%tutor cell magic."""
16
+ """Test the %%pytutor cell magic."""
17
17
 
18
18
  def test_tutor_magic_registered(self, ipython):
19
- assert "tutor" in ipython.magics_manager.magics["cell"]
19
+ assert "pytutor" in ipython.magics_manager.magics["cell"]
20
20
 
21
21
  def test_simple_code(self, ipython):
22
- result = ipython.run_cell_magic("tutor", "", "x = 3\ny = 4")
22
+ result = ipython.run_cell_magic("pytutor", "", "x = 3\ny = 4")
23
23
  # Should return None (display is called) or a widget/HTML
24
24
  assert result is None or hasattr(result, 'children') or "jpt" in str(result)
25
25
 
26
26
  def test_print_output(self, ipython):
27
- result = ipython.run_cell_magic("tutor", "", "print('hello world')")
27
+ result = ipython.run_cell_magic("pytutor", "", "print('hello world')")
28
28
  assert result is None or hasattr(result, 'children') or "jpt" in str(result)
29
29
 
30
30
  def test_raw_option(self, ipython):
31
- result = ipython.run_cell_magic("tutor", "--raw", "x = 3")
31
+ result = ipython.run_cell_magic("pytutor", "--raw", "x = 3")
32
32
  # --raw returns JSON
33
33
  assert result is not None
34
34
  assert hasattr(result, 'data') # IPython.display.JSON
35
35
 
36
36
  def test_max_steps_option(self, ipython):
37
- result = ipython.run_cell_magic("tutor", "--max-steps 10", "x = 3")
37
+ result = ipython.run_cell_magic("pytutor", "--max-steps 10", "x = 3")
38
38
  assert result is None or hasattr(result, 'children') or "jpt" in str(result)
39
39
 
40
40
  def test_recursion(self, ipython):
@@ -46,7 +46,7 @@ def factorial(n):
46
46
 
47
47
  result = factorial(3)
48
48
  """
49
- result = ipython.run_cell_magic("tutor", "", code)
49
+ result = ipython.run_cell_magic("pytutor", "", code)
50
50
  assert result is None or hasattr(result, 'children') or "jpt" in str(result)
51
51
 
52
52
  def test_inheritance(self, ipython):
@@ -62,18 +62,18 @@ class Circle(Shape):
62
62
 
63
63
  c = Circle('red', 5)
64
64
  """
65
- result = ipython.run_cell_magic("tutor", "", code)
65
+ result = ipython.run_cell_magic("pytutor", "", code)
66
66
  assert result is None or hasattr(result, 'children') or "jpt" in str(result)
67
67
 
68
68
  def test_syntax_error_handled(self, ipython):
69
- result = ipython.run_cell_magic("tutor", "", "x = )")
69
+ result = ipython.run_cell_magic("pytutor", "", "x = )")
70
70
  assert result is None or hasattr(result, 'children') or "jpt" in str(result)
71
71
 
72
72
  def test_imports(self, ipython):
73
73
  code = "import math\nresult = math.pi"
74
- result = ipython.run_cell_magic("tutor", "", code)
74
+ result = ipython.run_cell_magic("pytutor", "", code)
75
75
  assert result is None or hasattr(result, 'children') or "jpt" in str(result)
76
76
 
77
77
  def test_height_option(self, ipython):
78
- result = ipython.run_cell_magic("tutor", "--height 500", "x = 3")
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)
@@ -146,7 +146,7 @@ print(c.describe())
146
146
  """Traced code should execute in the user's namespace so variables persist.
147
147
 
148
148
  This is like a normal Jupyter cell — classes/functions defined in one
149
- cell are available in the next. The %%tutor magic should work the same way.
149
+ cell are available in the next. The %%pytutor magic should work the same way.
150
150
  """
151
151
  user_globals = {"existing": 42}
152
152
  # Trace code that defines a new variable