https://github.com/python/cpython/commit/694e73788c4a17c9409210d7f318ecbfe6e34bb3
commit: 694e73788c4a17c9409210d7f318ecbfe6e34bb3
branch: 3.13
author: Miss Islington (bot) <[email protected]>
committer: terryjreedy <[email protected]>
date: 2026-09-27T01:37:37Z
summary:

[3.13] gh-153480: Stop IDLE crashing when a file open in the editor is deleted 
(GH-153481) (#158263)

gh-153480: Stop IDLE crashing when a file open in the editor is deleted 
(GH-153481)

EditorWindow.focus_in_event calls last_mtime on every <FocusIn> 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.

---------
(cherry picked from commit 6af40a682881f8442e1bdae67d34b186ef42826a)

Co-authored-by: tonghuaroot (童话) <[email protected]>
Co-authored-by: Serhiy Storchaka <[email protected]>

files:
A Misc/NEWS.d/next/IDLE/2026-07-10-19-41-00.gh-issue-153480.EdMt1x.rst
M Lib/idlelib/editor.py
M Lib/idlelib/idle_test/test_editor.py

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.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to