From 2fd3f1e14ad2a1fb220054b3fb98f4a92ff741af Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Fri, 17 Nov 2023 15:56:22 -0700 Subject: [PATCH 1/7] Add tests for TracebackException.exc_type. --- Lib/test/test_traceback.py | 424 +++++++++++++++++++++++++++++++++++-- 1 file changed, 409 insertions(+), 15 deletions(-) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index b43dca6f640b9a..5b950de167732a 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -38,6 +38,25 @@ 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)) + 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. @@ -2713,6 +2732,7 @@ class Unrepresentable: def __repr__(self) -> str: raise Exception("Unrepresentable") + class TestTracebackException(unittest.TestCase): def test_smoke(self): @@ -3220,10 +3240,397 @@ 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 check(self, exc, exc_type=NOT_SET, /, **expected): + if exc_type is self.NOT_SET: + exc_type = type(exc) + tb = exc.__traceback__ + tbexc = traceback.TracebackException(exc_type, exc, tb) + tbexc_type = tbexc.exc_type + attrs = {name: getattr(tbexc_type, name, None) + for name in self.ATTRS} + + self.assertEqual(attrs, expected) + return tbexc + + def check_proxy(self, exc, proxy, /, **expected): + with self.assertRaises(TypeError): + return self.check(exc, proxy, **expected) + + def check_str(self, exc, qualname, /, **expected): + with self.assertRaises(TypeError): + return self.check(exc, qualname, **expected) + + 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('str'): + exc_type = expected['__qualname__'] + self.check_str(exc, exc_type, **expected) + + with self.subTest('missing'): + self.check(exc, None, **{n: None for n in self.ATTRS}) + + 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('str'): + exc_type = expected['__qualname__'] + self.check_str(exc, exc_type, **expected) + + with self.subTest('missing'): + self.check(exc, None, **{n: None for n in self.ATTRS}) + + # 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) + 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, **expected) + + with self.subTest('str'): + exc_type = expected['__qualname__'] + self.check_str(exc, exc_type, **expected) + + with self.subTest('missing'): + self.check(exc, None, **{n: None for n in self.ATTRS}) + + # 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) + + with self.subTest('dishonest proxy'): + proxy = type(sys.implementation)(**expected) + self.check_proxy(exc, proxy, **expected) + + 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) + + with self.subTest('mismatching proxy'): + proxy = type(sys.implementation)( + __name__='AnotherError', + __module__='mymod', + __qualname__='mymod.AnotherError', + ) + self.check_proxy(exc, proxy, **expected) + + with self.subTest('str'): + exc_type = expected['__qualname__'] + self.check_str(exc, exc_type, **expected) + + with self.subTest('missing'): + self.check(exc, None, **{n: None for n in self.ATTRS}) + + # 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) + + with self.subTest('dishonest proxy'): + proxy = type(sys.implementation)(**expected) + self.check_proxy(exc, proxy, **expected) + + 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) + + with self.subTest('mismatching proxy'): + proxy = type(sys.implementation)( + __name__='AnotherError', + __module__='mymod', + __qualname__='mymod.AnotherError', + ) + self.check_proxy(exc, proxy, **expected) + + with self.subTest('str'): + exc_type = expected['__qualname__'] + self.check_str(exc, exc_type, **expected) + + with self.subTest('missing'): + self.check(exc, None, **{n: None for n in self.ATTRS}) + + # 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) + + with self.subTest('dishonest proxy'): + proxy = type(sys.implementation)(**expected) + self.check_proxy(exc, proxy, **expected) + + 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) + + with self.subTest('mismatching proxy'): + proxy = type(sys.implementation)( + __name__='AnotherError', + __module__='mymod', + __qualname__='mymod.AnotherError', + ) + self.check_proxy(exc, proxy, **expected) + + with self.subTest('str'): + exc_type = expected['__qualname__'] + self.check_str(exc, exc_type, **expected) + + with self.subTest('missing'): + self.check(exc, None, **{n: None for n in self.ATTRS}) + + # 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) + + with self.subTest('dishonest proxy'): + proxy = type(sys.implementation)(**expected) + self.check_proxy(exc, proxy, **expected) + + 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 +3798,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: From d58e089824d506baaeb73c2da1cbc4a252e20eaf Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Fri, 17 Nov 2023 16:06:16 -0700 Subject: [PATCH 2/7] Factor out _match_class(). --- Lib/traceback.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/Lib/traceback.py b/Lib/traceback.py index b25a7291f6be51..f3a6829935d9d1 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -696,6 +696,15 @@ def emit(self, text_gen, margin_char=None): yield textwrap.indent(text, indent_str, lambda line: True) +def _match_class(exc_type, *expected): + assert expected + if not exc_type: + return False + if not issubclass(exc_type, expected): + return False + return True + + class TracebackException: """An exception ready for rendering. @@ -760,7 +769,7 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, 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 +780,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 +921,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( From f862c4e98fa48ba90398279aba027acd8fc1307b Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Fri, 17 Nov 2023 16:37:15 -0700 Subject: [PATCH 3/7] Factor out _resolve_exc_type(). --- Lib/test/test_traceback.py | 91 ++++++++++++++++++++++++++++++-------- Lib/traceback.py | 20 ++++++++- 2 files changed, 91 insertions(+), 20 deletions(-) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 5b950de167732a..42d15df394d89b 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -3250,25 +3250,37 @@ class TestTracebackExceptionExcType(unittest.TestCase, ModuleTestBase): NOT_SET = object() - def check(self, exc, exc_type=NOT_SET, /, **expected): + def _new(self, exc, exc_type=NOT_SET): if exc_type is self.NOT_SET: exc_type = type(exc) tb = exc.__traceback__ - tbexc = traceback.TracebackException(exc_type, exc, tb) + 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): - with self.assertRaises(TypeError): - return self.check(exc, proxy, **expected) + tbexc = self._new(exc, proxy) + self._check(tbexc, expected) + return tbexc def check_str(self, exc, qualname, /, **expected): - with self.assertRaises(TypeError): - return self.check(exc, qualname, **expected) + with self.assertRaises(ValueError): + self._new(exc, qualname) + + 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( @@ -3301,7 +3313,7 @@ def test_builtin_exception(self): self.check_str(exc, exc_type, **expected) with self.subTest('missing'): - self.check(exc, None, **{n: None for n in self.ATTRS}) + self.check_none(exc) class MyOuterError(RuntimeError): pass @@ -3337,7 +3349,7 @@ def test_custom_exception(self): self.check_str(exc, exc_type, **expected) with self.subTest('missing'): - self.check(exc, None, **{n: None for n in self.ATTRS}) + self.check_none(exc) # A custom exception type in a class definition: context = f'TestTracebackExceptionExcType' @@ -3390,7 +3402,9 @@ def test_SyntaxError(self): with self.subTest('matching proxy'): proxy = type(sys.implementation)(**expected) - self.check_proxy(exc, proxy, **expected) + tbexc = self.check_proxy(exc, proxy, **expected) + # This cannot match: + self.assertFalse(hasattr(tbexc, 'msg')) with self.subTest('mismatching proxy'): proxy = type(sys.implementation)( @@ -3398,14 +3412,16 @@ def test_SyntaxError(self): __module__='mymod', __qualname__='mymod.AnotherError', ) - self.check_proxy(exc, proxy, **expected) + tbexc = self.check_proxy(exc, proxy, **vars(proxy)) + # This cannot match: + self.assertFalse(hasattr(tbexc, 'msg')) with self.subTest('str'): exc_type = expected['__qualname__'] self.check_str(exc, exc_type, **expected) with self.subTest('missing'): - self.check(exc, None, **{n: None for n in self.ATTRS}) + self.check_none(exc) # A custom subclass: class MySyntaxError(SyntaxError): @@ -3431,10 +3447,16 @@ class MySyntaxError(SyntaxError): 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) + # This cannot match: + self.assertFalse(hasattr(tbexc, 'msg')) def test_ImportError(self): expected = dict( @@ -3455,6 +3477,9 @@ def test_ImportError(self): with self.subTest('matching proxy'): proxy = type(sys.implementation)(**expected) self.check_proxy(exc, proxy, **expected) + tbexc = self.check_proxy(exc, proxy, **expected) + # This cannot match: + self.assertNotIn('Did you mean', str(tbexc)) with self.subTest('mismatching proxy'): proxy = type(sys.implementation)( @@ -3462,14 +3487,16 @@ def test_ImportError(self): __module__='mymod', __qualname__='mymod.AnotherError', ) - self.check_proxy(exc, proxy, **expected) + tbexc = self.check_proxy(exc, proxy, **vars(proxy)) + # This cannot match: + self.assertNotIn('Did you mean', str(tbexc)) with self.subTest('str'): exc_type = expected['__qualname__'] self.check_str(exc, exc_type, **expected) with self.subTest('missing'): - self.check(exc, None, **{n: None for n in self.ATTRS}) + self.check_none(exc) # A custom subclass: class MyImportError(ImportError): @@ -3495,10 +3522,16 @@ class MyImportError(ImportError): 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) + # This cannot match: + self.assertNotIn('Did you mean', str(tbexc)) def test_AttributeError(self): expected = dict( @@ -3521,6 +3554,9 @@ def test_AttributeError(self): with self.subTest('matching proxy'): proxy = type(sys.implementation)(**expected) self.check_proxy(exc, proxy, **expected) + tbexc = self.check_proxy(exc, proxy, **expected) + # This cannot match: + self.assertNotIn('Did you mean', str(tbexc)) with self.subTest('mismatching proxy'): proxy = type(sys.implementation)( @@ -3528,14 +3564,16 @@ def test_AttributeError(self): __module__='mymod', __qualname__='mymod.AnotherError', ) - self.check_proxy(exc, proxy, **expected) + tbexc = self.check_proxy(exc, proxy, **vars(proxy)) + # This cannot match: + self.assertNotIn('Did you mean', str(tbexc)) with self.subTest('str'): exc_type = expected['__qualname__'] self.check_str(exc, exc_type, **expected) with self.subTest('missing'): - self.check(exc, None, **{n: None for n in self.ATTRS}) + self.check_none(exc) # A custom subclass: class MyAttributeError(AttributeError): @@ -3556,10 +3594,16 @@ class MyAttributeError(AttributeError): 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) + # This cannot match: + self.assertNotIn('Did you mean', str(tbexc)) def test_NameError(self): expected = dict( @@ -3581,6 +3625,9 @@ def test_NameError(self): with self.subTest('matching proxy'): proxy = type(sys.implementation)(**expected) self.check_proxy(exc, proxy, **expected) + tbexc = self.check_proxy(exc, proxy, **expected) + # This cannot match: + self.assertNotIn('Did you mean', str(tbexc)) with self.subTest('mismatching proxy'): proxy = type(sys.implementation)( @@ -3588,14 +3635,16 @@ def test_NameError(self): __module__='mymod', __qualname__='mymod.AnotherError', ) - self.check_proxy(exc, proxy, **expected) + tbexc = self.check_proxy(exc, proxy, **vars(proxy)) + # This cannot match: + self.assertNotIn('Did you mean', str(tbexc)) with self.subTest('str'): exc_type = expected['__qualname__'] self.check_str(exc, exc_type, **expected) with self.subTest('missing'): - self.check(exc, None, **{n: None for n in self.ATTRS}) + self.check_none(exc) # A custom subclass: class MyNameError(NameError): @@ -3621,10 +3670,16 @@ class MyNameError(NameError): 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) + # This cannot match: + self.assertNotIn('Did you mean', str(tbexc)) global_for_suggestions = None diff --git a/Lib/traceback.py b/Lib/traceback.py index f3a6829935d9d1..c9da65dc8b6776 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -696,9 +696,24 @@ 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 not exc_type: + if exc_type is None: + return False + if not isinstance(exc_type, type): return False if not issubclass(exc_type, expected): return False @@ -756,6 +771,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 @@ -763,7 +780,6 @@ 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') From 9209b15e784622035eb029871e99bfe888f7e6c5 Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Fri, 17 Nov 2023 18:30:16 -0700 Subject: [PATCH 4/7] Honor proxies. --- Lib/test/test_traceback.py | 32 ++++++++++++++++---------------- Lib/traceback.py | 15 ++++++++++++--- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 42d15df394d89b..8b656e9dfcd3fd 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -3403,8 +3403,8 @@ def test_SyntaxError(self): with self.subTest('matching proxy'): proxy = type(sys.implementation)(**expected) tbexc = self.check_proxy(exc, proxy, **expected) - # This cannot match: - self.assertFalse(hasattr(tbexc, 'msg')) + # Verify the special-casing in __init__(). + self.assertEqual(tbexc.msg, exc.msg) with self.subTest('mismatching proxy'): proxy = type(sys.implementation)( @@ -3455,8 +3455,8 @@ class MySyntaxError(SyntaxError): proxy = type(sys.implementation)(**expected) self.check_proxy(exc, proxy, **expected) tbexc = self.check_proxy(exc, proxy, **expected) - # This cannot match: - self.assertFalse(hasattr(tbexc, 'msg')) + # Verify the special-casing in __init__(). + self.assertEqual(tbexc.msg, exc_sub.msg) def test_ImportError(self): expected = dict( @@ -3478,8 +3478,8 @@ def test_ImportError(self): proxy = type(sys.implementation)(**expected) self.check_proxy(exc, proxy, **expected) tbexc = self.check_proxy(exc, proxy, **expected) - # This cannot match: - self.assertNotIn('Did you mean', str(tbexc)) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) with self.subTest('mismatching proxy'): proxy = type(sys.implementation)( @@ -3530,8 +3530,8 @@ class MyImportError(ImportError): proxy = type(sys.implementation)(**expected) self.check_proxy(exc, proxy, **expected) tbexc = self.check_proxy(exc, proxy, **expected) - # This cannot match: - self.assertNotIn('Did you mean', str(tbexc)) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) def test_AttributeError(self): expected = dict( @@ -3555,8 +3555,8 @@ def test_AttributeError(self): proxy = type(sys.implementation)(**expected) self.check_proxy(exc, proxy, **expected) tbexc = self.check_proxy(exc, proxy, **expected) - # This cannot match: - self.assertNotIn('Did you mean', str(tbexc)) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) with self.subTest('mismatching proxy'): proxy = type(sys.implementation)( @@ -3602,8 +3602,8 @@ class MyAttributeError(AttributeError): proxy = type(sys.implementation)(**expected) self.check_proxy(exc, proxy, **expected) tbexc = self.check_proxy(exc, proxy, **expected) - # This cannot match: - self.assertNotIn('Did you mean', str(tbexc)) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) def test_NameError(self): expected = dict( @@ -3626,8 +3626,8 @@ def test_NameError(self): proxy = type(sys.implementation)(**expected) self.check_proxy(exc, proxy, **expected) tbexc = self.check_proxy(exc, proxy, **expected) - # This cannot match: - self.assertNotIn('Did you mean', str(tbexc)) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) with self.subTest('mismatching proxy'): proxy = type(sys.implementation)( @@ -3678,8 +3678,8 @@ class MyNameError(NameError): proxy = type(sys.implementation)(**expected) self.check_proxy(exc, proxy, **expected) tbexc = self.check_proxy(exc, proxy, **expected) - # This cannot match: - self.assertNotIn('Did you mean', str(tbexc)) + # Verify the special-casing in __init__(). + self.assertIn('Did you mean', str(tbexc)) global_for_suggestions = None diff --git a/Lib/traceback.py b/Lib/traceback.py index c9da65dc8b6776..842c8bc8b6dceb 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -713,10 +713,19 @@ def _match_class(exc_type, *expected): assert expected if exc_type is None: return False - if not isinstance(exc_type, type): - return False - if not issubclass(exc_type, 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 From 42a490409a20b550446497a6e10f8a59f5ebc7a8 Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Fri, 17 Nov 2023 18:34:54 -0700 Subject: [PATCH 5/7] Drop tests for str. --- Lib/test/test_traceback.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 8b656e9dfcd3fd..2e283266ad5a56 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -3272,10 +3272,6 @@ def check_proxy(self, exc, proxy, /, **expected): self._check(tbexc, expected) return tbexc - def check_str(self, exc, qualname, /, **expected): - with self.assertRaises(ValueError): - self._new(exc, qualname) - def check_none(self, exc): expected = {n: None for n in self.ATTRS} tbexc = self._new(exc, None) @@ -3308,10 +3304,6 @@ def test_builtin_exception(self): ) self.check_proxy(exc, proxy, **vars(proxy)) - with self.subTest('str'): - exc_type = expected['__qualname__'] - self.check_str(exc, exc_type, **expected) - with self.subTest('missing'): self.check_none(exc) @@ -3344,10 +3336,6 @@ def test_custom_exception(self): ) self.check_proxy(exc, proxy, **vars(proxy)) - with self.subTest('str'): - exc_type = expected['__qualname__'] - self.check_str(exc, exc_type, **expected) - with self.subTest('missing'): self.check_none(exc) @@ -3416,10 +3404,6 @@ def test_SyntaxError(self): # This cannot match: self.assertFalse(hasattr(tbexc, 'msg')) - with self.subTest('str'): - exc_type = expected['__qualname__'] - self.check_str(exc, exc_type, **expected) - with self.subTest('missing'): self.check_none(exc) @@ -3491,10 +3475,6 @@ def test_ImportError(self): # This cannot match: self.assertNotIn('Did you mean', str(tbexc)) - with self.subTest('str'): - exc_type = expected['__qualname__'] - self.check_str(exc, exc_type, **expected) - with self.subTest('missing'): self.check_none(exc) @@ -3568,10 +3548,6 @@ def test_AttributeError(self): # This cannot match: self.assertNotIn('Did you mean', str(tbexc)) - with self.subTest('str'): - exc_type = expected['__qualname__'] - self.check_str(exc, exc_type, **expected) - with self.subTest('missing'): self.check_none(exc) @@ -3639,10 +3615,6 @@ def test_NameError(self): # This cannot match: self.assertNotIn('Did you mean', str(tbexc)) - with self.subTest('str'): - exc_type = expected['__qualname__'] - self.check_str(exc, exc_type, **expected) - with self.subTest('missing'): self.check_none(exc) From f7c61b29161d67ce4a3e6c60dcad54dbdd18aa34 Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Wed, 22 Nov 2023 14:47:39 -0700 Subject: [PATCH 6/7] Add a NEWS entry. --- .../next/Library/2023-11-22-14-47-28.gh-issue-111922.qc94fY.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2023-11-22-14-47-28.gh-issue-111922.qc94fY.rst 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. From 04a6d8e5ff0b82148a04b3b92fb861d1198f8f1f Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Wed, 22 Nov 2023 17:41:09 -0700 Subject: [PATCH 7/7] Address review comments. --- Lib/test/test_traceback.py | 2 +- Lib/traceback.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 2e283266ad5a56..4bfcfdcc104976 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -49,6 +49,7 @@ def make_module(self, mod_name, code): 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") @@ -2732,7 +2733,6 @@ class Unrepresentable: def __repr__(self) -> str: raise Exception("Unrepresentable") - class TestTracebackException(unittest.TestCase): def test_smoke(self): diff --git a/Lib/traceback.py b/Lib/traceback.py index 842c8bc8b6dceb..f41348a5d75051 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -709,10 +709,12 @@ def _resolve_exc_type(exc_type): raise ValueError(f'unsupported exc_type {exc_type!r}') -def _match_class(exc_type, *expected): +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): @@ -811,7 +813,7 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name) if suggestion: self._str += f". Did you mean: '{suggestion}'?" - elif _match_class(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)