-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpython_tracer.py
More file actions
388 lines (331 loc) · 14.2 KB
/
Copy pathpython_tracer.py
File metadata and controls
388 lines (331 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
# python_tracer.py — line-level Python execution tracer for LeetTutor.
#
# SECURITY NOTE: execute_and_trace() uses exec() to run user-supplied code.
# This is intentional — LeetTutor is a personal tool for tracing YOUR OWN
# code locally. Do not host it as a public service without a sandbox.
import sys
import json
import inspect
import math
import ast
def serialize_value(val, depth=0, _seen=None):
"""Recursively serialize a Python value to a JSON-safe structure.
- Tracks object ids in `_seen` to detect true reference cycles, not just
depth, so long linked lists are not truncated to '...cycle...'.
- Tuples/sets are serialized as tagged objects distinguishable from lists.
- float inf/nan produce JSON-safe sentinel strings.
- A hard depth cap of 50 still guards against pathological structures.
"""
if _seen is None:
_seen = set()
if depth > 50:
return "...depth limit..."
if val is None:
return None
t_name = type(val).__name__
if inspect.ismodule(val) or inspect.isclass(val) or inspect.isroutine(val) or callable(val):
return str(val)
if t_name == 'float':
if val == float('inf'):
return "Infinity"
if val == float('-inf'):
return "-Infinity"
if math.isnan(val):
return "NaN"
return val
if t_name in ('int', 'str', 'bool'):
return val
if isinstance(val, tuple):
return {"__type__": "tuple", "items": [serialize_value(x, depth + 1, _seen) for x in val]}
if isinstance(val, list):
return [serialize_value(x, depth + 1, _seen) for x in val]
if isinstance(val, dict):
return {str(k): serialize_value(v, depth + 1, _seen) for k, v in val.items()}
if isinstance(val, set):
return {"__type__": "set", "items": [serialize_value(x, depth + 1, _seen) for x in val]}
obj_id = id(val)
if obj_id in _seen:
return f"...cycle ({t_name})..."
_seen = _seen | {obj_id}
if hasattr(val, '__dict__'):
obj_dict = {}
for k, v in val.__dict__.items():
if not k.startswith('_'):
obj_dict[k] = serialize_value(v, depth + 1, _seen)
obj_dict['__type__'] = t_name
return obj_dict
return str(val)
# ---------------------------------------------------------------------------
# Preamble prepended to user code before exec(). PREAMBLE_LINES is derived
# from this string automatically, and _PREAMBLE_NAMES is computed by
# actually running it once — both stay correct even if the preamble changes,
# with zero hardcoded magic numbers or name lists to maintain by hand.
# ---------------------------------------------------------------------------
_PREAMBLE = """\
from typing import *
import collections
import math
import heapq
import bisect
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def to_list_node(arr):
if not arr: return None
head = ListNode(arr[0])
curr = head
for x in arr[1:]:
curr.next = ListNode(x)
curr = curr.next
return head
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def to_tree_node(vals):
if not vals: return None
from collections import deque
root = TreeNode(vals[0])
q = deque([root])
i = 1
while q and i < len(vals):
node = q.popleft()
if i < len(vals) and vals[i] is not None:
node.left = TreeNode(vals[i])
q.append(node.left)
i += 1
if i < len(vals) and vals[i] is not None:
node.right = TreeNode(vals[i])
q.append(node.right)
i += 1
return root
"""
PREAMBLE_LINES = _PREAMBLE.count('\n')
# Every name the preamble binds (typing.* exports, stdlib modules, the
# ListNode/TreeNode/to_list_node/to_tree_node helpers). Anything with these
# names is "scaffolding", not the learner's own data, and is excluded from
# every variables panel. Computed by actually executing the preamble once,
# so it's automatically correct for whatever Python version this runs on
# (typing's exported names vary release to release) and automatically
# tracks any future edits to _PREAMBLE with no separate list to maintain.
_preamble_ns = {}
exec(_PREAMBLE, _preamble_ns) # pylint: disable=exec-used
_PREAMBLE_NAMES = set(_preamble_ns.keys())
_PREAMBLE_NAMES.discard('__builtins__')
def _classify_line(text):
"""Cheap, dependency-free heuristic tag for what a line of code *does*.
Used to give each step a short human label (Loop / Branch / Return /
...) in the UI without needing an LLM call per step.
"""
t = text.strip()
if not t:
return None
if t.startswith(('for ', 'while ')):
return 'loop'
if t.startswith(('if ', 'elif ', 'else')):
return 'branch'
if t.startswith('return'):
return 'return'
if t.startswith(('def ', 'class ')):
return 'define'
if t.startswith(('break', 'continue')):
return 'control'
if any(op in t for op in ('+=', '-=', '*=', '/=', '//=', '%=')):
return 'update'
# Plain assignment, but not a comparison (==, <=, >=, !=)
if '=' in t and not any(op in t for op in ('==', '<=', '>=', '!=', ':=')):
return 'assign'
return 'call'
def _longest_parseable_prefix(code):
"""If `code` has a trailing syntax error — e.g. it was cut off
mid-statement by a flaky extraction, or the learner is still in the
middle of typing it — progressively drop lines from the END until
what remains parses cleanly, rather than refusing to trace anything
at all. Returns (trimmed_code, lines_dropped): dropped is 0 if the
original code was already valid, and trimmed_code is None if not
even a single line parses.
"""
lines = code.split('\n')
try:
ast.parse(code)
return code, 0
except SyntaxError:
pass
for trim in range(1, len(lines) + 1):
candidate = '\n'.join(lines[:len(lines) - trim])
if not candidate.strip():
break
try:
ast.parse(candidate)
return candidate, trim
except SyntaxError:
continue
return None, -1
class PythonCodeTracer:
def __init__(self):
self.steps = []
def _make_trace(self, line_text_map, user_start):
"""Returns a sys.settrace callable that only records lines from the
user's own code (never the preamble, never stdlib frames pulled in
by `import heapq` etc.), and only meaningful local variables
(never the ~80 names `from typing import *` dumps into scope).
"""
def filtered_locals(frame):
out = {}
for name, val in frame.f_locals.items():
if name.startswith('__') or name == 'self':
continue
if name in _PREAMBLE_NAMES:
continue
if inspect.ismodule(val) or inspect.isclass(val) or inspect.isroutine(val) or callable(val):
continue # code structure (a def/class), not learner data
out[name] = serialize_value(val)
return out
def build_call_stack(top_frame):
"""Walk f_back to collect every active frame that belongs to
the exec'd code (skips frames once we leave '<string>' or drop
below the user's own code region), outermost first — mirroring
how Python Tutor renders the call stack.
"""
frames = []
f = top_frame
while f is not None and f.f_code.co_filename == '<string>':
frames.append(f)
f = f.f_back
frames.reverse()
stack = []
for fr in frames:
stack.append({
"function": "Global" if fr.f_code.co_name == '<module>' else fr.f_code.co_name,
"line": fr.f_lineno,
"variables": filtered_locals(fr),
})
return stack
def trace_calls(frame, event, arg):
if event == 'line':
line_no = frame.f_lineno
if frame.f_code.co_filename != '<string>':
return trace_calls # stdlib/site-packages frame — skip
if line_no < user_start:
return trace_calls # preamble line — skip
current_locals = filtered_locals(frame)
line_text = line_text_map.get(line_no, "")
action = _classify_line(line_text)
# Skip pure declaration lines (def/class headers) that have
# no learner-meaningful state yet — this happens at module
# scope (top-level `def foo(...):`) AND one level deeper,
# inside the transient frame Python creates to execute a
# class body (e.g. `class Solution:` briefly runs its own
# frame just to define methods). Both were the source of
# the original noise: dozens of near-empty "steps" for
# every class/def header before real execution even starts.
if not current_locals and action == 'define':
return trace_calls
is_module_frame = frame.f_code.co_name == '<module>'
self.steps.append({
"line": line_no,
"lineText": line_text,
"action": _classify_line(line_text),
"function": "Global" if is_module_frame else frame.f_code.co_name,
"stackDepth": len(inspect.stack()),
"callStack": build_call_stack(frame),
"isResult": is_module_frame and '_result' in current_locals,
})
return trace_calls
return trace_calls
def execute_and_trace(self, code_to_run, function_call):
self.steps = []
original_line_count = len(code_to_run.split('\n'))
trimmed_code, dropped = _longest_parseable_prefix(code_to_run)
if trimmed_code is None:
# Not even a single line parses on its own — nothing to salvage.
# Report the real syntax error against the original code so the
# line number/message are meaningful.
try:
ast.parse(code_to_run)
except SyntaxError as e:
self.steps.append({
"error": str(e),
"error_type": "SyntaxError",
"line": (e.lineno or 1) + PREAMBLE_LINES,
"lineText": code_to_run.split('\n')[(e.lineno or 1) - 1] if e.lineno else "",
})
return json.dumps({"steps": self.steps, "truncated": None})
truncated_info = None
if dropped > 0:
traced_lines = original_line_count - dropped
truncated_info = {
"droppedLines": dropped,
"totalLines": original_line_count,
"tracedLines": traced_lines,
"message": (
f"Only tracing the first {traced_lines} of {original_line_count} lines — "
f"the rest doesn't parse as valid Python (a syntax error starts around there), "
f"so it was left out rather than blocking the trace entirely."
),
}
code_lines = trimmed_code.split('\n')
user_start = PREAMBLE_LINES + 1
call_line_no = user_start + len(code_lines) + 1 # one blank line, then the call
line_text_map = {user_start + i: line for i, line in enumerate(code_lines)}
line_text_map[call_line_no] = f"_result = ({function_call})"
execution_block = _PREAMBLE + trimmed_code + f"\n\n_result = ({function_call})"
tracer = self._make_trace(line_text_map, user_start)
# Single shared namespace for both globals and locals. If these were
# two different dicts, top-level classes like ListNode/TreeNode
# would be stored via STORE_NAME into the locals dict, but nested
# helper functions (to_list_node, to_tree_node, and the user's own
# Solution methods) resolve free names via LOAD_GLOBAL against
# func.__globals__ — the *other* dict — causing a NameError even
# though the class is defined right above it.
ns = {}
sys.settrace(tracer)
try:
exec(execution_block, ns) # pylint: disable=exec-used
# sys.settrace's 'line' event fires *before* a line executes, so
# there is no trace event after the final `_result = (...)`
# assignment completes — the program just ends. Append the
# result explicitly so the UI always has a definitive final step.
if '_result' in ns:
self.steps.append({
"line": call_line_no,
"lineText": line_text_map.get(call_line_no, ""),
"action": "return",
"function": "Global",
"stackDepth": 1,
"callStack": [{
"function": "Global",
"line": call_line_no,
"variables": {"_result": serialize_value(ns["_result"])},
}],
"isResult": True,
})
except Exception as e:
tb = sys.exc_info()[2]
while tb and tb.tb_next:
tb = tb.tb_next
raw_line = tb.tb_lineno if tb else -1
self.steps.append({
"error": str(e),
"error_type": type(e).__name__,
"line": raw_line,
"lineText": line_text_map.get(raw_line, ""),
})
finally:
sys.settrace(None)
return json.dumps({"steps": self.steps, "truncated": truncated_info})
tracer = PythonCodeTracer()
# --- FFI JS Bindings (only active inside Pyodide / PyScript) ---
try:
from pyodide.ffi import create_proxy
from js import window
def js_execute_and_trace(code_js, call_js):
return tracer.execute_and_trace(str(code_js), str(call_js))
window.executePythonTracer = create_proxy(js_execute_and_trace)
window.TRACER_PREAMBLE_LINES = PREAMBLE_LINES
if hasattr(window, "onPythonLoaded"):
window.onPythonLoaded()
except ImportError:
pass