diff --git a/Include/internal/pycore_magic_number.h b/Include/internal/pycore_magic_number.h index b6945f2bc5f6e0d..15d3ad6f3a336b7 100644 --- a/Include/internal/pycore_magic_number.h +++ b/Include/internal/pycore_magic_number.h @@ -303,6 +303,7 @@ Known values: Python 3.16a1 3703 (Replace DELETE_GLOBAL with PUSH_NULL; STORE_GLOBAL) Python 3.16a1 3704 (Replace DELETE_ATTR with PUSH_NULL; STORE_ATTR) Python 3.16a1 3705 (Add INTRINSIC_ADD_CONDITIONAL_ANNOTATION) + Python 3.16a1 3706 (Add INTRINSIC_MATCH_CLASS_ISINSTANCE) Python 3.17 will start with 3750 @@ -312,7 +313,7 @@ Known values: */ -#define PYC_MAGIC_NUMBER 3705 +#define PYC_MAGIC_NUMBER 3706 /* This is equivalent to converting PYC_MAGIC_NUMBER to 2 bytes (little-endian) and then appending b'\r\n'. */ #define PYC_MAGIC_NUMBER_TOKEN \ 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/Lib/idlelib/idle_test/test_run.py b/Lib/idlelib/idle_test/test_run.py index 19adb325ec06f13..d7e69f70fd87d85 100644 --- a/Lib/idlelib/idle_test/test_run.py +++ b/Lib/idlelib/idle_test/test_run.py @@ -49,6 +49,7 @@ def __eq__(self, other): ('int.reel', AttributeError, "type object 'int' has no attribute 'reel'. " "Did you mean '.real' instead of '.reel'?\n"), # More in 3.15. + (r'raise NameError("123\n456")', NameError, "123\n456\n"), ) @force_not_colorized @@ -56,7 +57,7 @@ def test_get_message(self): for code, exc, msg in self.data: with self.subTest(code=code): try: - eval(compile(code, '', 'eval')) + exec(compile(code, '', 'exec')) except exc: typ, val, tb = sys.exc_info() actual = run.get_message_lines(typ, val, tb)[0] @@ -68,15 +69,20 @@ def test_get_message(self): new_callable=lambda: (lambda t, e: None)) def test_get_multiple_message(self, mock): d = self.data - data2 = ((d[0], d[1]), (d[1], d[2]), (d[2], d[0])) + data2 = ((d[0], d[1]), + (d[1], d[2]), + (d[2], d[3]), + (d[3], d[0]), + (d[1], d[3]), + (d[0], d[2])) subtests = 0 for (code1, exc1, msg1), (code2, exc2, msg2) in data2: with self.subTest(codes=(code1,code2)): try: - eval(compile(code1, '', 'eval')) + exec(compile(code1, '', 'exec')) except exc1: try: - eval(compile(code2, '', 'eval')) + exec(compile(code2, '', 'exec')) except exc2: with captured_stderr() as output: run.print_exception() diff --git a/Lib/idlelib/run.py b/Lib/idlelib/run.py index 48e8f3c4605fc07..5e04afe2ccc5c15 100644 --- a/Lib/idlelib/run.py +++ b/Lib/idlelib/run.py @@ -4,7 +4,6 @@ f'''{sys.executable} -c "__import__('idlelib.run').run.main()"''' '.run' is needed because __import__ returns idlelib, not idlelib.run. """ -import contextlib import functools import io import linecache @@ -244,15 +243,8 @@ def show_socket_error(err, address): def get_message_lines(typ, exc, tb): - "Return line composing the exception message." - if typ in (AttributeError, NameError): - # 3.10+ hints are not directly accessible from python (#44026). - err = io.StringIO() - with contextlib.redirect_stderr(err): - sys.__excepthook__(typ, exc, tb) - return [err.getvalue().split("\n")[-2] + "\n"] - else: - return traceback.format_exception_only(typ, exc) + "Return lines of the exception message, with any suggestion." + return list(traceback.TracebackException(typ, exc, tb).format_exception_only()) def print_exception(): diff --git a/Lib/test/test_dict.py b/Lib/test/test_dict.py index 673987733fc8c4f..3b9ff672a1aa035 100644 --- a/Lib/test/test_dict.py +++ b/Lib/test/test_dict.py @@ -1424,6 +1424,31 @@ def test_reversed_dict_after_clear_and_restore(self): for it in iterators: self.assertEqual(list(it), []) + def test_reversed_dict_keys_changed_during_iteration(self): + d = dict.fromkeys(range(10)) + for i in range(7): + del d[i] + + iterators = ( + reversed(d), + reversed(d.keys()), + reversed(d.values()), + reversed(d.items()), + ) + for it in iterators: + next(it) + + # Same size as before, but with different keys below + # the iterators' current position. + d.clear() + d.update(dict.fromkeys(range(10))) + for i in range(3, 10): + del d[i] + + for it in iterators: + with self.assertRaisesRegex(RuntimeError, 'keys changed'): + list(it) + def test_dict_copy_order(self): # bpo-34320 od = collections.OrderedDict([('a', 1), ('b', 2)]) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-26-15-39-32.gh-issue-158254.qT7vRk.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-26-15-39-32.gh-issue-158254.qT7vRk.rst new file mode 100644 index 000000000000000..21908eea4a8236b --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-26-15-39-32.gh-issue-158254.qT7vRk.rst @@ -0,0 +1,3 @@ +Reverse iterators over a :class:`dict` and its views now raise +:exc:`RuntimeError` if the dictionary's keys change during iteration, like +forward iterators, instead of yielding entries for the new keys. diff --git a/Misc/NEWS.d/next/IDLE/2025-08-08-10-50-59.gh-issue-135511.9Rw5Zg.rst b/Misc/NEWS.d/next/IDLE/2025-08-08-10-50-59.gh-issue-135511.9Rw5Zg.rst new file mode 100644 index 000000000000000..10e59572b53746e --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2025-08-08-10-50-59.gh-issue-135511.9Rw5Zg.rst @@ -0,0 +1 @@ +Fix display of :exc:`NameError` and :exc:`AttributeError` with multi-line message. 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. diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 9a469f88230f8b2..15377ac083c2b27 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -6303,6 +6303,12 @@ dictreviter_iter_lock_held(PyDictObject *d, PyObject *self) value = entry_ptr->me_value; } } + // We found an element, but did not expect it + if (di->len == 0) { + PyErr_SetString(PyExc_RuntimeError, + "dictionary keys changed during iteration"); + goto fail; + } di->di_pos = i-1; di->len--;