A Python-Tutor-style, step-by-step execution visualizer for LeetCode solutions — runs entirely in your browser via PyScript/Pyodide, no server, no data leaves your machine.
Paste a Python solution and a test call, click Trace, and step through execution one line at a time — watching every variable, list, linked list, tree, dict, and set update in real time. Inspired by Philip Guo's Python Tutor, rebuilt specifically for LeetCode-style problems: ListNode/TreeNode helpers are pre-loaded so you can construct test inputs without boilerplate.
- Code panel — highlights the line that just ran and the line about to run
- Memory & Variables panel — shows the full call stack (Global scope down through every active frame, correctly separating recursive calls), renders lists as index tapes, 2-D arrays as grids, linked lists as node chains with arrows, trees as indented structures, dicts/sets as tag clouds. Only your own algorithm's variables ever appear — see the noise-filtering notes below.
- Changed-variable highlighting — a variable that just changed value flashes and gets a "changed" tag, so you can track state evolution across loop iterations at a glance
- Action tags — every step is labeled Loop / Branch / Assign / Update / Return / Call based on what the line does, shown as a colored badge next to the step counter and in the Trace History table
- Result banner — once execution finishes, a dedicated green banner shows exactly what your function returned, rendered with the same visualizers (so a returned linked list still shows as a node chain, not a memory address)
- Complexity tab — a static-analysis estimate of your code's time and space complexity (loop nesting, recursion shape, sort/memoization detection, hidden
O(n)list-membership checks), with an expandable "Why?" reasoning list for each verdict. Runs entirely client-side via Python's ownastmodule — no LLM call, no server round-trip. - Trace History panel — a spreadsheet-style table of every step (line, action, frame, variables); click any row to jump straight there
- Player controls — step forward/back, play/pause at adjustable speed, reset, with a slim progress bar tracking your position
The standalone page is the primary product — paste-and-go, nothing to install. A companion Chrome extension (below) is a thin convenience layer that auto-imports code from the LeetCode editor.
git clone https://github.com/vkDemon1/leettutor.git
cd leettutor
python -m http.server 8000Open http://localhost:8000.
The extension scrapes your current solution directly from LeetCode's Monaco editor and opens LeetTutor with it pre-loaded — no copy-paste.
- You click the extension's toolbar icon on a
leetcode.com/problems/*page. - A content script reads the editor's exact contents via LeetCode's own
monacoobject (not by scraping visible DOM text, which would miss off-screen lines). - The code is base64-encoded into a URL and opened in a new tab:
https://vkDemon1.github.io/leettutor/?code=<base64>&call=<base64> - LeetTutor reads those URL parameters on load and runs the trace automatically.
This URL-param handoff is deliberately simpler than a postMessage/iframe integration — it works cross-origin with no coupling, degrades gracefully (worst case you land on an empty LeetTutor tab), and the resulting link is itself shareable.
- Go to
chrome://extensions, enable Developer mode. - Click Load unpacked, select the
extension/folder in this repo. - Open any LeetCode problem, write/select a Python3 solution, click the extension icon.
LeetCode's DOM and internal APIs change periodically; the extension's Monaco-based extraction is best-effort. inject.js polls for window.monaco for up to 3 seconds (Monaco can initialize after the content script first runs), and falls back to scraping visible DOM text only if that never appears — worth knowing because that DOM fallback only sees whatever lines are currently scrolled into view, so a long solution can come back truncated. If extraction ever returns empty or partial, just paste your code into the page manually — that path always works.
Partial/truncated code still traces. Whatever the source — a flaky extraction, or you mid-typing a solution — if the pasted code has a trailing syntax error, the tracer no longer refuses to run. It finds the longest prefix that parses cleanly, traces that, and shows a banner telling you how many lines were included versus dropped. A syntax error in the middle of otherwise-valid code (a genuine typo, not a truncation) still reports normally, since trimming only removes from the end.
complexity_analyzer.py walks the target function's AST and applies the
same pattern-matching an experienced reviewer does at a glance:
- Loop nesting depth → polynomial time (
O(n),O(n^2), ...) whileloops that shrink their range (hi = mid - 1,x //= 2) →O(log n)- Recursion, distinguishing:
- genuinely overlapping subproblems with no memoization (
fib(n-1) + fib(n-2)) →O(2^n) - the same branching shape applied to a tree/list (
maxDepth(root.left),maxDepth(root.right)) →O(n), because each node is visited exactly once even though there are two call sites — this is the single easiest mistake for a naive "count the recursive calls" heuristic to make, so it gets a dedicated check - divide-and-conquer (
mergeSort(arr[:mid])) →O(n log n) - binary-search-style (
search(nums, target, mid+1, hi)) →O(log n), correctly recognizing that only one of the two call sites ever runs per invocation (an early-return "guard clause" is treated as an implicitelse) - memoized recursion (
@lru_cache, amemo/cachedict) →O(n)-ish, flagged as lower confidence since the real bound depends on the state space
- genuinely overlapping subproblems with no memoization (
sort()/sorted()→ floors the estimate atO(n log n)- Space: data structures whose size scales with the input (
dp = [0]*n,[[0]*n for _ in range(n)], a dict/list grown inside a loop) plus recursion call-stack depth - A specific warning for the classic accidental-quadratic bug: an
incheck against something that looks like a list (not a dict/set) inside a loop
This is a heuristic, not a prover. Determining exact asymptotic complexity for arbitrary code is undecidable in general — the analyzer can be fooled by unusual control flow, complexity hidden inside a call to a different helper function (only the target function's own body is analyzed), or generator/itertools-based tricks. Every verdict ships with its reasoning so you can sanity-check it yourself rather than take it on faith; treat it the way you'd treat a linter, not a test suite.
pip install pytest
pytest tests/ -v57 tests cover: the serializer (cycle detection, tuples, sets, long linked lists), noise filtering (typing.* exclusion, class-body frame suppression), the call stack, the result-capture step, the tracer itself (error handling mid-execution, to_list_node/to_tree_node helpers, the preamble line-offset invariant, partial-code recovery), and the complexity analyzer (every pattern described above, including the tree-vs-Fibonacci distinction).
Building this surfaced several real bugs in the original tracer design, all covered by regression tests now:
- Runtime errors were silently swallowed. The original UI only checked
steps[0].error, which only catches errors before any line executes (e.g. a syntax error). Any runtime error (IndexError, TypeError — the common case) landed at the end of the steps array and was never surfaced. Fixed: every step carries its ownerror/error_typefields, checked directly at render time. - A deep
exec()namespace bug meantto_list_node/to_tree_nodenever worked.exec(code, globals(), {})uses two different dicts for globals and locals; nested helper functions resolve free variables via__globals__, not the locals dict, soListNode/TreeNode(stored in the locals dict) were invisible toto_list_node/to_tree_node— aNameErroron every single list/tree problem. Fixed by using one shared namespace dict for both globals and locals. - Standard-library frames leaked into the trace.
sys.settracefires for every Python frame, including ones triggered insideimport heapq/import collectionsduring preamble execution. The tracer now filters toco_filename == '<string>'so only user code is recorded. - Long linked lists were truncated. The serializer used a flat recursion-depth cutoff as a cycle guard, which falsely truncated any list longer than ~10 nodes. Replaced with proper object-id-based cycle detection (a
visitedset), so length is no longer conflated with cycles. - Tuples and sets fell through to
str()instead of being rendered as structured data. Both now serialize as tagged objects the UI renders properly. - Fragile hardcoded line-offset. The UI used to hardcode
-29in two places to convert raw trace line numbers to user-visible ones, coupled to an exact preamble line count that lived in a different file. NowPREAMBLE_LINESis computed once from the actual preamble string and exposed to JS aswindow.TRACER_PREAMBLE_LINES, so editing the preamble can never silently desync line highlighting again. from typing import *and stdlib imports polluted every single variables panel. Because the preamble doesfrom typing import *plusimport collections/math/heapq/bisect, and the exec namespace fix above (point 2) intentionally shares one dict for globals/locals, the module-level frame'sf_localscontained 40+ irrelevant names (Annotated,ParamSpec,TypeVarTuple,<module 'heapq' ...>,<class 'ListNode'>, and so on) alongside the learner's actual variables. Fixed by computing_PREAMBLE_NAMESonce (by literally executing the preamble in isolation and recording what it binds) and excluding every one of those names, plus any module/class/function object, from every frame at render time — automatically correct regardless of Python version, sincetyping's exports differ release to release.- Transient class-body frames leaked in too. Executing
class Solution:briefly runs its own frame (to define the methods), which used to show up as an empty, confusing "Solution" card in the memory panel. Declaration-only lines (def/classheaders with no bound state yet) are now filtered at every nesting level, not just module scope. - No visibility into the call stack for recursive problems. The original design only ever showed the single innermost frame, so a recursive function like
maxDepthgave no sense of the stack building up. The tracer now walksframe.f_backat every step to report the full active call stack (Global → outer calls → current frame), rendered as stacked cards with the currently-executing frame highlighted — closer to how Python Tutor itself visualizes recursion. - No signal for what a step actually accomplishes. Added a zero-dependency line classifier (
loop/branch/assign/update/return/call) surfaced as a colored badge, plus diff-based "changed" highlighting on any variable whose value differs from its last appearance in that same frame, plus an explicit result banner (the tracer doesn't get a real trace event after the final line, sincesys.settracefires before each line executes and the program then just ends — so the result is captured directly from the namespace afterexec()returns and appended as its own step). - Truncated code refused to run at all. Any trailing syntax error — from a flaky extraction or the learner mid-typing — made
exec()fail before a single line executed, since Python has to compile a whole block before running any of it. The tracer now finds the longest prefix of the code that parses cleanly, traces that, and reports how much was dropped, instead of an all-or-nothing failure. (extract_and_trace()'s return shape changed from a bare steps array to{"steps": [...], "truncated": {...} | null}to carry this.)
leettutor/
├── index.html # Standalone visualizer (primary product)
├── python_tracer.py # Tracer engine + Pyodide FFI bindings
├── complexity_analyzer.py # Static AST-based time/space complexity estimator
├── extension/ # Chrome extension (Manifest V3)
│ ├── manifest.json
│ ├── background.js # Toolbar click handler → builds the handoff URL
│ ├── content.js # Extracts code from the LeetCode page
│ └── inject.js # Page-context script that reads monaco directly
├── tests/
│ ├── test_tracer.py # 30 tests covering the tracer + serializer + recovery
│ └── test_complexity.py # 25 tests covering the complexity heuristics
└── .github/workflows/
├── test.yml # CI: run tests on every push/PR
└── deploy.yml # CI: deploy index.html to GitHub Pages
execute_and_trace() runs your code via exec(). This is by design for a personal, local, single-user tool. It is not hardened for hosting as a public multi-tenant service — don't repurpose the tracer backend that way without adding a real sandbox.
Inspired by Python Tutor by Philip Guo — a fantastic tool for building programming intuition. LeetTutor narrows that idea specifically to LeetCode-style problems (pre-loaded ListNode/TreeNode helpers, LeetCode DOM integration) rather than trying to be a general-purpose replacement.
MIT © 2025 vkDemon1