Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions Lib/idlelib/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1116,19 +1116,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.
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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With a modified buffer, Close asks whether to save, and the FocusIn event queued when this dialog closed is delivered inside that message box, so the dialog is shown a second time (and currently crashes on the message box's grab, a separate tkinter bug). Set self.mtime = None before showing the dialog, for all choices; a successful Save As sets it again.

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)

def load_extensions(self):
self.extensions = {}
self.load_standard_extensions()
Expand Down
77 changes: 77 additions & 0 deletions Lib/idlelib/idle_test/test_editor.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -301,5 +305,78 @@ 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):
self.assertTrue(self._run_choice(0).close.called)

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)
Original file line number Diff line number Diff line change
@@ -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.
Loading