From e43529924040e2dbd1392431eda615647cdb3e17 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Thu, 23 Jul 2026 23:27:12 +0800 Subject: [PATCH] gh-154544: Fix TracebackException mutating attributes during formatting --- Lib/test/test_traceback.py | 11 +++++ Lib/traceback.py | 49 +++++++++++-------- ...-07-18-10-00-00.gh-issue-154544.TbKwTp.rst | 3 ++ 3 files changed, 43 insertions(+), 20 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-18-10-00-00.gh-issue-154544.TbKwTp.rst diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index e38d0942e463e9c..4b0c52ce604650d 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -1864,6 +1864,17 @@ def test_no_keyword_suggestion_for_comma_errors(self): self.assertIn("Perhaps you forgot a comma", stderr_text) self.assertNotIn("Did you mean", stderr_text) + def test_format_is_idempotent(self): + code = "fr x in range(10):\n pass\n" + try: + compile(code, "", "exec") + except SyntaxError as exc: + te = traceback.TracebackException(type(exc), exc, None) + r1 = list(te.format_exception_only()) + r2 = list(te.format_exception_only()) + self.assertEqual(r1, r2) + self.assertEqual(te.msg, exc.msg) + @requires_debug_ranges() @force_not_colorized_test_class class PurePythonTracebackErrorCaretTests( diff --git a/Lib/traceback.py b/Lib/traceback.py index dcdab1f12e9a168..a256d5b444ddd0e 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1519,14 +1519,14 @@ def _find_keyword_typos(self): except SyntaxError: continue - # Keep token.line but handle offsets correctly - self.text = token.line - self.offset = token.start[1] + 1 - self.end_offset = token.end[1] + 1 - self.lineno = start[0] - self.end_lineno = end[0] - self.msg = f"invalid syntax. Did you mean '{suggestion}'?" - return + return ( + token.line, + token.start[1] + 1, + token.end[1] + 1, + start[0], + end[0], + f"invalid syntax. Did you mean '{suggestion}'?", + ) def _format_syntax_error(self, stype, **kwargs): @@ -1555,31 +1555,38 @@ def _format_syntax_error(self, stype, **kwargs): # text = " foo\n" # rtext = " foo" # ltext = "foo" + typo = None with suppress(Exception): - self._find_keyword_typos() - text = self.text + typo = self._find_keyword_typos() + if typo is not None: + text, offset, end_offset, lineno, end_lineno, msg = typo + else: + offset = self.offset + end_offset = self.end_offset + lineno = self.lineno + end_lineno = self.end_lineno + msg = self.msg rtext = text.rstrip('\n') ltext = rtext.lstrip(' \n\f') spaces = len(rtext) - len(ltext) - if self.offset is None: + if offset is None: yield ' {}\n'.format(ltext) - elif isinstance(self.offset, int): - offset = self.offset - if self.lineno == self.end_lineno: + elif isinstance(offset, int): + if lineno == end_lineno: end_offset = ( - self.end_offset + end_offset if ( - isinstance(self.end_offset, int) - and self.end_offset != 0 + isinstance(end_offset, int) + and end_offset != 0 ) else offset ) else: end_offset = len(rtext) + 1 - if self.text and offset > len(self.text): + if text and offset > len(text): offset = len(rtext) + 1 - if self.text and end_offset > len(self.text): + if text and end_offset > len(text): end_offset = len(rtext) + 1 if offset >= end_offset or end_offset < 0: end_offset = offset + 1 @@ -1611,7 +1618,9 @@ def _format_syntax_error(self, stype, **kwargs): ) else: yield ' {}\n'.format(ltext) - msg = self.msg or "" + if not isinstance(text, str): + msg = self.msg + msg = msg or "" yield "{}{}{}: {}{}{}{}\n".format( theme.type, stype, diff --git a/Misc/NEWS.d/next/Library/2026-07-18-10-00-00.gh-issue-154544.TbKwTp.rst b/Misc/NEWS.d/next/Library/2026-07-18-10-00-00.gh-issue-154544.TbKwTp.rst new file mode 100644 index 000000000000000..ccb4a719eaeb2ff --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-18-10-00-00.gh-issue-154544.TbKwTp.rst @@ -0,0 +1,3 @@ +Fix :func:`traceback.TracebackException.format_exception_only` mutating +its own attributes when suggesting a keyword typo fix, making repeated +calls produce different output. Patch by tonghuaroot.