diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index b43dca6f640b9a..4bfcfdcc104976 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -38,6 +38,26 @@ LEVENSHTEIN_DATA_FILE = Path(__file__).parent / 'levenshtein_examples.json' +class ModuleTestBase: + + def make_module(self, mod_name, code): + tmpdir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, tmpdir) + + sys.path.append(str(tmpdir)) + self.addCleanup(sys.path.pop) + + if not mod_name: + mod_name = ''.join(random.choices(string.ascii_letters, k=16)) + assert mod_name not in sys.modules, repr(mod_name) + self.addCleanup(lambda: sys.modules.pop(mod_name, None)) + + module = tmpdir / (mod_name + ".py") + module.write_text(code) + + return mod_name + + class TracebackCases(unittest.TestCase): # For now, a very minimal set of tests. I want to be sure that # formatting of SyntaxErrors works based on changes for 2.1. @@ -3220,10 +3240,424 @@ def test_comparison(self): self.assertEqual(exc, ALWAYS_EQ) +class MyRuntimeError(RuntimeError): + pass + + +class TestTracebackExceptionExcType(unittest.TestCase, ModuleTestBase): + + ATTRS = ('__name__', '__module__', '__qualname__') + + NOT_SET = object() + + def _new(self, exc, exc_type=NOT_SET): + if exc_type is self.NOT_SET: + exc_type = type(exc) + tb = exc.__traceback__ + return traceback.TracebackException(exc_type, exc, tb) + + def _check(self, tbexc, expected): + tbexc_type = tbexc.exc_type + attrs = {name: getattr(tbexc_type, name, None) + for name in self.ATTRS} + self.assertEqual(attrs, expected) + + def check(self, exc, /, **expected): + tbexc = self._new(exc, self.NOT_SET) + self._check(tbexc, expected) + return tbexc + + def check_proxy(self, exc, proxy, /, **expected): + tbexc = self._new(exc, proxy) + self._check(tbexc, expected) + return tbexc + + def check_none(self, exc): + expected = {n: None for n in self.ATTRS} + tbexc = self._new(exc, None) + self._check(tbexc, expected) + return tbexc + + def test_builtin_exception(self): + expected = dict( + __name__='RuntimeError', + __module__='builtins', + __qualname__='RuntimeError', + ) + try: + raise RuntimeError('spam') + except Exception as e: + exc = e + + with self.subTest('exception'): + self.check(exc, **expected) + + with self.subTest('matching proxy'): + proxy = type(sys.implementation)(**expected) + self.check_proxy(exc, proxy, **expected) + + with self.subTest('mismatching proxy'): + proxy = type(sys.implementation)( + __name__='MyRuntimeError', + __module__='mymod', + __qualname__='mymod.MyRuntimeError', + ) + self.check_proxy(exc, proxy, **vars(proxy)) + + with self.subTest('missing'): + self.check_none(exc) + + class MyOuterError(RuntimeError): + pass + + def test_custom_exception(self): + expected = dict( + __name__='MyRuntimeError', + __module__=self.__module__, + __qualname__='MyRuntimeError', + ) + try: + raise MyRuntimeError('spam') + except Exception as e: + exc = e + + with self.subTest('exception'): + self.check(exc, **expected) + + with self.subTest('matching proxy'): + proxy = type(sys.implementation)(**expected) + self.check_proxy(exc, proxy, **expected) + + with self.subTest('mismatching proxy'): + proxy = type(sys.implementation)( + __name__='AnotherError', + __module__='mymod', + __qualname__='mymod.AnotherError', + ) + self.check_proxy(exc, proxy, **vars(proxy)) + + with self.subTest('missing'): + self.check_none(exc) + + # A custom exception type in a class definition: + context = f'TestTracebackExceptionExcType' + expected_outer = dict( + __name__='MyOuterError', + __module__=self.__module__, + __qualname__=f'{context}.MyOuterError', + ) + try: + raise self.MyOuterError('spam') + except Exception as e: + exc_outer = e + with self.subTest('outer exception'): + self.check(exc_outer, **expected_outer) + + # A custom exception type in a function definition: + context += '.test_custom_exception' + expected_inner = dict( + __name__='MyInnerError', + __module__=self.__module__, + __qualname__=f'{context}..MyInnerError', + ) + class MyInnerError(RuntimeError): + pass + try: + raise MyInnerError('spam') + except Exception as e: + exc_inner = e + with self.subTest('inner exception'): + self.check(exc_inner, **expected_inner) + + def test_SyntaxError(self): + expected = dict( + __name__='SyntaxError', + __module__='builtins', + __qualname__='SyntaxError', + ) + modname = self.make_module('mymodule', """if True: + spam(... # missing close paren + """) + try: + import mymodule + except Exception as e: + exc = e + + with self.subTest('exception'): + tbexc = self.check(exc, **expected) + # Verify the special-casing in __init__(). + self.assertEqual(tbexc.msg, exc.msg) + + with self.subTest('matching proxy'): + proxy = type(sys.implementation)(**expected) + tbexc = self.check_proxy(exc, proxy, **expected) + # Verify the special-casing in __init__(). + self.assertEqual(tbexc.msg, exc.msg) + + with self.subTest('mismatching proxy'): + proxy = type(sys.implementation)( + __name__='AnotherError', + __module__='mymod', + __qualname__='mymod.AnotherError', + ) + tbexc = self.check_proxy(exc, proxy, **vars(proxy)) + # This cannot match: + self.assertFalse(hasattr(tbexc, 'msg')) + + with self.subTest('missing'): + self.check_none(exc) + + # A custom subclass: + class MySyntaxError(SyntaxError): + pass + expected_sub = dict( + __name__='MySyntaxError', + __module__=self.__module__, + __qualname__=MySyntaxError.__qualname__, + ) + try: + raise MySyntaxError() + except Exception as e: + for name in dir(exc): + if not name.startswith('_') and not getattr(e, name, None): + setattr(e, name, getattr(exc, name)) + exc_sub = e + + with self.subTest('exception subclass'): + tbexc = self.check(exc_sub, **expected_sub) + # Verify the special-casing in __init__(). + self.assertEqual(tbexc.msg, exc_sub.msg) + + with self.subTest('subclass matching proxy'): + proxy = type(sys.implementation)(**expected_sub) + self.check_proxy(exc, proxy, **expected_sub) + tbexc = self.check_proxy(exc, proxy, **expected_sub) + # This cannot match: + self.assertFalse(hasattr(tbexc, 'msg')) + + with self.subTest('dishonest proxy'): + proxy = type(sys.implementation)(**expected) + self.check_proxy(exc, proxy, **expected) + tbexc = self.check_proxy(exc, proxy, **expected) + # Verify the special-casing in __init__(). + self.assertEqual(tbexc.msg, exc_sub.msg) + + def test_ImportError(self): + expected = dict( + __name__='ImportError', + __module__='builtins', + __qualname__='ImportError', + ) + try: + from sys import mdules # should be modules + except Exception as e: + exc = e + + with self.subTest('exception'): + tbexc = self.check(exc, **expected) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) + + with self.subTest('matching proxy'): + proxy = type(sys.implementation)(**expected) + self.check_proxy(exc, proxy, **expected) + tbexc = self.check_proxy(exc, proxy, **expected) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) + + with self.subTest('mismatching proxy'): + proxy = type(sys.implementation)( + __name__='AnotherError', + __module__='mymod', + __qualname__='mymod.AnotherError', + ) + tbexc = self.check_proxy(exc, proxy, **vars(proxy)) + # This cannot match: + self.assertNotIn('Did you mean', str(tbexc)) + + with self.subTest('missing'): + self.check_none(exc) + + # A custom subclass: + class MyImportError(ImportError): + pass + expected_sub = dict( + __name__='MyImportError', + __module__=self.__module__, + __qualname__=MyImportError.__qualname__, + ) + try: + raise MyImportError() + except Exception as e: + for name in dir(exc): + if not name.startswith('_') and not getattr(e, name, None): + setattr(e, name, getattr(exc, name)) + exc_sub = e + + with self.subTest('exception subclass'): + tbexc = self.check(exc_sub, **expected_sub) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) + + with self.subTest('subclass matching proxy'): + proxy = type(sys.implementation)(**expected_sub) + self.check_proxy(exc, proxy, **expected_sub) + tbexc = self.check_proxy(exc, proxy, **expected_sub) + # This cannot match: + self.assertNotIn('Did you mean', str(tbexc)) + + with self.subTest('dishonest proxy'): + proxy = type(sys.implementation)(**expected) + self.check_proxy(exc, proxy, **expected) + tbexc = self.check_proxy(exc, proxy, **expected) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) + + def test_AttributeError(self): + expected = dict( + __name__='AttributeError', + __module__='builtins', + __qualname__='AttributeError', + ) + obj = type(sys.implementation)() + obj._this_var_does_not_exist = ... + try: + spam = obj.this_var_does_not_exist + except Exception as e: + exc = e + + with self.subTest('exception'): + tbexc = self.check(exc, **expected) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) + + with self.subTest('matching proxy'): + proxy = type(sys.implementation)(**expected) + self.check_proxy(exc, proxy, **expected) + tbexc = self.check_proxy(exc, proxy, **expected) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) + + with self.subTest('mismatching proxy'): + proxy = type(sys.implementation)( + __name__='AnotherError', + __module__='mymod', + __qualname__='mymod.AnotherError', + ) + tbexc = self.check_proxy(exc, proxy, **vars(proxy)) + # This cannot match: + self.assertNotIn('Did you mean', str(tbexc)) + + with self.subTest('missing'): + self.check_none(exc) + + # A custom subclass: + class MyAttributeError(AttributeError): + pass + expected_sub = dict( + __name__='MyAttributeError', + __module__=self.__module__, + __qualname__=MyAttributeError.__qualname__, + ) + try: + raise MyAttributeError() + except Exception as e: + for name in dir(exc): + if not name.startswith('_') and not getattr(e, name, None): + setattr(e, name, getattr(exc, name)) + exc_sub = e + + with self.subTest('subclass matching proxy'): + proxy = type(sys.implementation)(**expected_sub) + self.check_proxy(exc, proxy, **expected_sub) + tbexc = self.check_proxy(exc, proxy, **expected_sub) + # This cannot match: + self.assertNotIn('Did you mean', str(tbexc)) + + with self.subTest('dishonest proxy'): + proxy = type(sys.implementation)(**expected) + self.check_proxy(exc, proxy, **expected) + tbexc = self.check_proxy(exc, proxy, **expected) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) + + def test_NameError(self): + expected = dict( + __name__='NameError', + __module__='builtins', + __qualname__='NameError', + ) + _this_var_does_not_exist = ... + try: + spam = this_var_does_not_exist + except Exception as e: + exc = e + + with self.subTest('exception'): + tbexc = self.check(exc, **expected) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) + + with self.subTest('matching proxy'): + proxy = type(sys.implementation)(**expected) + self.check_proxy(exc, proxy, **expected) + tbexc = self.check_proxy(exc, proxy, **expected) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) + + with self.subTest('mismatching proxy'): + proxy = type(sys.implementation)( + __name__='AnotherError', + __module__='mymod', + __qualname__='mymod.AnotherError', + ) + tbexc = self.check_proxy(exc, proxy, **vars(proxy)) + # This cannot match: + self.assertNotIn('Did you mean', str(tbexc)) + + with self.subTest('missing'): + self.check_none(exc) + + # A custom subclass: + class MyNameError(NameError): + pass + expected_sub = dict( + __name__='MyNameError', + __module__=self.__module__, + __qualname__=MyNameError.__qualname__, + ) + try: + raise MyNameError() + except Exception as e: + for name in dir(exc): + if not name.startswith('_') and not getattr(e, name, None): + setattr(e, name, getattr(exc, name)) + exc_sub = e + + with self.subTest('exception subclass'): + tbexc = self.check(exc_sub, **expected_sub) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) + + with self.subTest('subclass matching proxy'): + proxy = type(sys.implementation)(**expected_sub) + self.check_proxy(exc, proxy, **expected_sub) + tbexc = self.check_proxy(exc, proxy, **expected_sub) + # This cannot match: + self.assertNotIn('Did you mean', str(tbexc)) + + with self.subTest('dishonest proxy'): + proxy = type(sys.implementation)(**expected) + self.check_proxy(exc, proxy, **expected) + tbexc = self.check_proxy(exc, proxy, **expected) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) + + global_for_suggestions = None -class SuggestionFormattingTestBase: +class SuggestionFormattingTestBase(ModuleTestBase): def get_suggestion(self, obj, attr_name=None): if attr_name is not None: def callable(): @@ -3391,21 +3825,8 @@ def __getattribute__(self, attr): self.assertIn("Did you mean", actual) self.assertIn("bluch", actual) - def make_module(self, code): - tmpdir = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, tmpdir) - - sys.path.append(str(tmpdir)) - self.addCleanup(sys.path.pop) - - mod_name = ''.join(random.choices(string.ascii_letters, k=16)) - module = tmpdir / (mod_name + ".py") - module.write_text(code) - - return mod_name - def get_import_from_suggestion(self, mod_dict, name): - modname = self.make_module(mod_dict) + modname = self.make_module(None, mod_dict) def callable(): try: diff --git a/Lib/traceback.py b/Lib/traceback.py index b25a7291f6be51..f41348a5d75051 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -696,6 +696,41 @@ def emit(self, text_gen, margin_char=None): yield textwrap.indent(text, indent_str, lambda line: True) +def _resolve_exc_type(exc_type): + ATTRS = ('__name__', '__module__', '__qualname__') + if exc_type is None: + return None + elif isinstance(exc_type, type): + assert all(getattr(exc_type, name, None) for name in ATTRS) + return exc_type + elif all(getattr(exc_type, name, None) for name in ATTRS): + return exc_type + else: + raise ValueError(f'unsupported exc_type {exc_type!r}') + + +def _match_class(exc_type, expected): + assert expected + if exc_type is None: + return False + if isinstance(expected, type): + expected = (expected,) + assert all(et.__module__ == 'builtins' for et in expected) + if isinstance(exc_type, type): + if not issubclass(exc_type, expected): + return False + elif exc_type.__module__ != 'builtins': + return False + else: + assert exc_type.__qualname__ == exc_type.__name__ + for _expected in expected: + if exc_type.__name__ == _expected.__name__: + break + else: + return False + return True + + class TracebackException: """An exception ready for rendering. @@ -747,6 +782,8 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, _seen = set() _seen.add(id(exc_value)) + self.exc_type = exc_type = _resolve_exc_type(exc_type) + self.max_group_width = max_group_width self.max_group_depth = max_group_depth @@ -754,13 +791,12 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, _walk_tb_with_full_positions(exc_traceback), limit=limit, lookup_lines=lookup_lines, capture_locals=capture_locals) - self.exc_type = exc_type # Capture now to permit freeing resources: only complication is in the # unofficial API _format_final_exc_line self._str = _safe_string(exc_value, 'exception') self.__notes__ = getattr(exc_value, '__notes__', None) - if exc_type and issubclass(exc_type, SyntaxError): + if _match_class(exc_type, SyntaxError): # Handle SyntaxError's specially self.filename = exc_value.filename lno = exc_value.lineno @@ -771,19 +807,19 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, self.offset = exc_value.offset self.end_offset = exc_value.end_offset self.msg = exc_value.msg - elif exc_type and issubclass(exc_type, ImportError) and \ + elif _match_class(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, (NameError, AttributeError)) and \ + elif _match_class(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): + if _match_class(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: @@ -912,7 +948,7 @@ def format_exception_only(self, *, show_group=False, _depth=0): smod = "" stype = smod + '.' + stype - if not issubclass(self.exc_type, SyntaxError): + if not _match_class(self.exc_type, SyntaxError): if _depth > 0: # Nested exceptions needs correct handling of multiline messages. formatted = _format_final_exc_line( diff --git a/Misc/NEWS.d/next/Library/2023-11-22-14-47-28.gh-issue-111922.qc94fY.rst b/Misc/NEWS.d/next/Library/2023-11-22-14-47-28.gh-issue-111922.qc94fY.rst new file mode 100644 index 00000000000000..83ce07edba439b --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-11-22-14-47-28.gh-issue-111922.qc94fY.rst @@ -0,0 +1,2 @@ +Allow ``TracebackException.exc_type`` to be a minimal proxy instead of only +an actual exception type.