Skip to content

Commit cbc7753

Browse files
miss-islingtontonghuarootserhiy-storchaka
authored
[3.14] gh-153480: Stop IDLE crashing when a file open in the editor is deleted (GH-153481) (#158262)
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 6af40a6) Co-authored-by: tonghuaroot (童话) <tonghuaroot@gmail.com> Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
1 parent c2d1138 commit cbc7753

3 files changed

Lines changed: 118 additions & 3 deletions

File tree

‎Lib/idlelib/editor.py‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,19 +1120,50 @@ def _close(self):
11201120

11211121
def last_mtime(self):
11221122
file = self.io.filename
1123-
return os.path.getmtime(file) if file else 0
1123+
if not file:
1124+
return None
1125+
try:
1126+
return os.path.getmtime(file)
1127+
except OSError:
1128+
# File is gone or cannot be stat'ed.
1129+
return None
11241130

11251131
def focus_in_event(self, event):
11261132
mtime = self.last_mtime()
1127-
if self.mtime != mtime:
1133+
if mtime == self.mtime:
1134+
return
1135+
if self.mtime is not None and mtime is None:
1136+
# The file was there and is now gone; reloading cannot work.
1137+
self.deleted_file_event(event)
1138+
else:
11281139
self.mtime = mtime
1129-
if self. askyesno(
1140+
if self.askyesno(
11301141
'Reload', '"%s"\n\nThis script has been modified by another program.'
11311142
'\nDo you want to reload it?' % self.io.filename, parent=self.text):
11321143
self.io.loadfile(self.io.filename)
11331144
else:
11341145
self.set_saved(False)
11351146

1147+
def deleted_file_event(self, event):
1148+
# The file was deleted or renamed while open; ask what to do with the
1149+
# buffer instead of offering a reload that could only fail. Forget the
1150+
# old mtime before showing the dialog so a FocusIn delivered while this
1151+
# dialog (or a Close/Save As sub-dialog) is open does not reopen it; a
1152+
# successful Save As restores it via set_saved(True).
1153+
self.mtime = None
1154+
dialog = simpledialog.SimpleDialog(
1155+
self.text,
1156+
title='File Deleted',
1157+
text='"%s"\n\nThis file no longer exists.' % self.io.filename,
1158+
buttons=('Close', 'Save As', 'Ignore'),
1159+
default=1,
1160+
cancel=2)
1161+
choice = dialog.go()
1162+
if choice == 0:
1163+
self.close()
1164+
elif choice == 1:
1165+
self.io.save_as(event)
1166+
11361167
def load_extensions(self):
11371168
self.extensions = {}
11381169
self.load_standard_extensions()

‎Lib/idlelib/idle_test/test_editor.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
"Test editor, coverage 53%."
22

33
from idlelib import editor
4+
import os
5+
import tempfile
6+
import types
47
import unittest
8+
from pathlib import Path
59
from collections import namedtuple
610
from unittest import mock
711
from test.support import requires
@@ -321,5 +325,81 @@ def test_rmenu_check_copy(self):
321325
eq(self.window.rmenu_check_cut(), 'normal')
322326

323327

328+
class LastMtimeTest(unittest.TestCase):
329+
# Exercise last_mtime as an unbound method on a stub; no GUI needed.
330+
331+
def test_existing_file_returns_mtime(self):
332+
with tempfile.TemporaryDirectory() as d:
333+
p = os.path.join(d, 'f.py')
334+
Path(p).touch()
335+
stub = types.SimpleNamespace(io=types.SimpleNamespace(filename=p))
336+
self.assertEqual(Editor.last_mtime(stub), os.path.getmtime(p))
337+
338+
def test_deleted_file_returns_none(self):
339+
with tempfile.TemporaryDirectory() as d:
340+
p = os.path.join(d, 'gone.py')
341+
Path(p).touch()
342+
os.remove(p)
343+
stub = types.SimpleNamespace(io=types.SimpleNamespace(filename=p))
344+
self.assertIsNone(Editor.last_mtime(stub))
345+
346+
def test_not_yet_created_filename(self):
347+
# __init__ calls last_mtime() before self.mtime is set, so last_mtime()
348+
# must not read self.mtime (the stub has no mtime attribute).
349+
stub = types.SimpleNamespace(
350+
io=types.SimpleNamespace(filename='/no/such/file.py'))
351+
self.assertIsNone(Editor.last_mtime(stub))
352+
353+
def test_no_filename_returns_none(self):
354+
stub = types.SimpleNamespace(io=types.SimpleNamespace(filename=None))
355+
self.assertIsNone(Editor.last_mtime(stub))
356+
357+
358+
class DeletedFileEventTest(unittest.TestCase):
359+
# Exercise the deleted-file handling as unbound methods; dialog is mocked.
360+
361+
def make_stub(self):
362+
return types.SimpleNamespace(
363+
mtime=1.0,
364+
text=None,
365+
io=types.SimpleNamespace(filename='/gone.py', save_as=mock.Mock()),
366+
close=mock.Mock(),
367+
set_saved=mock.Mock(),
368+
deleted_file_event=mock.Mock(),
369+
askyesno=mock.Mock(),
370+
last_mtime=lambda: None)
371+
372+
def test_focus_in_routes_deleted_to_dialog(self):
373+
stub = self.make_stub()
374+
Editor.focus_in_event(stub, 'event')
375+
stub.deleted_file_event.assert_called_once_with('event')
376+
stub.askyesno.assert_not_called()
377+
378+
def _run_choice(self, choice):
379+
stub = self.make_stub()
380+
with mock.patch.object(editor.simpledialog, 'SimpleDialog') as SD:
381+
SD.return_value.go.return_value = choice
382+
Editor.deleted_file_event(stub, 'event')
383+
return stub
384+
385+
def test_close_choice_closes_window(self):
386+
stub = self._run_choice(0)
387+
self.assertTrue(stub.close.called)
388+
# mtime is cleared before Close so the queued FocusIn does not reprompt.
389+
self.assertIsNone(stub.mtime)
390+
391+
def test_save_as_choice_clears_mtime_and_saves(self):
392+
stub = self._run_choice(1)
393+
stub.io.save_as.assert_called_once_with('event')
394+
# A cancelled Save As leaves mtime None so it does not reprompt.
395+
self.assertIsNone(stub.mtime)
396+
397+
def test_ignore_choice_clears_mtime(self):
398+
stub = self._run_choice(2)
399+
self.assertIsNone(stub.mtime)
400+
stub.io.save_as.assert_not_called()
401+
stub.set_saved.assert_not_called()
402+
403+
324404
if __name__ == '__main__':
325405
unittest.main(verbosity=2)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fix IDLE failing to start when opening a file which does not exist yet.
2+
Fix a traceback when a file open in the IDLE editor is deleted by another
3+
program; IDLE now asks whether to close the window, save the file elsewhere,
4+
or ignore it.

0 commit comments

Comments
 (0)