From 5a78710a65cbf248278fa673dc63246584e4ae47 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Fri, 10 Jul 2026 10:47:51 +0800 Subject: [PATCH 1/5] gh-153480: Stop IDLE crashing when a file open in the editor is deleted 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. --- Lib/idlelib/editor.py | 8 ++++++- Lib/idlelib/idle_test/test_editor.py | 23 +++++++++++++++++++ ...-07-10-19-41-00.gh-issue-153480.EdMt1x.rst | 2 ++ 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py index a040d791bdeb528..915ce7c6295b08d 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -1118,7 +1118,13 @@ def _close(self): def last_mtime(self): file = self.io.filename - return os.path.getmtime(file) if file else 0 + if not file: + return 0 + try: + return os.path.getmtime(file) + except OSError: + # File gone or inaccessible: keep the last known mtime. + return self.mtime def focus_in_event(self, event): mtime = self.last_mtime() diff --git a/Lib/idlelib/idle_test/test_editor.py b/Lib/idlelib/idle_test/test_editor.py index e28ee549f180aa0..b6110ad06eeef8a 100644 --- a/Lib/idlelib/idle_test/test_editor.py +++ b/Lib/idlelib/idle_test/test_editor.py @@ -1,6 +1,9 @@ "Test editor, coverage 53%." from idlelib import editor +import os +import tempfile +import types import unittest from collections import namedtuple from test.support import requires @@ -237,5 +240,25 @@ def test_rclick(self): pass +class LastMtimeTest(unittest.TestCase): + # Exercise last_mtime as an unbound method on a stub; no GUI needed. + + def test_deleted_file_does_not_raise(self): + with tempfile.TemporaryDirectory() as d: + p = os.path.join(d, 'gone.py') + open(p, 'w').close() + mtime = os.path.getmtime(p) + os.remove(p) + stub = types.SimpleNamespace( + io=types.SimpleNamespace(filename=p), mtime=mtime) + # Must not raise; returns the last known mtime. + self.assertEqual(Editor.last_mtime(stub), mtime) + + def test_no_filename_returns_zero(self): + stub = types.SimpleNamespace( + io=types.SimpleNamespace(filename=None), mtime=123) + self.assertEqual(Editor.last_mtime(stub), 0) + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/Misc/NEWS.d/next/Library/2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst b/Misc/NEWS.d/next/Library/2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst new file mode 100644 index 000000000000000..0147c300b772644 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst @@ -0,0 +1,2 @@ +IDLE no longer crashes with a traceback when a file open in the editor is +deleted or renamed by another program and the editor window is refocused. From a57c59b1b9d9a7f70aa173c4647628290b7710c4 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Thu, 17 Sep 2026 12:52:00 +0800 Subject: [PATCH 2/5] gh-153480: Return None from last_mtime() and handle a deleted file last_mtime() now returns None for a missing or un-stat-able file, which also fixes the startup path where it runs before self.mtime is set. The vanished-file case now offers Close, Save As or Ignore instead of a reload that could only fail. Tests updated; NEWS moved to IDLE/. --- Lib/idlelib/editor.py | 35 +++++++-- Lib/idlelib/idle_test/test_editor.py | 71 ++++++++++++++++--- ...-07-10-19-41-00.gh-issue-153480.EdMt1x.rst | 4 ++ ...-07-10-19-41-00.gh-issue-153480.EdMt1x.rst | 2 - 4 files changed, 95 insertions(+), 17 deletions(-) create mode 100644 Misc/NEWS.d/next/IDLE/2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst delete mode 100644 Misc/NEWS.d/next/Library/2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py index 4ed530efe63ea65..707422abe7bd189 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -1117,24 +1117,48 @@ def _close(self): def last_mtime(self): file = self.io.filename if not file: - return 0 + return None try: return os.path.getmtime(file) except OSError: - # File gone or inaccessible: keep the last known mtime. - return self.mtime + # 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. + 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) + else: + self.mtime = None + self.set_saved(False) + def load_extensions(self): self.extensions = {} self.load_standard_extensions() @@ -1728,3 +1752,4 @@ def _editor_window(root): # htest # from idlelib.idle_test.htest import run run(_editor_window) + diff --git a/Lib/idlelib/idle_test/test_editor.py b/Lib/idlelib/idle_test/test_editor.py index 864fcc2781f2bf6..668d936882aa794 100644 --- a/Lib/idlelib/idle_test/test_editor.py +++ b/Lib/idlelib/idle_test/test_editor.py @@ -5,6 +5,7 @@ import tempfile import types import unittest +from pathlib import Path from collections import namedtuple from unittest import mock from test.support import requires @@ -307,22 +308,72 @@ def test_rmenu_check_copy(self): class LastMtimeTest(unittest.TestCase): # Exercise last_mtime as an unbound method on a stub; no GUI needed. - def test_deleted_file_does_not_raise(self): + 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') - open(p, 'w').close() - mtime = os.path.getmtime(p) + Path(p).touch() os.remove(p) - stub = types.SimpleNamespace( - io=types.SimpleNamespace(filename=p), mtime=mtime) - # Must not raise; returns the last known mtime. - self.assertEqual(Editor.last_mtime(stub), mtime) + stub = types.SimpleNamespace(io=types.SimpleNamespace(filename=p)) + self.assertIsNone(Editor.last_mtime(stub)) - def test_no_filename_returns_zero(self): + 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=None), mtime=123) - self.assertEqual(Editor.last_mtime(stub), 0) + 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): + self.assertTrue(self._run_choice(0).close.called) + + def test_save_as_choice_saves(self): + self._run_choice(1).io.save_as.assert_called_once_with('event') + + def test_ignore_choice_marks_unsaved(self): + stub = self._run_choice(2) + self.assertIsNone(stub.mtime) + stub.set_saved.assert_called_once_with(False) 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..ce44246734f3f6a --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst @@ -0,0 +1,4 @@ +IDLE no longer fails with a traceback when a file open in the editor is +deleted or renamed by another program; refocusing the window now offers to +close it, save the buffer as a new file, or keep editing. Opening a +not-yet-created file no longer prevents IDLE from starting. diff --git a/Misc/NEWS.d/next/Library/2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst b/Misc/NEWS.d/next/Library/2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst deleted file mode 100644 index 0147c300b772644..000000000000000 --- a/Misc/NEWS.d/next/Library/2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst +++ /dev/null @@ -1,2 +0,0 @@ -IDLE no longer crashes with a traceback when a file open in the editor is -deleted or renamed by another program and the editor window is refocused. From e84056b061dcf30097ebdd5ccc9b622f910b9b6a Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Thu, 17 Sep 2026 14:32:54 +0800 Subject: [PATCH 3/5] Fix end-of-file newline in editor.py and test_editor.py --- Lib/idlelib/editor.py | 1 - Lib/idlelib/idle_test/test_editor.py | 1 - 2 files changed, 2 deletions(-) diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py index 707422abe7bd189..4988808ca015588 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -1752,4 +1752,3 @@ def _editor_window(root): # htest # from idlelib.idle_test.htest import run run(_editor_window) - diff --git a/Lib/idlelib/idle_test/test_editor.py b/Lib/idlelib/idle_test/test_editor.py index 668d936882aa794..4c7608e168ffe76 100644 --- a/Lib/idlelib/idle_test/test_editor.py +++ b/Lib/idlelib/idle_test/test_editor.py @@ -376,4 +376,3 @@ def test_ignore_choice_marks_unsaved(self): if __name__ == '__main__': unittest.main(verbosity=2) - From ceffab243c8529dadb030fdcc4e7694f80cce50f Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Thu, 17 Sep 2026 18:37:52 +0800 Subject: [PATCH 4/5] gh-153480: Clear mtime in the deleted-file dialog before Save As A cancelled Save As kept the old mtime and reprompted on every focus. Forget the mtime for both Save As and Ignore; a successful Save As restores it via set_saved(True). Reword the NEWS per review. --- Lib/idlelib/editor.py | 7 ++++--- Lib/idlelib/idle_test/test_editor.py | 12 ++++++++---- .../2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst | 8 ++++---- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py index 4988808ca015588..0a6fd2c6595fdc6 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -1153,11 +1153,12 @@ def deleted_file_event(self, event): choice = dialog.go() if choice == 0: self.close() - elif choice == 1: - self.io.save_as(event) else: + # Forget the old mtime so a cancelled Save As does not reprompt; + # a successful Save As restores it via set_saved(True). self.mtime = None - self.set_saved(False) + if choice == 1: + self.io.save_as(event) def load_extensions(self): self.extensions = {} diff --git a/Lib/idlelib/idle_test/test_editor.py b/Lib/idlelib/idle_test/test_editor.py index 4c7608e168ffe76..b18226fa62670bb 100644 --- a/Lib/idlelib/idle_test/test_editor.py +++ b/Lib/idlelib/idle_test/test_editor.py @@ -365,13 +365,17 @@ def _run_choice(self, choice): def test_close_choice_closes_window(self): self.assertTrue(self._run_choice(0).close.called) - def test_save_as_choice_saves(self): - self._run_choice(1).io.save_as.assert_called_once_with('event') + 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_marks_unsaved(self): + def test_ignore_choice_clears_mtime(self): stub = self._run_choice(2) self.assertIsNone(stub.mtime) - stub.set_saved.assert_called_once_with(False) + stub.io.save_as.assert_not_called() + stub.set_saved.assert_not_called() if __name__ == '__main__': 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 index ce44246734f3f6a..e00c76fc2d0d6f0 100644 --- 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 @@ -1,4 +1,4 @@ -IDLE no longer fails with a traceback when a file open in the editor is -deleted or renamed by another program; refocusing the window now offers to -close it, save the buffer as a new file, or keep editing. Opening a -not-yet-created file no longer prevents IDLE from starting. +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 9cabce6d58aec0230b639860d865c319cad5cf10 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Wed, 23 Sep 2026 09:41:40 +0800 Subject: [PATCH 5/5] gh-153480: Clear mtime before the deleted-file dialog for all choices With a modified buffer, Close asks whether to save and the queued FocusIn was delivered inside that message box, reopening the dialog. Forget the mtime before showing the dialog (not only for Save As/Ignore); a successful Save As restores it via set_saved(True). --- Lib/idlelib/editor.py | 14 +++++++------- Lib/idlelib/idle_test/test_editor.py | 5 ++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py index 0a6fd2c6595fdc6..b4677c6153ecc8a 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -1142,7 +1142,11 @@ def focus_in_event(self, event): 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. + # 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', @@ -1153,12 +1157,8 @@ def deleted_file_event(self, event): choice = dialog.go() if choice == 0: self.close() - else: - # Forget the old mtime so a cancelled Save As does not reprompt; - # a successful Save As restores it via set_saved(True). - self.mtime = None - if choice == 1: - self.io.save_as(event) + elif choice == 1: + self.io.save_as(event) def load_extensions(self): self.extensions = {} diff --git a/Lib/idlelib/idle_test/test_editor.py b/Lib/idlelib/idle_test/test_editor.py index b18226fa62670bb..9c8ec0f9bacb713 100644 --- a/Lib/idlelib/idle_test/test_editor.py +++ b/Lib/idlelib/idle_test/test_editor.py @@ -363,7 +363,10 @@ def _run_choice(self, choice): return stub def test_close_choice_closes_window(self): - self.assertTrue(self._run_choice(0).close.called) + 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)