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.
- jupyter_python_tutor/__init__.py +16 -0
- jupyter_python_tutor/magics.py +109 -0
- jupyter_python_tutor/renderer.py +837 -0
- jupyter_python_tutor/tracer.py +591 -0
- jupyter_python_tutor-0.2.0.dist-info/METADATA +151 -0
- jupyter_python_tutor-0.2.0.dist-info/RECORD +9 -0
- jupyter_python_tutor-0.2.0.dist-info/WHEEL +5 -0
- jupyter_python_tutor-0.2.0.dist-info/licenses/LICENSE +21 -0
- jupyter_python_tutor-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|