diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index bd3ecfd9a3863d..b99d7b0ace4add 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -3802,6 +3802,50 @@ def test_traceback_header(self): exc = traceback.TracebackException(Exception, Exception("haven"), None) self.assertEqual(list(exc.format()), ["Exception: haven\n"]) + def test_name_error_punctuation_with_suggestions(self): + def format_error(message, name): + return self.format_error(NameError, message, name=name) + + test_cases = [ + ("a.", "time", "NameError: a. Did you forget to import 'time'?\n"), + ("b?", "time", "NameError: b? Did you forget to import 'time'?\n"), + ("c!", "time", "NameError: c! Did you forget to import 'time'?\n"), + ("d", "time", "NameError: d. Did you forget to import 'time'?\n"), + ("e", "foo123", "NameError: e\n"), + ] + for message, name, expected in test_cases: + with self.subTest(message=message): + message = format_error(message, name) + self.assertEqual(message, expected) + + with self.subTest("stdlib module import suggestion"): + message = format_error("foo", "abc") + expected_message = ( + "NameError: foo. Did you mean: 'abs'? " + "Or did you forget to import 'abc'?\n" + ) + self.assertEqual(message, expected_message) + + with self.subTest("'did you mean' suggestion"): + message = format_error("bar", "flaot") + expected_message = "NameError: bar. Did you mean: 'float'?\n" + self.assertEqual(message, expected_message) + + def test_import_error_punctuation_handling_with_suggestions(self): + def format_error(message): + return self.format_error(ImportError, message, name="math", name_from="sinq") + + test_cases = [ + ("a.", "ImportError: a. Did you mean: 'sin'?\n"), + ("b?", "ImportError: b? Did you mean: 'sin'?\n"), + ("c!", "ImportError: c! Did you mean: 'sin'?\n"), + ("d", "ImportError: d. Did you mean: 'sin'?\n"), + ] + for message, expected in test_cases: + with self.subTest(message=message): + message = format_error(message) + self.assertEqual(message, expected) + @requires_debug_ranges() def test_print(self): def f(): @@ -3839,6 +3883,14 @@ def test_dont_swallow_cause_or_context_of_falsey_exception(self): except FalseyException as e: self.assertIn(context_message, traceback.format_exception(e)) + @staticmethod + def format_error(exc_type, exc_message, *args, **kwargs): + try: + raise exc_type(exc_message, *args, **kwargs) + except exc_type as e: + exc = traceback.TracebackException.from_exception(e) + return list(exc.format())[-1] + class TestTracebackException_ExceptionGroups(unittest.TestCase): def setUp(self): diff --git a/Lib/traceback.py b/Lib/traceback.py index 8e2d8d72a0a32d..e37b2b08fde4b9 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1081,50 +1081,33 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, self._is_syntax_error = False self._have_exc_type = exc_type is not None - if exc_type is not None: + + if self._have_exc_type: self.exc_type_qualname = exc_type.__qualname__ self.exc_type_module = exc_type.__module__ + if issubclass(exc_type, SyntaxError): + # Handle SyntaxErrors specially + self.filename = exc_value.filename + lno = exc_value.lineno + self.lineno = str(lno) if lno is not None else None + end_lno = exc_value.end_lineno + self.end_lineno = str(end_lno) if end_lno is not None else None + self.text = exc_value.text + self.offset = exc_value.offset + self.end_offset = exc_value.end_offset + self.msg = exc_value.msg + self._is_syntax_error = True + self._exc_metadata = getattr(exc_value, "_metadata", None) + elif suggestion := _suggestion_message(exc_type, exc_value, exc_traceback): + if self._str.endswith(('.', '?', '!')): + punctuation = '' + else: + punctuation = '.' + self._str += f"{punctuation} {suggestion}" else: self.exc_type_qualname = None self.exc_type_module = None - if exc_type and issubclass(exc_type, SyntaxError): - # Handle SyntaxError's specially - self.filename = exc_value.filename - lno = exc_value.lineno - self.lineno = str(lno) if lno is not None else None - end_lno = exc_value.end_lineno - self.end_lineno = str(end_lno) if end_lno is not None else None - self.text = exc_value.text - self.offset = exc_value.offset - self.end_offset = exc_value.end_offset - self.msg = exc_value.msg - self._is_syntax_error = True - self._exc_metadata = getattr(exc_value, "_metadata", None) - elif exc_type and issubclass(exc_type, ImportError) and \ - getattr(exc_value, "name_from", None) is not None: - wrong_name = getattr(exc_value, "name_from", None) - suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name) - if suggestion: - self._str += f". Did you mean: '{suggestion}'?" - elif exc_type and issubclass(exc_type, ModuleNotFoundError) and \ - sys.flags.no_site and \ - getattr(exc_value, "name", None) not in sys.stdlib_module_names: - self._str += (". Site initialization is disabled, did you forget to " - + "add the site-packages directory to sys.path?") - elif exc_type and issubclass(exc_type, (NameError, AttributeError)) and \ - getattr(exc_value, "name", None) is not None: - wrong_name = getattr(exc_value, "name", None) - suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name) - if suggestion: - self._str += f". Did you mean: '{suggestion}'?" - if issubclass(exc_type, NameError): - wrong_name = getattr(exc_value, "name", None) - if wrong_name is not None and wrong_name in sys.stdlib_module_names: - if suggestion: - self._str += f" Or did you forget to import '{wrong_name}'?" - else: - self._str += f". Did you forget to import '{wrong_name}'?" if lookup_lines: self._load_lines() self.__suppress_context__ = \ @@ -1732,6 +1715,38 @@ def _compute_suggestion_error(exc_value, tb, wrong_name): return suggestion +def _suggestion_message(exc_type, exc_value, exc_traceback): + if ( + issubclass(exc_type, ModuleNotFoundError) + and sys.flags.no_site + and getattr(exc_value, "name", None) not in sys.stdlib_module_names + ): + return ("Site initialization is disabled, did you forget to " + "add the site-packages directory to sys.path?") + if issubclass(exc_type, (ImportError, NameError, AttributeError)): + if issubclass(exc_type, ImportError): + wrong_name = getattr(exc_value, "name_from", None) + else: + wrong_name = getattr(exc_value, "name", None) + if wrong_name: + other_name = _compute_suggestion_error( + exc_value, exc_traceback, wrong_name + ) + maybe_stdlib_import = ( + issubclass(exc_type, NameError) + and wrong_name in sys.stdlib_module_names + ) + if not other_name: + if maybe_stdlib_import: + return f"Did you forget to import '{wrong_name}'?" + return None + text = f"Did you mean: '{other_name}'?" + if maybe_stdlib_import: + return f"{text} Or did you forget to import '{wrong_name}'?" + return text + return None + + def _levenshtein_distance(a, b, max_cost): # A Python implementation of Python/suggestions.c:levenshtein_distance. diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-08-24-11-42-38.gh-issue-137716.7-Mtj-.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-08-24-11-42-38.gh-issue-137716.7-Mtj-.rst new file mode 100644 index 00000000000000..1942ac2a93b976 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-08-24-11-42-38.gh-issue-137716.7-Mtj-.rst @@ -0,0 +1 @@ +Avoid double punctuation in :class:`~traceback.TracebackException` messages