From 6af40a682881f8442e1bdae67d34b186ef42826a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?tonghuaroot=20=28=E7=AB=A5=E8=AF=9D=29?= Date: Sun, 27 Sep 2026 09:12:32 +0800 Subject: [PATCH 1/4] gh-153480: Stop IDLE crashing when a file open in the editor is deleted (#153481) EditorWindow.focus_in_event calls last_mtime on every event, which passed the open file path to os.path.getmtime with no guard. When another program deleted or renamed the file, refocusing the IDLE window let a FileNotFoundError escape the Tk event handler. last_mtime now returns the last known mtime when the path is inaccessible, so refocusing no longer raises and does not prompt to reload a file that is gone. See PR discussion for more. --------- Co-authored-by: Serhiy Storchaka --- Lib/idlelib/editor.py | 37 ++++++++- Lib/idlelib/idle_test/test_editor.py | 80 +++++++++++++++++++ ...-07-10-19-41-00.gh-issue-153480.EdMt1x.rst | 4 + 3 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/IDLE/2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py index 1d5e181f42f7645..95ae8f450fa73d7 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -1120,19 +1120,50 @@ def _close(self): def last_mtime(self): file = self.io.filename - return os.path.getmtime(file) if file else 0 + if not file: + return None + try: + return os.path.getmtime(file) + except OSError: + # File is gone or cannot be stat'ed. + return None def focus_in_event(self, event): mtime = self.last_mtime() - if self.mtime != mtime: + if mtime == self.mtime: + return + if self.mtime is not None and mtime is None: + # The file was there and is now gone; reloading cannot work. + self.deleted_file_event(event) + else: self.mtime = mtime - if self. askyesno( + if self.askyesno( 'Reload', '"%s"\n\nThis script has been modified by another program.' '\nDo you want to reload it?' % self.io.filename, parent=self.text): self.io.loadfile(self.io.filename) else: self.set_saved(False) + def deleted_file_event(self, event): + # The file was deleted or renamed while open; ask what to do with the + # buffer instead of offering a reload that could only fail. Forget the + # old mtime before showing the dialog so a FocusIn delivered while this + # dialog (or a Close/Save As sub-dialog) is open does not reopen it; a + # successful Save As restores it via set_saved(True). + self.mtime = None + dialog = simpledialog.SimpleDialog( + self.text, + title='File Deleted', + text='"%s"\n\nThis file no longer exists.' % self.io.filename, + buttons=('Close', 'Save As', 'Ignore'), + default=1, + cancel=2) + choice = dialog.go() + if choice == 0: + self.close() + elif choice == 1: + self.io.save_as(event) + def load_extensions(self): self.extensions = {} self.load_standard_extensions() diff --git a/Lib/idlelib/idle_test/test_editor.py b/Lib/idlelib/idle_test/test_editor.py index 2aaeb5037901b8f..78fe68b24921d81 100644 --- a/Lib/idlelib/idle_test/test_editor.py +++ b/Lib/idlelib/idle_test/test_editor.py @@ -1,7 +1,11 @@ "Test editor, coverage 53%." from idlelib import editor +import os +import tempfile +import types import unittest +from pathlib import Path from collections import namedtuple from unittest import mock from test.support import requires @@ -321,5 +325,81 @@ def test_rmenu_check_copy(self): eq(self.window.rmenu_check_cut(), 'normal') +class LastMtimeTest(unittest.TestCase): + # Exercise last_mtime as an unbound method on a stub; no GUI needed. + + def test_existing_file_returns_mtime(self): + with tempfile.TemporaryDirectory() as d: + p = os.path.join(d, 'f.py') + Path(p).touch() + stub = types.SimpleNamespace(io=types.SimpleNamespace(filename=p)) + self.assertEqual(Editor.last_mtime(stub), os.path.getmtime(p)) + + def test_deleted_file_returns_none(self): + with tempfile.TemporaryDirectory() as d: + p = os.path.join(d, 'gone.py') + Path(p).touch() + os.remove(p) + stub = types.SimpleNamespace(io=types.SimpleNamespace(filename=p)) + self.assertIsNone(Editor.last_mtime(stub)) + + def test_not_yet_created_filename(self): + # __init__ calls last_mtime() before self.mtime is set, so last_mtime() + # must not read self.mtime (the stub has no mtime attribute). + stub = types.SimpleNamespace( + io=types.SimpleNamespace(filename='/no/such/file.py')) + self.assertIsNone(Editor.last_mtime(stub)) + + def test_no_filename_returns_none(self): + stub = types.SimpleNamespace(io=types.SimpleNamespace(filename=None)) + self.assertIsNone(Editor.last_mtime(stub)) + + +class DeletedFileEventTest(unittest.TestCase): + # Exercise the deleted-file handling as unbound methods; dialog is mocked. + + def make_stub(self): + return types.SimpleNamespace( + mtime=1.0, + text=None, + io=types.SimpleNamespace(filename='/gone.py', save_as=mock.Mock()), + close=mock.Mock(), + set_saved=mock.Mock(), + deleted_file_event=mock.Mock(), + askyesno=mock.Mock(), + last_mtime=lambda: None) + + def test_focus_in_routes_deleted_to_dialog(self): + stub = self.make_stub() + Editor.focus_in_event(stub, 'event') + stub.deleted_file_event.assert_called_once_with('event') + stub.askyesno.assert_not_called() + + def _run_choice(self, choice): + stub = self.make_stub() + with mock.patch.object(editor.simpledialog, 'SimpleDialog') as SD: + SD.return_value.go.return_value = choice + Editor.deleted_file_event(stub, 'event') + return stub + + def test_close_choice_closes_window(self): + stub = self._run_choice(0) + self.assertTrue(stub.close.called) + # mtime is cleared before Close so the queued FocusIn does not reprompt. + self.assertIsNone(stub.mtime) + + def test_save_as_choice_clears_mtime_and_saves(self): + stub = self._run_choice(1) + stub.io.save_as.assert_called_once_with('event') + # A cancelled Save As leaves mtime None so it does not reprompt. + self.assertIsNone(stub.mtime) + + def test_ignore_choice_clears_mtime(self): + stub = self._run_choice(2) + self.assertIsNone(stub.mtime) + stub.io.save_as.assert_not_called() + stub.set_saved.assert_not_called() + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/Misc/NEWS.d/next/IDLE/2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst b/Misc/NEWS.d/next/IDLE/2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst new file mode 100644 index 000000000000000..e00c76fc2d0d6f0 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst @@ -0,0 +1,4 @@ +Fix IDLE failing to start when opening a file which does not exist yet. +Fix a traceback when a file open in the IDLE editor is deleted by another +program; IDLE now asks whether to close the window, save the file elsewhere, +or ignore it. From 302ce2df352df1706325c6962af48567d0576289 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:27:46 +0200 Subject: [PATCH 2/4] gh-138912: Bump magic number post GH-144820 (#158258) --- Include/internal/pycore_magic_number.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Include/internal/pycore_magic_number.h b/Include/internal/pycore_magic_number.h index b6945f2bc5f6e0d..15d3ad6f3a336b7 100644 --- a/Include/internal/pycore_magic_number.h +++ b/Include/internal/pycore_magic_number.h @@ -303,6 +303,7 @@ Known values: Python 3.16a1 3703 (Replace DELETE_GLOBAL with PUSH_NULL; STORE_GLOBAL) Python 3.16a1 3704 (Replace DELETE_ATTR with PUSH_NULL; STORE_ATTR) Python 3.16a1 3705 (Add INTRINSIC_ADD_CONDITIONAL_ANNOTATION) + Python 3.16a1 3706 (Add INTRINSIC_MATCH_CLASS_ISINSTANCE) Python 3.17 will start with 3750 @@ -312,7 +313,7 @@ Known values: */ -#define PYC_MAGIC_NUMBER 3705 +#define PYC_MAGIC_NUMBER 3706 /* This is equivalent to converting PYC_MAGIC_NUMBER to 2 bytes (little-endian) and then appending b'\r\n'. */ #define PYC_MAGIC_NUMBER_TOKEN \ From 042a7c150552a4ddb5a90cf65a6e5b9e7ba3f7dc Mon Sep 17 00:00:00 2001 From: Joshua Bronson Date: Sat, 26 Sep 2026 22:52:51 -0400 Subject: [PATCH 3/4] gh-158254: Detect keys changing during reverse dict iteration (GH-158255) A reverse dict iterator only checked that the dict's size was unchanged, so after the keys were replaced with the same number of keys it kept yielding entries for the new keys. It could yield more items than the dict had when it was created, and __length_hint__() then wrapped around. Add the check the forward iterators already have: finding an entry after the expected number of items raises "dictionary keys changed during iteration". Co-authored-by: Claude Opus 5.5 --- Lib/test/test_dict.py | 25 +++++++++++++++++++ ...-09-26-15-39-32.gh-issue-158254.qT7vRk.rst | 3 +++ Objects/dictobject.c | 6 +++++ 3 files changed, 34 insertions(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-09-26-15-39-32.gh-issue-158254.qT7vRk.rst diff --git a/Lib/test/test_dict.py b/Lib/test/test_dict.py index 673987733fc8c4f..3b9ff672a1aa035 100644 --- a/Lib/test/test_dict.py +++ b/Lib/test/test_dict.py @@ -1424,6 +1424,31 @@ def test_reversed_dict_after_clear_and_restore(self): for it in iterators: self.assertEqual(list(it), []) + def test_reversed_dict_keys_changed_during_iteration(self): + d = dict.fromkeys(range(10)) + for i in range(7): + del d[i] + + iterators = ( + reversed(d), + reversed(d.keys()), + reversed(d.values()), + reversed(d.items()), + ) + for it in iterators: + next(it) + + # Same size as before, but with different keys below + # the iterators' current position. + d.clear() + d.update(dict.fromkeys(range(10))) + for i in range(3, 10): + del d[i] + + for it in iterators: + with self.assertRaisesRegex(RuntimeError, 'keys changed'): + list(it) + def test_dict_copy_order(self): # bpo-34320 od = collections.OrderedDict([('a', 1), ('b', 2)]) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-26-15-39-32.gh-issue-158254.qT7vRk.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-26-15-39-32.gh-issue-158254.qT7vRk.rst new file mode 100644 index 000000000000000..21908eea4a8236b --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-26-15-39-32.gh-issue-158254.qT7vRk.rst @@ -0,0 +1,3 @@ +Reverse iterators over a :class:`dict` and its views now raise +:exc:`RuntimeError` if the dictionary's keys change during iteration, like +forward iterators, instead of yielding entries for the new keys. diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 9a469f88230f8b2..15377ac083c2b27 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -6303,6 +6303,12 @@ dictreviter_iter_lock_held(PyDictObject *d, PyObject *self) value = entry_ptr->me_value; } } + // We found an element, but did not expect it + if (di->len == 0) { + PyErr_SetString(PyExc_RuntimeError, + "dictionary keys changed during iteration"); + goto fail; + } di->di_pos = i-1; di->len--; From 421c411672710e29df4c027b6824df17586c9d51 Mon Sep 17 00:00:00 2001 From: Locked-chess-official <13140752715@163.com> Date: Sun, 27 Sep 2026 12:03:50 +0800 Subject: [PATCH 4/4] gh-135511 Fixed truncated exception message for Name- and AttributeError (#135526) Only the last line of multiline error messages was displayed. --------- Co-authored-by: Terry Jan Reedy Co-authored-by: Serhiy Storchaka --- Lib/idlelib/idle_test/test_run.py | 14 ++++++++++---- Lib/idlelib/run.py | 12 ++---------- .../2025-08-08-10-50-59.gh-issue-135511.9Rw5Zg.rst | 1 + 3 files changed, 13 insertions(+), 14 deletions(-) create mode 100644 Misc/NEWS.d/next/IDLE/2025-08-08-10-50-59.gh-issue-135511.9Rw5Zg.rst diff --git a/Lib/idlelib/idle_test/test_run.py b/Lib/idlelib/idle_test/test_run.py index 19adb325ec06f13..d7e69f70fd87d85 100644 --- a/Lib/idlelib/idle_test/test_run.py +++ b/Lib/idlelib/idle_test/test_run.py @@ -49,6 +49,7 @@ def __eq__(self, other): ('int.reel', AttributeError, "type object 'int' has no attribute 'reel'. " "Did you mean '.real' instead of '.reel'?\n"), # More in 3.15. + (r'raise NameError("123\n456")', NameError, "123\n456\n"), ) @force_not_colorized @@ -56,7 +57,7 @@ def test_get_message(self): for code, exc, msg in self.data: with self.subTest(code=code): try: - eval(compile(code, '', 'eval')) + exec(compile(code, '', 'exec')) except exc: typ, val, tb = sys.exc_info() actual = run.get_message_lines(typ, val, tb)[0] @@ -68,15 +69,20 @@ def test_get_message(self): new_callable=lambda: (lambda t, e: None)) def test_get_multiple_message(self, mock): d = self.data - data2 = ((d[0], d[1]), (d[1], d[2]), (d[2], d[0])) + data2 = ((d[0], d[1]), + (d[1], d[2]), + (d[2], d[3]), + (d[3], d[0]), + (d[1], d[3]), + (d[0], d[2])) subtests = 0 for (code1, exc1, msg1), (code2, exc2, msg2) in data2: with self.subTest(codes=(code1,code2)): try: - eval(compile(code1, '', 'eval')) + exec(compile(code1, '', 'exec')) except exc1: try: - eval(compile(code2, '', 'eval')) + exec(compile(code2, '', 'exec')) except exc2: with captured_stderr() as output: run.print_exception() diff --git a/Lib/idlelib/run.py b/Lib/idlelib/run.py index 48e8f3c4605fc07..5e04afe2ccc5c15 100644 --- a/Lib/idlelib/run.py +++ b/Lib/idlelib/run.py @@ -4,7 +4,6 @@ f'''{sys.executable} -c "__import__('idlelib.run').run.main()"''' '.run' is needed because __import__ returns idlelib, not idlelib.run. """ -import contextlib import functools import io import linecache @@ -244,15 +243,8 @@ def show_socket_error(err, address): def get_message_lines(typ, exc, tb): - "Return line composing the exception message." - if typ in (AttributeError, NameError): - # 3.10+ hints are not directly accessible from python (#44026). - err = io.StringIO() - with contextlib.redirect_stderr(err): - sys.__excepthook__(typ, exc, tb) - return [err.getvalue().split("\n")[-2] + "\n"] - else: - return traceback.format_exception_only(typ, exc) + "Return lines of the exception message, with any suggestion." + return list(traceback.TracebackException(typ, exc, tb).format_exception_only()) def print_exception(): diff --git a/Misc/NEWS.d/next/IDLE/2025-08-08-10-50-59.gh-issue-135511.9Rw5Zg.rst b/Misc/NEWS.d/next/IDLE/2025-08-08-10-50-59.gh-issue-135511.9Rw5Zg.rst new file mode 100644 index 000000000000000..10e59572b53746e --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2025-08-08-10-50-59.gh-issue-135511.9Rw5Zg.rst @@ -0,0 +1 @@ +Fix display of :exc:`NameError` and :exc:`AttributeError` with multi-line message.