diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index a4a49fd44bb2e0..7061b6b100d412 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -3866,6 +3866,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(): @@ -3903,6 +3947,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 97d83f3ddd3297..cdc6523103a62a 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1086,63 +1086,40 @@ 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__ - 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: - if suggestion.isascii(): - self._str += f". Did you mean: '{suggestion}'?" - else: - self._str += f". Did you mean: '{suggestion}' ({suggestion!a})?" - elif exc_type and issubclass(exc_type, ModuleNotFoundError): - module_name = getattr(exc_value, "name", None) - if module_name in sys.stdlib_module_names: - message = _MISSING_STDLIB_MODULE_MESSAGES.get( + 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 (issubclass(exc_type, ModuleNotFoundError) + and getattr(exc_value, "name", None) in sys.stdlib_module_names): + module_name = exc_value.name + self._str = _MISSING_STDLIB_MODULE_MESSAGES.get( module_name, f"Standard library module {module_name!r} was not found" ) - self._str = message - elif sys.flags.no_site: - self._str += (". Site initialization is disabled, did you forget to " - + "add the site-packages directory to sys.path " - + "or to enable your virtual environment?") - 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: - if suggestion.isascii(): - self._str += f". Did you mean: '{suggestion}'?" + elif suggestion := _suggestion_message(exc_type, exc_value, exc_traceback): + if self._str.endswith(('.', '?', '!')): + punctuation = '' else: - self._str += f". Did you mean: '{suggestion}' ({suggestion!a})?" - 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}'?" + punctuation = '.' + self._str += f"{punctuation} {suggestion}" + else: + self.exc_type_qualname = None + self.exc_type_module = None + if lookup_lines: self._load_lines() self.__suppress_context__ = \ @@ -1771,6 +1748,42 @@ 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 " + "or to enable your virtual environment?") + 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 + if other_name.isascii(): + text = f"Did you mean: '{other_name}'?" + else: + text = f"Did you mean: '{other_name}' ({other_name!a})?" + 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