From 39e61879e1c8ad035d31b8af723adf5195b8ca66 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Sat, 15 Nov 2025 00:56:08 +0300 Subject: [PATCH 01/23] gh-44968: Add "Reload from Disk" feature to IDLE --- Lib/idlelib/idle_test/test_iomenu.py | 124 ++++++++++++++++++ Lib/idlelib/iomenu.py | 45 +++++++ Lib/idlelib/mainmenu.py | 1 + ...5-11-14-20-58-55.gh-issue-44968.rL9dK3.rst | 4 + 4 files changed, 174 insertions(+) create mode 100644 Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index e0642cf0cabef0..eda0a29f23e86e 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -57,6 +57,130 @@ def test_fixnewlines_end(self): eq(text.get('1.0', 'end-1c'), 'a\n') eq(fix(), 'a'+io.eol_convention) + def test_reload_no_file(self): + # Test reload when no file is associated + import tempfile + import os + from unittest.mock import Mock + + io = self.io + # Ensure no filename is set + io.filename = None + + # Mock the messagebox.showinfo + orig_showinfo = iomenu.messagebox.showinfo + showinfo_called = [] + def mock_showinfo(*args, **kwargs): + showinfo_called.append((args, kwargs)) + iomenu.messagebox.showinfo = mock_showinfo + + try: + result = io.reload(None) + self.assertEqual(result, "break") + self.assertEqual(len(showinfo_called), 1) + self.assertIn("No File", showinfo_called[0][0]) + finally: + iomenu.messagebox.showinfo = orig_showinfo + + def test_reload_with_file(self): + # Test reload with an actual file + import tempfile + import os + + io = self.io + text = io.editwin.text + + # Create a temporary file + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.py') as f: + f.write("# Original content\n") + temp_filename = f.name + + try: + # Load the file + io.loadfile(temp_filename) + self.assertEqual(text.get('1.0', 'end-1c'), "# Original content\n") + + # Modify the file content externally + with open(temp_filename, 'w') as f: + f.write("# Modified content\n") + + # Reload should update the content + result = io.reload(None) + self.assertEqual(result, "break") + self.assertEqual(text.get('1.0', 'end-1c'), "# Modified content\n") + finally: + os.unlink(temp_filename) + + def test_reload_with_unsaved_changes_cancel(self): + # Test reload with unsaved changes and user cancels + import tempfile + import os + + io = self.io + text = io.editwin.text + + # Create a temporary file + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.py') as f: + f.write("# Original content\n") + temp_filename = f.name + + try: + # Load the file + io.loadfile(temp_filename) + + # Make unsaved changes + text.insert('end-1c', "\n# Unsaved change") + io.set_saved(False) + + # Mock askokcancel to return False (cancel) + orig_askokcancel = iomenu.messagebox.askokcancel + iomenu.messagebox.askokcancel = lambda *args, **kwargs: False + + try: + result = io.reload(None) + self.assertEqual(result, "break") + # Content should not change + self.assertIn("# Unsaved change", text.get('1.0', 'end-1c')) + finally: + iomenu.messagebox.askokcancel = orig_askokcancel + finally: + os.unlink(temp_filename) + + def test_reload_with_unsaved_changes_confirm(self): + # Test reload with unsaved changes and user confirms + import tempfile + import os + + io = self.io + text = io.editwin.text + + # Create a temporary file + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.py') as f: + f.write("# Original content\n") + temp_filename = f.name + + try: + # Load the file + io.loadfile(temp_filename) + + # Make unsaved changes + text.insert('end-1c', "\n# Unsaved change") + io.set_saved(False) + + # Mock askokcancel to return True (confirm) + orig_askokcancel = iomenu.messagebox.askokcancel + iomenu.messagebox.askokcancel = lambda *args, **kwargs: True + + try: + result = io.reload(None) + self.assertEqual(result, "break") + # Content should be reverted to original + self.assertEqual(text.get('1.0', 'end-1c'), "# Original content\n") + finally: + iomenu.messagebox.askokcancel = orig_askokcancel + finally: + os.unlink(temp_filename) + def _extension_in_filetypes(extension): return any( diff --git a/Lib/idlelib/iomenu.py b/Lib/idlelib/iomenu.py index 464126e2df0668..8ca663bb608219 100644 --- a/Lib/idlelib/iomenu.py +++ b/Lib/idlelib/iomenu.py @@ -31,6 +31,7 @@ def __init__(self, editwin): self.save_as) self.__id_savecopy = self.text.bind("<>", self.save_a_copy) + self.__id_reload = self.text.bind("<>", self.reload) self.fileencoding = 'utf-8' self.__id_print = self.text.bind("<>", self.print_window) @@ -40,6 +41,7 @@ def close(self): self.text.unbind("<>", self.__id_save) self.text.unbind("<>",self.__id_saveas) self.text.unbind("<>", self.__id_savecopy) + self.text.unbind("<>", self.__id_reload) self.text.unbind("<>", self.__id_print) # Break cycles self.editwin = None @@ -237,6 +239,49 @@ def save_a_copy(self, event): self.updaterecentfileslist(filename) return "break" + def reload(self, event): + """Reload the file from disk, discarding any unsaved changes. + + If the file has unsaved changes, ask the user to confirm. + """ + if not self.filename: + messagebox.showinfo( + "No File", + "This window has no associated file to reload.", + parent=self.text) + self.text.focus_set() + return "break" + + if not self.get_saved(): + confirm = messagebox.askokcancel( + title="Reload File", + message=f"Discard changes to {self.filename}?", + default=messagebox.CANCEL, + parent=self.text) + if not confirm: + self.text.focus_set() + return "break" + + # Save cursor position + insert_pos = self.text.index("insert") + yview_pos = self.text.yview() + + # Reload the file + if self.loadfile(self.filename): + # Try to restore cursor position if the file still has that line + try: + self.text.mark_set("insert", insert_pos) + self.text.see("insert") + # Restore vertical scroll position + self.text.yview_moveto(yview_pos[0]) + except Exception: + # If position doesn't exist anymore, go to top + self.text.mark_set("insert", "1.0") + self.text.see("insert") + + self.text.focus_set() + return "break" + def writefile(self, filename): text = self.fixnewlines() chars = self.encode(text) diff --git a/Lib/idlelib/mainmenu.py b/Lib/idlelib/mainmenu.py index 91a32cebb513f9..6e000abd8f09e3 100644 --- a/Lib/idlelib/mainmenu.py +++ b/Lib/idlelib/mainmenu.py @@ -31,6 +31,7 @@ ('_Save', '<>'), ('Save _As...', '<>'), ('Save Cop_y As...', '<>'), + ('Re_load from Disk', '<>'), None, ('Prin_t Window', '<>'), None, diff --git a/Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst b/Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst new file mode 100644 index 00000000000000..eb6d65a804a4ff --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst @@ -0,0 +1,4 @@ +Add "Reload from Disk" menu item to IDLE's File menu. This allows users to +easily reload a file from disk, discarding any unsaved changes in the editor. +The feature is particularly useful when working with version control systems +or when external tools modify files. Patch by ashm-dev. From 9dacd3d80bd4e23e03ad1f4895958bfc2dddebff Mon Sep 17 00:00:00 2001 From: Shamil Date: Sat, 15 Nov 2025 01:24:33 +0300 Subject: [PATCH 02/23] Use builtins.open for file operations in tests --- Lib/idlelib/idle_test/test_iomenu.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index eda0a29f23e86e..472fe02fc86888 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -2,6 +2,7 @@ from idlelib import iomenu import unittest +import builtins from test.support import requires from tkinter import Tk from idlelib.editor import EditorWindow @@ -101,7 +102,7 @@ def test_reload_with_file(self): self.assertEqual(text.get('1.0', 'end-1c'), "# Original content\n") # Modify the file content externally - with open(temp_filename, 'w') as f: + with builtins.open(temp_filename, 'w') as f: f.write("# Modified content\n") # Reload should update the content From 884758bf853aa154cac5cac73b24781fba9152bd Mon Sep 17 00:00:00 2001 From: Shamil Date: Sat, 15 Nov 2025 09:47:42 +0300 Subject: [PATCH 03/23] Update Lib/idlelib/iomenu.py Co-authored-by: R.C.M --- Lib/idlelib/iomenu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/idlelib/iomenu.py b/Lib/idlelib/iomenu.py index 8ca663bb608219..2def1bc43a9108 100644 --- a/Lib/idlelib/iomenu.py +++ b/Lib/idlelib/iomenu.py @@ -246,7 +246,7 @@ def reload(self, event): """ if not self.filename: messagebox.showinfo( - "No File", + “File Not Found", "This window has no associated file to reload.", parent=self.text) self.text.focus_set() From d6f54a22b9b00b8193e83e3f247bc97c9bcd28ac Mon Sep 17 00:00:00 2001 From: Shamil Date: Sat, 15 Nov 2025 10:22:20 +0300 Subject: [PATCH 04/23] Simplify file reload by removing cursor restoration Removed cursor position restoration logic during file reload. --- Lib/idlelib/iomenu.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/Lib/idlelib/iomenu.py b/Lib/idlelib/iomenu.py index 2def1bc43a9108..843d39a235cd97 100644 --- a/Lib/idlelib/iomenu.py +++ b/Lib/idlelib/iomenu.py @@ -262,22 +262,8 @@ def reload(self, event): self.text.focus_set() return "break" - # Save cursor position - insert_pos = self.text.index("insert") - yview_pos = self.text.yview() - # Reload the file - if self.loadfile(self.filename): - # Try to restore cursor position if the file still has that line - try: - self.text.mark_set("insert", insert_pos) - self.text.see("insert") - # Restore vertical scroll position - self.text.yview_moveto(yview_pos[0]) - except Exception: - # If position doesn't exist anymore, go to top - self.text.mark_set("insert", "1.0") - self.text.see("insert") + self.loadfile(self.filename) self.text.focus_set() return "break" From c10c5b9831685cdbe7d897546725cb434a05038e Mon Sep 17 00:00:00 2001 From: Shamil Date: Sat, 15 Nov 2025 10:38:26 +0300 Subject: [PATCH 05/23] Fix typo in 'File Not Found' message --- Lib/idlelib/iomenu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/idlelib/iomenu.py b/Lib/idlelib/iomenu.py index 843d39a235cd97..7509bd36427992 100644 --- a/Lib/idlelib/iomenu.py +++ b/Lib/idlelib/iomenu.py @@ -246,7 +246,7 @@ def reload(self, event): """ if not self.filename: messagebox.showinfo( - “File Not Found", + "File Not Found", "This window has no associated file to reload.", parent=self.text) self.text.focus_set() From 7a88c340aea1112a63b014481866081a0b69da28 Mon Sep 17 00:00:00 2001 From: Shamil Date: Sat, 15 Nov 2025 10:50:04 +0300 Subject: [PATCH 06/23] Update error message in test for file reload --- Lib/idlelib/idle_test/test_iomenu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index 472fe02fc86888..69104dcbd861a7 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -79,7 +79,7 @@ def mock_showinfo(*args, **kwargs): result = io.reload(None) self.assertEqual(result, "break") self.assertEqual(len(showinfo_called), 1) - self.assertIn("No File", showinfo_called[0][0]) + self.assertIn("File Not Found", showinfo_called[0][0]) finally: iomenu.messagebox.showinfo = orig_showinfo From 5614c2891e86e4fe7ccf4b063177c0a7567a2f36 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Sat, 15 Nov 2025 23:30:57 +0300 Subject: [PATCH 07/23] fix import --- Lib/idlelib/idle_test/test_iomenu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index 472fe02fc86888..850db165e60653 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -1,8 +1,8 @@ "Test , coverage 17%." from idlelib import iomenu -import unittest import builtins +import unittest from test.support import requires from tkinter import Tk from idlelib.editor import EditorWindow From 545ba86ddccabfab9c87e76d3eb76ea800ba1bd4 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Sun, 16 Nov 2025 00:03:28 +0300 Subject: [PATCH 08/23] fix: move local imports to global --- Lib/idlelib/idle_test/test_iomenu.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index 1430c26a517181..d30b024de2854d 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -2,6 +2,8 @@ from idlelib import iomenu import builtins +import os +import tempfile import unittest from test.support import requires from tkinter import Tk @@ -60,9 +62,6 @@ def test_fixnewlines_end(self): def test_reload_no_file(self): # Test reload when no file is associated - import tempfile - import os - from unittest.mock import Mock io = self.io # Ensure no filename is set @@ -85,8 +84,6 @@ def mock_showinfo(*args, **kwargs): def test_reload_with_file(self): # Test reload with an actual file - import tempfile - import os io = self.io text = io.editwin.text @@ -114,8 +111,6 @@ def test_reload_with_file(self): def test_reload_with_unsaved_changes_cancel(self): # Test reload with unsaved changes and user cancels - import tempfile - import os io = self.io text = io.editwin.text @@ -149,8 +144,6 @@ def test_reload_with_unsaved_changes_cancel(self): def test_reload_with_unsaved_changes_confirm(self): # Test reload with unsaved changes and user confirms - import tempfile - import os io = self.io text = io.editwin.text From 5f5e769d78334bd8481439d77c5f04be37f738b0 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Sun, 16 Nov 2025 00:17:21 +0300 Subject: [PATCH 09/23] test_iomenu: use unittest.mock.patch and call_args --- Lib/idlelib/idle_test/test_iomenu.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index d30b024de2854d..65cecb46c68521 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -5,6 +5,7 @@ import os import tempfile import unittest +from unittest.mock import patch from test.support import requires from tkinter import Tk from idlelib.editor import EditorWindow @@ -67,20 +68,13 @@ def test_reload_no_file(self): # Ensure no filename is set io.filename = None - # Mock the messagebox.showinfo - orig_showinfo = iomenu.messagebox.showinfo - showinfo_called = [] - def mock_showinfo(*args, **kwargs): - showinfo_called.append((args, kwargs)) - iomenu.messagebox.showinfo = mock_showinfo - - try: + # Mock the messagebox.showinfo using unittest.mock + with patch.object(iomenu.messagebox, 'showinfo') as mock_showinfo: result = io.reload(None) self.assertEqual(result, "break") - self.assertEqual(len(showinfo_called), 1) - self.assertIn("File Not Found", showinfo_called[0][0]) - finally: - iomenu.messagebox.showinfo = orig_showinfo + mock_showinfo.assert_called_once() + args, kwargs = mock_showinfo.call_args + self.assertIn("File Not Found", args[0]) def test_reload_with_file(self): # Test reload with an actual file From da3746895a63171924d9c31b3ca105a95e8ffe0e Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Sun, 16 Nov 2025 00:23:24 +0300 Subject: [PATCH 10/23] IDLE tests: use addCleanup for tempfile removal in test_iomenu --- Lib/idlelib/idle_test/test_iomenu.py | 96 +++++++++++++--------------- 1 file changed, 45 insertions(+), 51 deletions(-) diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index 65cecb46c68521..e2d9a4462966ee 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -86,22 +86,20 @@ def test_reload_with_file(self): with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.py') as f: f.write("# Original content\n") temp_filename = f.name + self.addCleanup(os.unlink, temp_filename) - try: - # Load the file - io.loadfile(temp_filename) - self.assertEqual(text.get('1.0', 'end-1c'), "# Original content\n") + # Load the file + io.loadfile(temp_filename) + self.assertEqual(text.get('1.0', 'end-1c'), "# Original content\n") - # Modify the file content externally - with builtins.open(temp_filename, 'w') as f: - f.write("# Modified content\n") + # Modify the file content externally + with builtins.open(temp_filename, 'w') as f: + f.write("# Modified content\n") - # Reload should update the content - result = io.reload(None) - self.assertEqual(result, "break") - self.assertEqual(text.get('1.0', 'end-1c'), "# Modified content\n") - finally: - os.unlink(temp_filename) + # Reload should update the content + result = io.reload(None) + self.assertEqual(result, "break") + self.assertEqual(text.get('1.0', 'end-1c'), "# Modified content\n") def test_reload_with_unsaved_changes_cancel(self): # Test reload with unsaved changes and user cancels @@ -113,28 +111,26 @@ def test_reload_with_unsaved_changes_cancel(self): with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.py') as f: f.write("# Original content\n") temp_filename = f.name + self.addCleanup(os.unlink, temp_filename) + + # Load the file + io.loadfile(temp_filename) + + # Make unsaved changes + text.insert('end-1c', "\n# Unsaved change") + io.set_saved(False) + + # Mock askokcancel to return False (cancel) + orig_askokcancel = iomenu.messagebox.askokcancel + iomenu.messagebox.askokcancel = lambda *args, **kwargs: False try: - # Load the file - io.loadfile(temp_filename) - - # Make unsaved changes - text.insert('end-1c', "\n# Unsaved change") - io.set_saved(False) - - # Mock askokcancel to return False (cancel) - orig_askokcancel = iomenu.messagebox.askokcancel - iomenu.messagebox.askokcancel = lambda *args, **kwargs: False - - try: - result = io.reload(None) - self.assertEqual(result, "break") - # Content should not change - self.assertIn("# Unsaved change", text.get('1.0', 'end-1c')) - finally: - iomenu.messagebox.askokcancel = orig_askokcancel + result = io.reload(None) + self.assertEqual(result, "break") + # Content should not change + self.assertIn("# Unsaved change", text.get('1.0', 'end-1c')) finally: - os.unlink(temp_filename) + iomenu.messagebox.askokcancel = orig_askokcancel def test_reload_with_unsaved_changes_confirm(self): # Test reload with unsaved changes and user confirms @@ -146,28 +142,26 @@ def test_reload_with_unsaved_changes_confirm(self): with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.py') as f: f.write("# Original content\n") temp_filename = f.name + self.addCleanup(os.unlink, temp_filename) + + # Load the file + io.loadfile(temp_filename) + + # Make unsaved changes + text.insert('end-1c', "\n# Unsaved change") + io.set_saved(False) + + # Mock askokcancel to return True (confirm) + orig_askokcancel = iomenu.messagebox.askokcancel + iomenu.messagebox.askokcancel = lambda *args, **kwargs: True try: - # Load the file - io.loadfile(temp_filename) - - # Make unsaved changes - text.insert('end-1c', "\n# Unsaved change") - io.set_saved(False) - - # Mock askokcancel to return True (confirm) - orig_askokcancel = iomenu.messagebox.askokcancel - iomenu.messagebox.askokcancel = lambda *args, **kwargs: True - - try: - result = io.reload(None) - self.assertEqual(result, "break") - # Content should be reverted to original - self.assertEqual(text.get('1.0', 'end-1c'), "# Original content\n") - finally: - iomenu.messagebox.askokcancel = orig_askokcancel + result = io.reload(None) + self.assertEqual(result, "break") + # Content should be reverted to original + self.assertEqual(text.get('1.0', 'end-1c'), "# Original content\n") finally: - os.unlink(temp_filename) + iomenu.messagebox.askokcancel = orig_askokcancel def _extension_in_filetypes(extension): From 66e04952767c59ccbf41d54dc8dfff9222a9b566 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Sun, 16 Nov 2025 00:57:17 +0300 Subject: [PATCH 11/23] remove comments --- Lib/idlelib/idle_test/test_iomenu.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index e2d9a4462966ee..83c7200be1b550 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -82,7 +82,6 @@ def test_reload_with_file(self): io = self.io text = io.editwin.text - # Create a temporary file with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.py') as f: f.write("# Original content\n") temp_filename = f.name @@ -107,7 +106,6 @@ def test_reload_with_unsaved_changes_cancel(self): io = self.io text = io.editwin.text - # Create a temporary file with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.py') as f: f.write("# Original content\n") temp_filename = f.name @@ -138,7 +136,6 @@ def test_reload_with_unsaved_changes_confirm(self): io = self.io text = io.editwin.text - # Create a temporary file with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.py') as f: f.write("# Original content\n") temp_filename = f.name From a154c9f344b1fd63f3ff29aacf4a9c5184c3570b Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Sun, 16 Nov 2025 01:18:43 +0300 Subject: [PATCH 12/23] refactor(tests): Use mock.patch for messagebox dialogs --- Lib/idlelib/idle_test/test_iomenu.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index 83c7200be1b550..58b1ff8ce4cdfd 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -119,16 +119,12 @@ def test_reload_with_unsaved_changes_cancel(self): io.set_saved(False) # Mock askokcancel to return False (cancel) - orig_askokcancel = iomenu.messagebox.askokcancel - iomenu.messagebox.askokcancel = lambda *args, **kwargs: False - - try: + with patch('idlelib.iomenu.messagebox.askokcancel', return_value=False) as mock_askokcancel: result = io.reload(None) self.assertEqual(result, "break") # Content should not change self.assertIn("# Unsaved change", text.get('1.0', 'end-1c')) - finally: - iomenu.messagebox.askokcancel = orig_askokcancel + mock_askokcancel.assert_called_once() def test_reload_with_unsaved_changes_confirm(self): # Test reload with unsaved changes and user confirms @@ -149,16 +145,12 @@ def test_reload_with_unsaved_changes_confirm(self): io.set_saved(False) # Mock askokcancel to return True (confirm) - orig_askokcancel = iomenu.messagebox.askokcancel - iomenu.messagebox.askokcancel = lambda *args, **kwargs: True - - try: + with patch('idlelib.iomenu.messagebox.askokcancel', return_value=True) as mock_askokcancel: result = io.reload(None) self.assertEqual(result, "break") # Content should be reverted to original self.assertEqual(text.get('1.0', 'end-1c'), "# Original content\n") - finally: - iomenu.messagebox.askokcancel = orig_askokcancel + mock_askokcancel.assert_called_once() def _extension_in_filetypes(extension): From bd28bb4471990f5c196bf36104b08ae6a7fb7af9 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Sun, 16 Nov 2025 01:28:27 +0300 Subject: [PATCH 13/23] add news --- Doc/whatsnew/3.15.rst | 6 ++++++ .../next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 9393b65ed8e906..c6013a4bc5a024 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -533,6 +533,12 @@ http.cookies * Allow '``"``' double quotes in cookie values. (Contributed by Nick Burns and Senthil Kumaran in :gh:`92936`.) +idle +---- + +* Add a "Reload from Disk" item to the File menu. This allows discarding + unsaved changes and reloading the current version of the file from the disk. + Patch by Shamil Abdulaev. (Contributed by Shamil Abdulaev in :gh:`44968`.) inspect ------- diff --git a/Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst b/Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst index eb6d65a804a4ff..64d01fa18bfba3 100644 --- a/Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst +++ b/Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst @@ -1,4 +1,4 @@ Add "Reload from Disk" menu item to IDLE's File menu. This allows users to easily reload a file from disk, discarding any unsaved changes in the editor. The feature is particularly useful when working with version control systems -or when external tools modify files. Patch by ashm-dev. +or when external tools modify files. Patch by Shamil Abdulaev. From fe997ec485d12841ac1c4a330cf27a13047f0714 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Sun, 16 Nov 2025 01:32:49 +0300 Subject: [PATCH 14/23] rewrite tests --- Lib/idlelib/idle_test/test_iomenu.py | 91 +++++++++++----------------- 1 file changed, 37 insertions(+), 54 deletions(-) diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index 58b1ff8ce4cdfd..c6932846e38bad 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -1,11 +1,8 @@ "Test , coverage 17%." from idlelib import iomenu -import builtins -import os -import tempfile import unittest -from unittest.mock import patch +from unittest.mock import patch, mock_open from test.support import requires from tkinter import Tk from idlelib.editor import EditorWindow @@ -63,12 +60,9 @@ def test_fixnewlines_end(self): def test_reload_no_file(self): # Test reload when no file is associated - io = self.io - # Ensure no filename is set io.filename = None - # Mock the messagebox.showinfo using unittest.mock with patch.object(iomenu.messagebox, 'showinfo') as mock_showinfo: result = io.reload(None) self.assertEqual(result, "break") @@ -78,79 +72,68 @@ def test_reload_no_file(self): def test_reload_with_file(self): # Test reload with an actual file - io = self.io text = io.editwin.text + io.filename = "/dummy/path/test.py" - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.py') as f: - f.write("# Original content\n") - temp_filename = f.name - self.addCleanup(os.unlink, temp_filename) + original_content = "# Original content\n" + modified_content = "# Modified content\n" - # Load the file - io.loadfile(temp_filename) - self.assertEqual(text.get('1.0', 'end-1c'), "# Original content\n") + m = mock_open() + m.side_effect = [ + mock_open(read_data=original_content).return_value, + mock_open(read_data=modified_content).return_value, + ] - # Modify the file content externally - with builtins.open(temp_filename, 'w') as f: - f.write("# Modified content\n") + with patch('builtins.open', m): + io.loadfile(io.filename) + self.assertEqual(text.get('1.0', 'end-1c'), original_content) + result = io.reload(None) - # Reload should update the content - result = io.reload(None) self.assertEqual(result, "break") - self.assertEqual(text.get('1.0', 'end-1c'), "# Modified content\n") + self.assertEqual(text.get('1.0', 'end-1c'), modified_content) def test_reload_with_unsaved_changes_cancel(self): # Test reload with unsaved changes and user cancels - io = self.io text = io.editwin.text + io.filename = "/dummy/path/test.py" + original_content = "# Original content\n" + unsaved_content = original_content + "\n# Unsaved change" - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.py') as f: - f.write("# Original content\n") - temp_filename = f.name - self.addCleanup(os.unlink, temp_filename) - - # Load the file - io.loadfile(temp_filename) + # Mock the initial file load. + with patch('builtins.open', mock_open(read_data=original_content)): + io.loadfile(io.filename) - # Make unsaved changes - text.insert('end-1c', "\n# Unsaved change") + text.insert('end', "\n# Unsaved change") io.set_saved(False) - # Mock askokcancel to return False (cancel) - with patch('idlelib.iomenu.messagebox.askokcancel', return_value=False) as mock_askokcancel: + with patch('idlelib.iomenu.messagebox.askokcancel', return_value=False) as mock_ask: result = io.reload(None) self.assertEqual(result, "break") - # Content should not change - self.assertIn("# Unsaved change", text.get('1.0', 'end-1c')) - mock_askokcancel.assert_called_once() + # Content should not change. + self.assertEqual(text.get('1.0', 'end-1c'), unsaved_content) + mock_ask.assert_called_once() def test_reload_with_unsaved_changes_confirm(self): # Test reload with unsaved changes and user confirms - io = self.io text = io.editwin.text + io.filename = "/dummy/path/test.py" + original_content = "# Original content\n" - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.py') as f: - f.write("# Original content\n") - temp_filename = f.name - self.addCleanup(os.unlink, temp_filename) + with patch('builtins.open', mock_open(read_data=original_content)): + io.loadfile(io.filename) + text.insert('end', "\n# Unsaved change") + io.set_saved(False) - # Load the file - io.loadfile(temp_filename) + with patch('idlelib.iomenu.messagebox.askokcancel', return_value=True) as mock_ask: + result = io.reload(None) - # Make unsaved changes - text.insert('end-1c', "\n# Unsaved change") - io.set_saved(False) - - # Mock askokcancel to return True (confirm) - with patch('idlelib.iomenu.messagebox.askokcancel', return_value=True) as mock_askokcancel: - result = io.reload(None) - self.assertEqual(result, "break") - # Content should be reverted to original - self.assertEqual(text.get('1.0', 'end-1c'), "# Original content\n") - mock_askokcancel.assert_called_once() + self.assertEqual(result, "break") + # Content should be reverted to original. + self.assertEqual(text.get('1.0', 'end-1c'), original_content) + mock_ask.assert_called_once() def _extension_in_filetypes(extension): From 74216999df6eb396f64e8e578aae0cd4f2bbf4de Mon Sep 17 00:00:00 2001 From: Shamil Date: Sun, 16 Nov 2025 02:00:42 +0300 Subject: [PATCH 15/23] Update Doc/whatsnew/3.15.rst Co-authored-by: R.C.M --- Doc/whatsnew/3.15.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index c6013a4bc5a024..b12f9466c963bf 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -538,7 +538,7 @@ idle * Add a "Reload from Disk" item to the File menu. This allows discarding unsaved changes and reloading the current version of the file from the disk. - Patch by Shamil Abdulaev. (Contributed by Shamil Abdulaev in :gh:`44968`.) + (Contributed by Shamil Abdulaev in :gh:`44968`.) inspect ------- From 21c5a9c15ba24b2c2dcffb9a5bc071b2b9b33ef1 Mon Sep 17 00:00:00 2001 From: Shamil Date: Sun, 16 Nov 2025 02:29:56 +0300 Subject: [PATCH 16/23] Update Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- .../next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst b/Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst index 64d01fa18bfba3..972f88637c74fd 100644 --- a/Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst +++ b/Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst @@ -1,4 +1,3 @@ Add "Reload from Disk" menu item to IDLE's File menu. This allows users to easily reload a file from disk, discarding any unsaved changes in the editor. -The feature is particularly useful when working with version control systems -or when external tools modify files. Patch by Shamil Abdulaev. +Patch by Shamil Abdulaev. From ad17754b6eb4cf9ba529869eeb1b733217144af1 Mon Sep 17 00:00:00 2001 From: Shamil Date: Sun, 16 Nov 2025 02:30:10 +0300 Subject: [PATCH 17/23] Update Doc/whatsnew/3.15.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Doc/whatsnew/3.15.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index b12f9466c963bf..ac0b492248858f 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -533,13 +533,15 @@ http.cookies * Allow '``"``' double quotes in cookie values. (Contributed by Nick Burns and Senthil Kumaran in :gh:`92936`.) -idle ----- + +idlelib +------- * Add a "Reload from Disk" item to the File menu. This allows discarding unsaved changes and reloading the current version of the file from the disk. (Contributed by Shamil Abdulaev in :gh:`44968`.) + inspect ------- From 86726830aa9546e767245611ca1fa93c14dd4a8c Mon Sep 17 00:00:00 2001 From: Shamil Date: Sun, 16 Nov 2025 02:36:11 +0300 Subject: [PATCH 18/23] Update Doc/whatsnew/3.15.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Doc/whatsnew/3.15.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index ac0b492248858f..98b5a8e2a970b3 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -537,9 +537,9 @@ http.cookies idlelib ------- -* Add a "Reload from Disk" item to the File menu. This allows discarding - unsaved changes and reloading the current version of the file from the disk. - (Contributed by Shamil Abdulaev in :gh:`44968`.) +* Add a "Reload from Disk" item to the File menu. This allows discarding + unsaved changes and reloading the current version of the file from the disk. + (Contributed by Shamil Abdulaev in :gh:`44968`.) inspect From a1e94ca6a4aedc0ea1b6ac5f2ccbd826d9097432 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Sun, 16 Nov 2025 02:52:41 +0300 Subject: [PATCH 19/23] fix --- Lib/idlelib/mainmenu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/idlelib/mainmenu.py b/Lib/idlelib/mainmenu.py index 6e000abd8f09e3..6162b07a33a65e 100644 --- a/Lib/idlelib/mainmenu.py +++ b/Lib/idlelib/mainmenu.py @@ -31,7 +31,7 @@ ('_Save', '<>'), ('Save _As...', '<>'), ('Save Cop_y As...', '<>'), - ('Re_load from Disk', '<>'), + ('_Reload from Disk', '<>'), None, ('Prin_t Window', '<>'), None, From 1788b803059536e2022aa731f17d5f4ba9721d3e Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Sun, 16 Nov 2025 03:29:34 +0300 Subject: [PATCH 20/23] fix tsan --- Lib/idlelib/idle_test/test_iomenu.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index c6932846e38bad..39659d2c4a31e9 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -70,7 +70,8 @@ def test_reload_no_file(self): args, kwargs = mock_showinfo.call_args self.assertIn("File Not Found", args[0]) - def test_reload_with_file(self): + @patch('idlelib.iomenu.messagebox.showerror') + def test_reload_with_file(self, mock_showerror): # Test reload with an actual file io = self.io text = io.editwin.text @@ -90,6 +91,7 @@ def test_reload_with_file(self): self.assertEqual(text.get('1.0', 'end-1c'), original_content) result = io.reload(None) + mock_showerror.assert_not_called() self.assertEqual(result, "break") self.assertEqual(text.get('1.0', 'end-1c'), modified_content) From 953dc8da826c517379d5101931fbbd601e2d53d3 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Sun, 16 Nov 2025 12:04:16 +0300 Subject: [PATCH 21/23] fix tests --- Lib/idlelib/idle_test/test_iomenu.py | 86 ++++++++++++++-------------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index 39659d2c4a31e9..7e8db59e230c19 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -1,18 +1,20 @@ -"Test , coverage 17%." - -from idlelib import iomenu +import builtins +import os +import tempfile import unittest -from unittest.mock import patch, mock_open +from unittest.mock import patch + from test.support import requires from tkinter import Tk + +from idlelib import iomenu, util from idlelib.editor import EditorWindow -from idlelib import util from idlelib.idle_test.mock_idle import Func # Fail if either tokenize.open and t.detect_encoding does not exist. # These are used in loadfile and encode. # Also used in pyshell.MI.execfile and runscript.tabnanny. -from tokenize import open, detect_encoding +from tokenize import open as tokenize_open, detect_encoding # Remove when we have proper tests that use both. @@ -33,10 +35,18 @@ def tearDownClass(cls): del cls.editwin cls.root.update_idletasks() for id in cls.root.tk.call('after', 'info'): - cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.after_cancel(id) cls.root.destroy() del cls.root + def _create_tempfile(self, content: str) -> str: + fd, filename = tempfile.mkstemp(suffix='.py') + os.close(fd) + self.addCleanup(os.unlink, filename) + with builtins.open(filename, 'w', encoding='utf-8') as f: + f.write(content) + return filename + def test_init(self): self.assertIs(self.io.editwin, self.editwin) @@ -46,49 +56,44 @@ def test_fixnewlines_end(self): fix = io.fixnewlines text = io.editwin.text - # Make the editor temporarily look like Shell. self.editwin.interp = None shelltext = '>>> if 1' self.editwin.get_prompt_text = Func(result=shelltext) - eq(fix(), shelltext) # Get... call and '\n' not added. + eq(fix(), shelltext) del self.editwin.interp, self.editwin.get_prompt_text text.insert(1.0, 'a') - eq(fix(), 'a'+io.eol_convention) + eq(fix(), 'a' + io.eol_convention) eq(text.get('1.0', 'end-1c'), 'a\n') - eq(fix(), 'a'+io.eol_convention) + eq(fix(), 'a' + io.eol_convention) def test_reload_no_file(self): - # Test reload when no file is associated io = self.io io.filename = None - with patch.object(iomenu.messagebox, 'showinfo') as mock_showinfo: + with patch('idlelib.iomenu.messagebox.showinfo') as mock_showinfo: result = io.reload(None) self.assertEqual(result, "break") mock_showinfo.assert_called_once() args, kwargs = mock_showinfo.call_args self.assertIn("File Not Found", args[0]) - @patch('idlelib.iomenu.messagebox.showerror') - def test_reload_with_file(self, mock_showerror): - # Test reload with an actual file + def test_reload_with_file(self): io = self.io text = io.editwin.text - io.filename = "/dummy/path/test.py" - original_content = "# Original content\n" modified_content = "# Modified content\n" - m = mock_open() - m.side_effect = [ - mock_open(read_data=original_content).return_value, - mock_open(read_data=modified_content).return_value, - ] + filename = self._create_tempfile(original_content) + io.filename = filename - with patch('builtins.open', m): + with patch('idlelib.iomenu.messagebox.showerror') as mock_showerror: io.loadfile(io.filename) self.assertEqual(text.get('1.0', 'end-1c'), original_content) + + with builtins.open(filename, 'w', encoding='utf-8') as f: + f.write(modified_content) + result = io.reload(None) mock_showerror.assert_not_called() @@ -96,44 +101,41 @@ def test_reload_with_file(self, mock_showerror): self.assertEqual(text.get('1.0', 'end-1c'), modified_content) def test_reload_with_unsaved_changes_cancel(self): - # Test reload with unsaved changes and user cancels io = self.io text = io.editwin.text - io.filename = "/dummy/path/test.py" original_content = "# Original content\n" unsaved_content = original_content + "\n# Unsaved change" - # Mock the initial file load. - with patch('builtins.open', mock_open(read_data=original_content)): - io.loadfile(io.filename) + filename = self._create_tempfile(original_content) + io.filename = filename + io.loadfile(io.filename) text.insert('end', "\n# Unsaved change") io.set_saved(False) with patch('idlelib.iomenu.messagebox.askokcancel', return_value=False) as mock_ask: result = io.reload(None) - self.assertEqual(result, "break") - # Content should not change. - self.assertEqual(text.get('1.0', 'end-1c'), unsaved_content) - mock_ask.assert_called_once() + + self.assertEqual(result, "break") + self.assertEqual(text.get('1.0', 'end-1c'), unsaved_content) + mock_ask.assert_called_once() def test_reload_with_unsaved_changes_confirm(self): - # Test reload with unsaved changes and user confirms io = self.io text = io.editwin.text - io.filename = "/dummy/path/test.py" original_content = "# Original content\n" - with patch('builtins.open', mock_open(read_data=original_content)): - io.loadfile(io.filename) - text.insert('end', "\n# Unsaved change") - io.set_saved(False) + filename = self._create_tempfile(original_content) + io.filename = filename + io.loadfile(io.filename) - with patch('idlelib.iomenu.messagebox.askokcancel', return_value=True) as mock_ask: - result = io.reload(None) + text.insert('end', "\n# Unsaved change") + io.set_saved(False) + + with patch('idlelib.iomenu.messagebox.askokcancel', return_value=True) as mock_ask: + result = io.reload(None) self.assertEqual(result, "break") - # Content should be reverted to original. self.assertEqual(text.get('1.0', 'end-1c'), original_content) mock_ask.assert_called_once() From 18a30d2e5be50eda3a54046a01e18ba486dfac94 Mon Sep 17 00:00:00 2001 From: Shamil Date: Wed, 19 Nov 2025 09:51:13 +0300 Subject: [PATCH 22/23] Update Lib/idlelib/idle_test/test_iomenu.py Co-authored-by: R.C.M --- Lib/idlelib/idle_test/test_iomenu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index 7e8db59e230c19..49bf74f9f7876b 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -35,7 +35,7 @@ def tearDownClass(cls): del cls.editwin cls.root.update_idletasks() for id in cls.root.tk.call('after', 'info'): - cls.root.after_cancel(id) + cls.root.after_cancel(id) # Need for EditorWindow. cls.root.destroy() del cls.root From 885f2cfdf8ef367f4ddb98aec4229da820151f8f Mon Sep 17 00:00:00 2001 From: Shamil Date: Wed, 19 Nov 2025 09:51:23 +0300 Subject: [PATCH 23/23] Update Lib/idlelib/idle_test/test_iomenu.py Co-authored-by: R.C.M --- Lib/idlelib/idle_test/test_iomenu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index 49bf74f9f7876b..9d7d1222938c4e 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -59,7 +59,7 @@ def test_fixnewlines_end(self): self.editwin.interp = None shelltext = '>>> if 1' self.editwin.get_prompt_text = Func(result=shelltext) - eq(fix(), shelltext) + eq(fix(), shelltext) # Get... call and '\n' not added. del self.editwin.interp, self.editwin.get_prompt_text text.insert(1.0, 'a')