format.py, undo.py, replace.py, searchengine.py, iomenu.py, runscript.py, zoomheight.py, CPython main 993a0c65a7b.
Tracked in the umbrella issue python/cpython#154760 — one of 69 idlelib findings from a two-pass review with code-review-toolkit.
Each numbered heading below is one finding and has its own anchor, so the umbrella table links straight to it. The numbers are row numbers in that table, not identifiers — when referring to one of these, cite #154760 and the row, not the bare number.
Do not start work on a finding until a maintainer has cleared it in #154760. Every row there starts at Under analysis, which means not yet cleared.
AI disclaimer: these reports were drafted with Claude Code. Every cited line was re-read in the tree, every "Verified" block was produced by running the code, and the prior-art search was run against the full python/cpython tracker before publishing.
How to read a finding. Where is the line as of the commit above and will drift. Guarded twin is a place in idlelib that already handles the same situation correctly — it is both the evidence that this is a defect by the project's own standard and the shape of the fix. Prior art records what the tracker already says.
FIX · reproduced · shape guard-rechecks-call-receiver
Where: replace.py:226-229
What happens. m = prog.match(chars, col) is followed by if not prog: — but prog was already proven non-None at :201, so the guard is dead and m is never checked. With Regular expression on, Find \Z, Replace: search_forward matches the line without its newline and succeeds, do_replace re-matches WITH the newline, m is None, and m.expand() raises AttributeError into the Tk callback. Any \Z-anchored pattern triggers it.
Guarded twin. replace.py:152 (replace_all) and replace.py:186-189 (do_find) both guard the match correctly. Three near-identical guards, one misspelled.
Suggested fix. if not m:
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
FIX · reproduced · shape mirrored-direction-handles-fewer-cases
Where: searchengine.py:146-151
What happens. A match failing the ok or m.end() > col test falls through to line = line + 1, skipping the remainder of the line; the backward twin delegates to search_reverse, which scans the whole prefix. Replace All \w* -> <> on aaa bbb yields <> bbb; [0-9]* -> # on a1b2c3 yields #a1b2c3. Silent wrong output, no error.
Guarded twin. search_backward via search_reverse (searchengine.py:189-210).
Suggested fix. Continue scanning the line from m.end() instead of advancing to the next line.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
Note. The test suite DOCUMENTS this bug: test_searchengine.py:309-311 has the assertion commented out with 'seems buggy - tjr'. The backward twin's equivalent case is asserted and passes.
FIX · confirmed by reading · shape cleanup-only-on-success-path
Where: zoomheight.py:66-105
What happens. get_max_height_and_y_coord maximizes the window to measure it and restores state in the last two statements rather than a finally. get_window_geometry regex-matches wm_geometry() and does m.groups() unguarded, so a WM whose geometry string does not match raises AttributeError; any of three top.update() calls can raise TclError. Either way the window is left zoomed, possibly with a deliberately mangled geometry, and the cache at :102 is never populated — so the next Alt+2 re-maximizes it again, every time.
Guarded twin. sidebar.py:57-64 temp_enable_text_widget — a contextmanager restoring state in finally.
Suggested fix. try/finally around 69-102 restoring geometry and wm_state; guard get_window_geometry against a non-matching pattern.
Prior art — novel; the change that introduced it is identified. The whole probe, including WmInfoGatheringError and the success-path-only restore, arrived with Tal Einat's GH-13678 for #81220 [CLOSED] 'IDLE: Improve zoomheight doc and behavior' (bpo-37039, 2019-06-17). That issue's reporters described 'Restore Height has no effect', which is this symptom; the fix addressed measurement, and left the exception path unwound.
CONSIDER · reproduced · shape cleanup-only-on-success-path
Where: iomenu.py:360-375
What happens. print_window writes unsaved buffers to a temp file, then both pipe.close() and the os.unlink cleanup sit on the success path. The print command is user-editable config: setting print-command-posix to a value without %s makes line 333 raise TypeError, so every Ctrl+P leaves another /tmp/IDLE_tmp_* containing the user's unsaved source. A UnicodeDecodeError from pipe.read() leaks an fd and an unreaped child too.
Guarded twin. None in idlelib — no sibling does this correctly.
Suggested fix. try/finally around 322-346 for the unlink; with os.popen(...) for the pipe.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
CONSIDER · reproduced · shape index-computed-before-a-mutation-used-after-it
Where: replace.py:172-188
What happens. replace_all computes first/last from the original m.span() and passes the stale last to show_hit after the text length changed. On abc, Find b, Replace XYZ: Replace All leaves the selection 'X', while the do_replace path on identical input selects 'XYZ'. Shorter replacements spill the selection into following text.
Guarded twin. do_replace ends with show_hit(first, text.index('insert')) — the actual end of inserted text.
Suggested fix. Use text.index('insert') for the end, as do_replace does.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
Note. test_replace.py:285-286 asserts the correct behaviour for the do_replace path; test_replace_all never checks the selection.
CONSIDER · reproduced · shape except-exception-too-broad
Where: searchengine.py:85-89
What happens. Pattern a{4294967296} raises OverflowError out of getprog, past every caller: search.py:73,96, replace.py:132,183,200 and grep.py:137. The invariant in test_searchengine.py:180-184 is that a bad pattern is reported to the user via report_error, not raised.
Guarded twin. The re.PatternError branch immediately above it.
Suggested fix. Catch the wider set of exceptions re.compile can raise.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
CONSIDER · confirmed by reading · shape commit-side-effect-outside-the-success-guard
Where: iomenu.py:253,261
What happens. save_as and save_a_copy call updaterecentfileslist outside their success guards, unlike the loadfile twin which reaches its call only after return False-ing on every error path. A cancelled Save As rewrites recent-files.lst; a failed write adds the 0-byte truncated file as entry #1, and the os.path.exists prune at editor.py:960 will not remove it.
Guarded twin. iomenu.py:179 (loadfile).
Suggested fix. Move the call inside the success branch, as loadfile does.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
FIX · reproduced · shape per-line-property-derived-from-the-aggregate
Where: format.py:59, format.py:161
What happens. get_comment_header is re.match(r"^([ \t]*#*)", line) — anchored — so on a multi-line selection it answers for line 1 only; reformat_comment:161 then strips that width off EVERY line. Verified by execution against the 3.14 build: '# Explain the next line.\nresult = compute(a, b)' becomes '# Explain the next line. esult = compute(a, b)' (the 'r' is gone), and ' # Indented note.\n value = 1' loses the 'v'. The result is written back with text.delete/text.insert, so the user's source is corrupted with nothing raised.
Verified:
in : '# Explain the next line.\nresult = compute(a, b)'
out: '# Explain the next line. esult = compute(a, b)' <- 'r' gone
in : ' # Indented note.\n value = 1'
out: ' # Indented note. alue = 1' <- 'v' gone
Guarded twin. The no-selection branch: find_paragraph:101-112 walks outward only while get_comment_header(line) == comment_header, so every line it passes on is guaranteed to carry the header. The selection branch never establishes that invariant.
Suggested fix. Derive the header per line, or refuse to treat a mixed selection as a comment block.
Prior art — an open issue covers the area, not this defect. The SYMPTOM was reported on Discourse ('mixes two lines, a syntax error occurs when it runs') and answered with a documentation change: #143774 / PR #143775 (merged 2026-01-20) rewrote the Format Paragraph entry to say 'Avoid code lines' and added a Format block section. Nobody in that thread describes CHARACTER DELETION, and the root cause — an anchored regex answering for line 1 and the width then stripped off every line — was never diagnosed. Report the mechanism and the silent data loss, not the symptom.
https://discuss.python.org/t/idle-format-paragraph/105560 python/cpython#143774 python/cpython#143775
FIX · confirmed by reading · shape cleanup-only-on-success-path
Where: replace.py:161, replace.py:233, format.py:71, format.py:258
What happens. All four sites call undo_block_start(), then do text.delete/insert work, then call undo_block_stop() — with no try/finally, so an exception in between leaks UndoDelegator.undoblock. The leaked block freezes pointer and saved, so Ctrl-Z stops working AND maybesave() can return 'yes': the window then closes without ever prompting for the unsaved buffer.
Guarded twin. pyshell.py:1329 recall() — undo_block_start() followed immediately by try:.
Suggested fix. Wrap each body in try/finally, as recall() already does.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
CONSIDER · confirmed by reading · shape unguarded-inverse-of-guarded-operation
Where: undo.py:104
What happens. undo_block_start tests if self.undoblock == 0 before touching it; undo_block_stop calls self.undoblock.bump_depth(-1) unconditionally, so an unbalanced stop raises AttributeError on an int rather than being a no-op. This compounds 0045: the first leaked block turns the next mismatched stop into a second, differently-shaped failure.
Guarded twin. undo.py:100, the start half of the same pair, three lines above.
Suggested fix. Give stop the same == 0 guard, or make the sentinel a real object.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
FIX · confirmed by reading · shape asymmetric-encode-decode-pair
Where: iomenu.py:324-326
What happens. When encode(enc) fails, the fallback is return chars.encode('utf-8-sig') — a UTF-8 BOM — while the now-incorrect coding cookie is left in the text. The resulting file declares one encoding, starts with the BOM of another, and can no longer be imported, run, or reopened; IDLE's own 'Specify file encoding' recovery cannot fix it either, because that path also reads the cookie. The user is told 'Saving as UTF-8' and not that the file has been made unloadable.
Guarded twin. The ascii fast path at iomenu.py:280-283, which returns only when the round trip is known to hold.
Suggested fix. Rewrite or remove the cookie when falling back, and do not emit a BOM into a cookie'd file.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
CONSIDER · confirmed by reading · shape mirrored-direction-handles-fewer-cases
Where: format.py:340, format.py:325-330
What happens. untabify_region_event calls line.expandtabs(tabwidth) on the entire line, so a literal tab inside a string literal is expanded to spaces — silently changing what the program prints. Its mirror, tabify_region_event, computes the indent with get_line_indent and preserves line[raw:] untouched. The tests never round-trip the pair, so the asymmetry is invisible to the suite.
Guarded twin. format.py:325-330, the tabify half of the same pair.
Suggested fix. Expand tabs only in the leading indent, as tabify does.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
CONSIDER · confirmed by reading · shape mirrored-direction-handles-fewer-cases
Where: undo.py:294
What happens. text.insert(self.index1, self.chars) restores the characters but not the tags they carried. In an editor window nothing re-tags but nothing reads the tags either; in the Shell, pyshell.recall and ShellSidebar both key off exactly those tags, so undoing a deletion leaves text that renders correctly and behaves as if it were not shell output.
Guarded twin. InsertCommand, whose undo/redo pair does round-trip through the same text.
Suggested fix. Capture and restore the tag ranges alongside self.chars.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
FIX · confirmed by reading · shape unchecked-no-op-sentinel
Where: runscript.py:189, runscript.py:194
What happens. Both call sites invoke self.editwin.io.save(None) for effect and discard the result, then return the filename. A save that failed — read-only file, full disk — therefore still yields a filename, and F5 runs the STALE contents from disk under a RESTART banner, so the user watches output from code they believe they just edited.
Guarded twin. iomenu.py:198, which re-checks get_saved() after the same call.
Suggested fix. Test the return value, or re-check get_saved() before running.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.