Skip to content

Instantly share code, notes, and snippets.

@devdanzin
Last active July 27, 2026 08:46
Show Gist options
  • Select an option

  • Save devdanzin/0e2d9434d8d40cc5dc4f73b22547d8ef to your computer and use it in GitHub Desktop.

Select an option

Save devdanzin/0e2d9434d8d40cc5dc4f73b22547d8ef to your computer and use it in GitHub Desktop.
idlelib #154760: IDLE editor: formatting, undo, search/replace, save and run

IDLE editor: formatting, undo, search/replace, save and run

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.


3 — Replace's NULL-guard tests the call receiver instead of the match

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.


5 — Forward search abandons the rest of a line after a zero-width match

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.


9 — A failed window probe leaves the user's window stuck maximized

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.

python/cpython#81220


11 — A bad print command orphans the user's unsaved source in /tmp

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.


19 — Replace All's final selection uses the pre-replacement span

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.


20 — getprog catches only re.PatternError, so OverflowError escapes to every caller

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.


24 — Recent-files list is updated outside the success guard

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.


35 — Format Paragraph silently deletes characters from every non-comment line of a selection

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


45 — undo_block_start/stop is unpaired on the exception path in four places

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.


46 — undo_block_stop does not guard the sentinel its own twin guards

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.


60 — Saving a file whose coding cookie cannot encode its text writes a BOM and keeps the cookie

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.


64 — Untabify rewrites the whole line where its tabify mirror rewrites only the indent

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.


65 — DeleteCommand.undo reinserts text without tags where the Insert mirror round-trips them

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.


67 — Run Module ignores the return of io.save(), so a failed save runs the old code from disk

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.


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment