Repository navigation
Stop re-parsing a broken Python file once per line - #6
Merged
Merged
Conversation
When a paste has a syntax error, the adapter salvages the longest prefix that parses. It did that by dropping one line at a time and re-parsing, so an error near the top of a large file re-parsed it thousands of times: 197KB broken on line 1 took 7.0s, all of it in `ast.parse`. A prefix that still contains the line the SyntaxError was reported on fails the same way, so each failed attempt now jumps to just above the reported line. On ~1,500 randomly broken stdlib files (Python 3.11, 3.12 and 3.13) the salvaged tree is identical to the old one every time. The 197KB case now takes 4ms. Node.walk is also iterative now. The recursive generator passed every node up through one frame per ancestor, which costs O(nodes * depth); on deeply nested input (40 lines of 300-term sums) explain() goes from 7.3s to 5.4s. On ordinary code the difference is within noise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UigC7feaY8NkAy9oay3GDW
# Conflicts: # explain_my_code/adapters/python.py
ethanzhoucool
marked this pull request as ready for review
November 3, 2026 17:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When pasted Python has a syntax error, the adapter salvages the longest prefix that still parses ("Broken code still explains" in the README).
_parse_longest_prefixdid that by dropping one line per attempt and callingast.parseagain. An error near the top of a large file therefore re-parsed it about once per line, so the work grew with the square of the file length.Measured locally with Python 3.13,
explain()end to end:(on line 1/v1/explain/all-levels, the endpoint the web UI calls, runsexplain()three times, so a broken 200 KB paste used to cost about 20 s of CPU on the single worker.Changes
adapters/python.py: a prefix that still contains the line theSyntaxErrorwas reported on fails the same way. So each failed attempt now jumps to just above the reported line instead of retrying one line shorter. The first attempt starts from the original error's line, which saves a parse. When the error is reported past the end of the prefix (e.g. "expected an indented block" at EOF), it falls back to dropping one line.ir.py:Node.walkis now an explicit-stack pre-order traversal instead of a recursive generator. The generator passed every node up through oneyield fromframe per ancestor, which is O(nodes × depth), and the analysis walks the tree a few dozen times per request. Measured: on deeply nested input (40 lines of 300-terma + a + ..., 48 KB)explain()goes from 7.3 s to 5.4 s. On ordinary code the difference is within noise (0.331 s → 0.326 s on 195 KB of stdlib). I'm including it for the deep case and because it no longer depends on generator recursion depth.Testing
pyteston Python 3.13, 3.12 and 3.11: 154 passed, 3 skipped.ruff check explain_my_code testsis clean.ast.dumpon the result. That was 477 broken files on 3.13, 515 on 3.12 and 526 on 3.11. Every one matched. The old salvage took 45–62 s per run and the new one 0.9–1.4 s.test_salvaging_a_large_broken_file_does_not_reparse_it_line_by_linecountsast.parsecalls (at most 3 for a 6,000-line file broken on line 1; it fails onmain). It counts calls rather than timing them, so it can't flake.test_broken_python_keeps_everything_above_the_errorcovers 5 error shapes, including an unterminated string and a missing body at EOF. It passes before and after, to pin the behaviour.test_walk_is_pre_orderchecks the new walk against a recursive reference.Notes
The remaining cost on deep input is
ast.unparse._repr()unparses a node's whole subtree and only then truncates it to 24–64 characters, so a longa + b + c + ...chain is quadratic. Fixing that properly means a length-bounded unparser, and the stdlib's unparser is private API that moved in 3.14 (ast._Unparserbecame_ast_unparse.Unparser). I left it out rather than build on it.Merge note: #2 also touches
_parse_longest_prefix, making it skip prefixes that raiseMemoryError/RecursionError. Whichever PR lands second gets a small conflict there. To resolve it, keep this PR's loop and addexcept (MemoryError, RecursionError): end -= 1after theSyntaxErrorbranch.https://claude.ai/code/session_01UigC7feaY8NkAy9oay3GDW
Generated by Claude Code