jupyter-python-tutor 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,591 @@
1
+ """Code tracer using sys.settrace() to capture step-by-step execution state.
2
+
3
+ Produces a list of TraceStep dicts, each containing the line number, the
4
+ source line text, and the state of all local/global variables at that point.
5
+
6
+ Key design: the tracer executes code directly in the kernel's globals so
7
+ that imported packages and variables from previous cells are available.
8
+ Variables defined by the traced code persist in the kernel (like a normal
9
+ Jupyter cell). Each object that is too complex to show inline gets a
10
+ stable ``heap_id`` label (``id1``, ``id2``, ...) so the renderer can draw
11
+ arrow boxes from frame variables to heap objects.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import re as _re
17
+ import sys
18
+ import types
19
+ import typing
20
+ from copy import deepcopy
21
+
22
+ __all__ = ["trace_code"]
23
+
24
+
25
+ # Types we consider "simple" — shown inline in frames.
26
+ _SIMPLE = (int, float, str, bool, type(None))
27
+ _CONTAINER = (list, tuple, set, frozenset, dict)
28
+
29
+
30
+ def _is_simple(obj: typing.Any) -> bool:
31
+ """Return True if obj is a primitive we can show inline."""
32
+ if isinstance(obj, _SIMPLE):
33
+ return True
34
+ # Small containers of primitives can also be inline
35
+ if isinstance(obj, _CONTAINER):
36
+ try:
37
+ items = obj if not isinstance(obj, dict) else list(obj.values())
38
+ if len(items) > 8:
39
+ return False
40
+ return all(_is_simple(i) for i in items)
41
+ except Exception:
42
+ return False
43
+ return False
44
+
45
+
46
+ def _is_visualizable(obj: typing.Any) -> bool:
47
+ """Return True if obj is a type we can safely snapshot for visualization."""
48
+ if isinstance(obj, _SIMPLE):
49
+ return True
50
+ if isinstance(obj, _CONTAINER):
51
+ try:
52
+ for item in obj if not isinstance(obj, dict) else obj.values():
53
+ if not _is_visualizable(item):
54
+ return False
55
+ return True
56
+ except Exception:
57
+ return False
58
+ return False
59
+
60
+
61
+ def _safe_repr(obj: typing.Any) -> str:
62
+ """Return a repr, falling back to type name for non-visualizable objects."""
63
+ try:
64
+ r = repr(obj)
65
+ if len(r) > 200:
66
+ r = r[:197] + "..."
67
+ return r
68
+ except Exception:
69
+ return f"<{type(obj).__name__}>"
70
+
71
+
72
+ # ─── Names to exclude from the global frame visualization ──────────────────
73
+ # These are IPython/Jupyter internals and Python builtins that clutter the
74
+ # Global frame. We want the Global frame to show only user-relevant names:
75
+ # imported modules (as references, not contents), user-defined variables,
76
+ # classes, and functions defined in the notebook or the traced cell.
77
+ _EXCLUDE_GLOBAL_NAMES = frozenset({
78
+ # IPython internals
79
+ "__name__", "__doc__", "__package__", "__loader__", "__spec__",
80
+ "__builtins__", "__file__", "__cached__", "_dh", "_ih", "_oh",
81
+ "_sh", "_", "__", "___", "__builtin__", "__builtins__",
82
+ "In", "Out", "get_ipython", "exit", "quit", "user_ns",
83
+ "open", "input", # builtins that shouldn't appear as global vars
84
+ "_i", "_ii", "_iii", "_i1", "_i2", "_i3", "_i4", "_i5",
85
+ "_i6", "_i7", "_i8", "_i9", "_i10", "_i11", "_i12",
86
+ # Jupyter/IPython helpers
87
+ "display", "HTML", "JSON", "Markdown", "Math", "Latex",
88
+ "PrettyPrinter", "pretty",
89
+ })
90
+
91
+ # Names that look like IPython history cells (_1, _2, etc.)
92
+ _IPYTHON_HISTORY = _re.compile(r"^_\d+$")
93
+
94
+ # Module-level cache: maps code_str → set of names the code touches.
95
+ # This avoids re-parsing on every trace step.
96
+ _code_name_cache: dict[str, frozenset[str]] = {}
97
+
98
+
99
+ def _extract_code_names(code_str: str) -> frozenset[str]:
100
+ """Extract all names the traced code defines or references via AST.
101
+
102
+ Returns a frozenset of variable names, function names, class names,
103
+ and imported names that appear in the code. This is used to filter
104
+ the global frame so only code-relevant variables are shown.
105
+ """
106
+ if code_str in _code_name_cache:
107
+ return _code_name_cache[code_str]
108
+
109
+ names: set[str] = set()
110
+ try:
111
+ tree = ast.parse(code_str)
112
+ for node in ast.walk(tree):
113
+ if isinstance(node, ast.Name):
114
+ names.add(node.id)
115
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
116
+ names.add(node.name)
117
+ elif isinstance(node, ast.arg):
118
+ names.add(node.arg)
119
+ elif isinstance(node, ast.alias):
120
+ # import statements: "import os" → "os", "from m import x as y" → "y"
121
+ names.add(node.asname or node.name)
122
+ elif isinstance(node, ast.Global):
123
+ for n in node.names:
124
+ names.add(n)
125
+ elif isinstance(node, ast.Nonlocal):
126
+ for n in node.names:
127
+ names.add(n)
128
+ except SyntaxError:
129
+ pass
130
+
131
+ result = frozenset(names)
132
+ _code_name_cache[code_str] = result
133
+ return result
134
+
135
+
136
+ def _is_excluded_global(name: str, val: typing.Any) -> bool:
137
+ """Return True if a global variable should NOT be shown in the Global frame."""
138
+ if name in _EXCLUDE_GLOBAL_NAMES:
139
+ return True
140
+ if name.startswith("__"):
141
+ return True
142
+ if _IPYTHON_HISTORY.match(name):
143
+ return True
144
+ # Skip IPython display hooks
145
+ if name.startswith("_") and name not in ("_", "__", "___"):
146
+ # Allow _, __, ___ (last three results) but skip other _ prefixed
147
+ if not name in ("_", "__", "___"):
148
+ return True
149
+ return False
150
+
151
+
152
+ def _capture_object_details(val: typing.Any, id_map: dict[int, str]) -> dict:
153
+ """Capture details about an object for the heap display.
154
+
155
+ Returns a dict with:
156
+ - attrs: list of {name, value_repr, heap_id} for instance/class attributes
157
+ - items: list of {key, value_repr, heap_id} for containers
158
+ - bases: list of base class names (for classes)
159
+ """
160
+ details: dict = {"attrs": [], "items": [], "bases": []}
161
+
162
+ try:
163
+ if isinstance(val, type):
164
+ # Class object — capture base classes and methods
165
+ bases = [b.__name__ for b in val.__bases__ if b is not object]
166
+ details["bases"] = bases
167
+ # Capture methods defined on this class (not inherited)
168
+ for name in sorted(dir(val)):
169
+ if name.startswith("__") and name != "__init__":
170
+ continue
171
+ try:
172
+ member = getattr(val, name)
173
+ if callable(member) and not isinstance(member, type):
174
+ details["attrs"].append({"name": name, "value_repr": "", "heap_id": None})
175
+ except Exception:
176
+ pass
177
+ elif isinstance(val, (types.FunctionType, types.BuiltinFunctionType)):
178
+ # Function — capture __name__ and __qualname__
179
+ # IMPORTANT: functools.wraps copies __name__ from the wrapped function
180
+ # to the wrapper, so val.__name__ may be "fibonacci" even for a wrapper.
181
+ # The true function name is in val.__code__.co_name (e.g. "wrapper").
182
+ # We show __name__ as an attribute so the user can see the renaming.
183
+ details["attrs"].append({"name": "__name__", "value_repr": val.__name__, "heap_id": None})
184
+ if hasattr(val, "__qualname__"):
185
+ details["attrs"].append({"name": "__qualname__", "value_repr": val.__qualname__, "heap_id": None})
186
+ elif isinstance(val, types.ModuleType):
187
+ # Module — just the name
188
+ pass
189
+ elif isinstance(val, dict):
190
+ # Dict — capture key-value pairs
191
+ for k, v in list(val.items())[:12]:
192
+ if _is_simple(v):
193
+ details["items"].append({"key": _safe_repr(k), "value_repr": _safe_repr(v), "heap_id": None})
194
+ else:
195
+ oid = id(v)
196
+ if oid not in id_map:
197
+ id_map[oid] = f"id{len(id_map) + 1}"
198
+ details["items"].append({"key": _safe_repr(k), "value_repr": "", "heap_id": id_map[oid]})
199
+ elif isinstance(val, (list, tuple, set, frozenset)):
200
+ # Sequence — capture items
201
+ for i, v in enumerate(list(val)[:12]):
202
+ if _is_simple(v):
203
+ details["items"].append({"key": str(i), "value_repr": _safe_repr(v), "heap_id": None})
204
+ else:
205
+ oid = id(v)
206
+ if oid not in id_map:
207
+ id_map[oid] = f"id{len(id_map) + 1}"
208
+ details["items"].append({"key": str(i), "value_repr": "", "heap_id": id_map[oid]})
209
+ else:
210
+ # Instance object — capture instance attributes
211
+ if hasattr(val, "__dict__"):
212
+ for name, v in sorted(vars(val).items()):
213
+ if name.startswith("__"):
214
+ continue
215
+ if _is_simple(v):
216
+ details["attrs"].append({"name": name, "value_repr": _safe_repr(v), "heap_id": None})
217
+ else:
218
+ oid = id(v)
219
+ if oid not in id_map:
220
+ id_map[oid] = f"id{len(id_map) + 1}"
221
+ details["attrs"].append({"name": name, "value_repr": "", "heap_id": id_map[oid]})
222
+ except Exception:
223
+ pass
224
+
225
+ return details
226
+
227
+
228
+ def _snapshot_variables(frame: types.FrameType, id_map: dict[int, str] | None = None) -> tuple[dict, list[dict]]:
229
+ """Capture local variables and return (simple_vars, object_refs).
230
+
231
+ simple_vars: name→repr for primitives/small containers (shown inline)
232
+ object_refs: list of {name, repr, id, heap_id, type} for objects that should be
233
+ shown as heap references (classes, instances, large containers)
234
+ id_map: shared dict mapping id(obj)→heap_id label, so the same object
235
+ always gets the same label across all frames and steps.
236
+ """
237
+ if id_map is None:
238
+ id_map = {}
239
+ simple: dict[str, str] = {}
240
+ objects: list[dict] = []
241
+
242
+ # Locals (function frame)
243
+ for name, val in frame.f_locals.items():
244
+ if name.startswith("__"):
245
+ continue
246
+ if _is_simple(val):
247
+ simple[name] = _safe_repr(val)
248
+ else:
249
+ oid = id(val)
250
+ if oid not in id_map:
251
+ id_map[oid] = f"id{len(id_map) + 1}"
252
+ objects.append({
253
+ "name": name,
254
+ "repr": _safe_repr(val),
255
+ "id": oid,
256
+ "heap_id": id_map[oid],
257
+ "type": type(val).__name__,
258
+ "is_class": isinstance(val, type),
259
+ "is_instance": hasattr(val, "__class__") and not isinstance(val, type) and not isinstance(val, _SIMPLE + _CONTAINER + (types.FunctionType, types.ModuleType)),
260
+ "is_function": isinstance(val, (types.FunctionType, types.BuiltinFunctionType)),
261
+ "is_module": isinstance(val, types.ModuleType),
262
+ # true_name: the actual function name from the code object.
263
+ # functools.wraps copies __name__ but __code__.co_name stays original.
264
+ "true_name": val.__code__.co_name if isinstance(val, types.FunctionType) else None,
265
+ "display_name": val.__name__ if isinstance(val, types.FunctionType) else None,
266
+ "details": _capture_object_details(val, id_map),
267
+ })
268
+
269
+ return simple, objects
270
+
271
+
272
+ def _snapshot_globals(
273
+ frame: types.FrameType,
274
+ id_map: dict[int, str] | None = None,
275
+ code_names: frozenset[str] | None = None,
276
+ ) -> tuple[dict, list[dict]]:
277
+ """Capture user globals (not builtins/IPython internals).
278
+
279
+ Returns (simple_vars, object_refs) same as _snapshot_variables but
280
+ for the global frame, filtered to exclude clutter.
281
+
282
+ If ``code_names`` is provided, only globals whose name appears in
283
+ ``code_names`` are shown — this limits the display to variables and
284
+ imports that the traced code actually uses.
285
+ """
286
+ if id_map is None:
287
+ id_map = {}
288
+ simple: dict[str, str] = {}
289
+ objects: list[dict] = []
290
+
291
+ for name, val in frame.f_globals.items():
292
+ if _is_excluded_global(name, val):
293
+ continue
294
+ # If we have code-relevant names, filter to only those
295
+ if code_names is not None and name not in code_names:
296
+ continue
297
+ # Skip modules — they're imports, show as references
298
+ if isinstance(val, types.ModuleType):
299
+ # Show imported modules as references (like OPT_Mentor shows
300
+ # imported packages as arrows to module objects)
301
+ oid = id(val)
302
+ if oid not in id_map:
303
+ id_map[oid] = f"id{len(id_map) + 1}"
304
+ objects.append({
305
+ "name": name,
306
+ "repr": f"<module '{val.__name__}'>",
307
+ "id": oid,
308
+ "heap_id": id_map[oid],
309
+ "type": "module",
310
+ "is_class": False,
311
+ "is_instance": False,
312
+ "is_function": False,
313
+ "is_module": True,
314
+ "details": _capture_object_details(val, id_map),
315
+ })
316
+ continue
317
+ if _is_simple(val):
318
+ simple[name] = _safe_repr(val)
319
+ else:
320
+ oid = id(val)
321
+ if oid not in id_map:
322
+ id_map[oid] = f"id{len(id_map) + 1}"
323
+ objects.append({
324
+ "name": name,
325
+ "repr": _safe_repr(val),
326
+ "id": oid,
327
+ "heap_id": id_map[oid],
328
+ "type": type(val).__name__,
329
+ "is_class": isinstance(val, type),
330
+ "is_instance": hasattr(val, "__class__") and not isinstance(val, type) and not isinstance(val, _SIMPLE + _CONTAINER + (types.FunctionType, types.ModuleType)),
331
+ "is_function": isinstance(val, (types.FunctionType, types.BuiltinFunctionType)),
332
+ "is_module": isinstance(val, types.ModuleType),
333
+ "true_name": val.__code__.co_name if isinstance(val, types.FunctionType) else None,
334
+ "display_name": val.__name__ if isinstance(val, types.FunctionType) else None,
335
+ "details": _capture_object_details(val, id_map),
336
+ })
337
+
338
+ return simple, objects
339
+
340
+
341
+ def _snapshot_frame(
342
+ frame: types.FrameType,
343
+ is_global: bool = False,
344
+ id_map: dict[int, str] | None = None,
345
+ code_names: frozenset[str] | None = None,
346
+ ) -> dict:
347
+ """Capture a single frame's name, line, and variables for the call stack."""
348
+ if is_global:
349
+ simple_vars, object_refs = _snapshot_globals(frame, id_map, code_names)
350
+ else:
351
+ simple_vars, object_refs = _snapshot_variables(frame, id_map)
352
+ return {
353
+ "func": frame.f_code.co_name,
354
+ "line": frame.f_lineno,
355
+ "simple_vars": simple_vars,
356
+ "object_refs": object_refs,
357
+ "locals": simple_vars, # backward compat
358
+ }
359
+
360
+
361
+ def _snapshot_call_stack(
362
+ frame: types.FrameType,
363
+ id_map: dict[int, str] | None = None,
364
+ code_names: frozenset[str] | None = None,
365
+ ) -> list[dict]:
366
+ """Walk up the call stack via frame.f_back and capture all frames.
367
+
368
+ Returns a list ordered outermost-first (module → outer → inner).
369
+ The first element is always the Global frame (using f_globals).
370
+ Only frames from our traced file are included (plus the global frame).
371
+ """
372
+ stack: list[dict] = []
373
+ f = frame
374
+ seen_globals = False
375
+ while f is not None:
376
+ if f.f_code.co_filename == frame.f_code.co_filename:
377
+ is_global = (f.f_code.co_name == "<module>")
378
+ if is_global:
379
+ seen_globals = True
380
+ stack.append(_snapshot_frame(f, is_global=is_global, id_map=id_map, code_names=code_names))
381
+ f = f.f_back
382
+ stack.reverse() # outermost first
383
+
384
+ # If we didn't see a global frame in the traced file, add one from f_globals
385
+ if not seen_globals and frame.f_globals:
386
+ # Create a synthetic global frame
387
+ simple_vars, object_refs = _snapshot_globals(frame, id_map, code_names)
388
+ global_frame = {
389
+ "func": "<module>",
390
+ "line": 0,
391
+ "simple_vars": simple_vars,
392
+ "object_refs": object_refs,
393
+ "locals": simple_vars,
394
+ }
395
+ stack.insert(0, global_frame)
396
+
397
+ return stack
398
+
399
+
400
+ def _trace_code_internal(
401
+ code_str: str,
402
+ user_globals: dict[str, typing.Any] | None = None,
403
+ max_steps: int = 5000,
404
+ ) -> list[dict]:
405
+ """Execute *code_str* under a trace and return the list of steps.
406
+
407
+ Each step is a dict with keys:
408
+ line: int — line number (1-based) that is about to execute
409
+ source: str — the source line text (stripped)
410
+ call_stack: list — list of frame dicts (outermost first)
411
+ event: str — "call", "line", or "return"
412
+ func: str — current function name
413
+ stdout: str — output printed up to THIS step (incremental)
414
+ """
415
+ steps: list[dict] = []
416
+ step_count = 0
417
+ # Execute directly in the user's namespace so that classes, functions,
418
+ # and imports from previous cells are available. This is how a normal
419
+ # Jupyter cell works — the %%tutor magic should behave the same way,
420
+ # just with tracing enabled. Variables defined by the traced code WILL
421
+ # persist in the kernel (like a normal cell), but we must not overwrite
422
+ # __name__ or __builtins__.
423
+ if user_globals is None:
424
+ user_globals = {"__name__": "__main__", "__builtins__": __builtins__}
425
+ g = user_globals
426
+ # Ensure __builtins__ is available
427
+ if "__builtins__" not in g:
428
+ g["__builtins__"] = __builtins__
429
+
430
+ # Compile with a unique filename so we can map line numbers
431
+ filename = "<jpt>"
432
+ try:
433
+ compiled = compile(code_str, filename, "exec")
434
+ except SyntaxError as e:
435
+ return [{
436
+ "line": e.lineno or 1,
437
+ "source": f"# SyntaxError: {e.msg}",
438
+ "call_stack": [],
439
+ "event": "exception",
440
+ "func": "<module>",
441
+ "error": f"{type(e).__name__}: {e.msg}",
442
+ "stdout": "",
443
+ }]
444
+
445
+ source_lines = code_str.splitlines()
446
+
447
+ # Extract names the code touches, so we can filter the global frame
448
+ # to only show variables/imports relevant to this code.
449
+ code_names = _extract_code_names(code_str)
450
+
451
+ class _StepLimitExceeded(Exception):
452
+ """Raised internally to stop execution when max_steps is reached."""
453
+
454
+ # ─── Stdout capture with per-step tracking ──────────────────────────
455
+ # We capture stdout in a buffer and track the length at each step,
456
+ # so each step shows only the output produced up to that point.
457
+ import io
458
+ captured = io.StringIO()
459
+ stdout_lengths: list[int] = [] # length of captured output at each step
460
+
461
+ class _StepTrackingStream:
462
+ """A stream that tracks how much output has been written at each step."""
463
+ def __init__(self, real_stream):
464
+ self._real = real_stream
465
+ self._total_len = 0
466
+ def write(self, s):
467
+ self._real.write(s)
468
+ self._total_len += len(s)
469
+ def flush(self):
470
+ self._real.flush()
471
+ @property
472
+ def total_len(self):
473
+ return self._total_len
474
+
475
+ tracking_stream = _StepTrackingStream(captured)
476
+
477
+ # Shared ID map: maps id(obj) → "id1", "id2", etc.
478
+ # Same object always gets the same label across all steps.
479
+ id_map: dict[int, str] = {}
480
+
481
+ def _trace(frame, event, arg):
482
+ nonlocal step_count
483
+ if frame.f_code.co_filename != filename:
484
+ return _trace # skip frames from other files
485
+ if step_count >= max_steps:
486
+ raise _StepLimitExceeded() # stop execution, not just tracing
487
+ step_count += 1
488
+
489
+ lineno = frame.f_lineno
490
+ source = source_lines[lineno - 1].strip() if 1 <= lineno <= len(source_lines) else ""
491
+ call_stack = _snapshot_call_stack(frame, id_map, code_names)
492
+ current_stdout = captured.getvalue() # everything printed so far
493
+
494
+ if event == "line":
495
+ steps.append({
496
+ "line": lineno,
497
+ "source": source,
498
+ "call_stack": call_stack,
499
+ "event": "line",
500
+ "func": frame.f_code.co_name,
501
+ "stdout": current_stdout,
502
+ })
503
+ elif event == "call":
504
+ steps.append({
505
+ "line": lineno,
506
+ "source": source,
507
+ "call_stack": call_stack,
508
+ "event": "call",
509
+ "func": frame.f_code.co_name,
510
+ "stdout": current_stdout,
511
+ })
512
+ elif event == "return":
513
+ ret_repr = _safe_repr(arg) if arg is not None else "None"
514
+ steps.append({
515
+ "line": lineno,
516
+ "source": source,
517
+ "call_stack": call_stack,
518
+ "event": "return",
519
+ "func": frame.f_code.co_name,
520
+ "return_value": ret_repr,
521
+ "stdout": current_stdout,
522
+ })
523
+ elif event == "exception":
524
+ exc_type, exc_val, _ = arg
525
+ # Don't record our internal exception
526
+ if exc_type is _StepLimitExceeded:
527
+ return None
528
+ steps.append({
529
+ "line": lineno,
530
+ "source": source,
531
+ "call_stack": call_stack,
532
+ "event": "exception",
533
+ "func": frame.f_code.co_name,
534
+ "error": f"{exc_type.__name__}: {exc_val}",
535
+ "stdout": current_stdout,
536
+ })
537
+
538
+ return _trace
539
+
540
+ old_stdout = sys.stdout
541
+ old_trace = sys.gettrace()
542
+
543
+ try:
544
+ sys.stdout = tracking_stream
545
+ sys.settrace(_trace)
546
+ exec(compiled, g)
547
+ except _StepLimitExceeded:
548
+ pass # execution stopped due to step limit
549
+ except Exception as e:
550
+ steps.append({
551
+ "line": 0,
552
+ "source": f"# {type(e).__name__}: {e}",
553
+ "call_stack": [],
554
+ "event": "exception",
555
+ "func": "<module>",
556
+ "error": f"{type(e).__name__}: {e}",
557
+ "stdout": captured.getvalue(),
558
+ })
559
+ finally:
560
+ sys.settrace(old_trace)
561
+ sys.stdout = old_stdout
562
+
563
+ return steps
564
+
565
+
566
+ def trace_code(
567
+ code_str: str,
568
+ max_steps: int = 5000,
569
+ user_globals: dict[str, typing.Any] | None = None,
570
+ ) -> list[dict]:
571
+ """Trace Python code and return step-by-step execution state.
572
+
573
+ Parameters
574
+ ----------
575
+ code_str : str
576
+ The Python code to trace.
577
+ max_steps : int
578
+ Maximum number of trace steps to capture (safety limit).
579
+ user_globals : dict, optional
580
+ The kernel's global namespace (so imported packages and existing
581
+ variables are available to the traced code). The code is executed
582
+ directly in this namespace — variables defined by the traced code
583
+ persist in the kernel (like a normal Jupyter cell).
584
+
585
+ Returns
586
+ -------
587
+ list[dict]
588
+ Each dict has keys: line, source, call_stack, event, func.
589
+ May also include: return_value, error, stdout.
590
+ """
591
+ return _trace_code_internal(code_str, user_globals, max_steps)