diff --git a/test-requirements.txt b/test-requirements.txt index 9a23970..14e4a45 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +1,4 @@ +contextlib2 +fixtures +testtools unittest2 diff --git a/traceback2/__init__.py b/traceback2/__init__.py index e69de29..8ce2a9d 100644 --- a/traceback2/__init__.py +++ b/traceback2/__init__.py @@ -0,0 +1,316 @@ +"""Extract, format and print information about Python stack traces.""" + +import linecache +import sys +import operator + +__all__ = ['extract_stack', 'extract_tb', 'format_exception', + 'format_exception_only', 'format_list', 'format_stack', + 'format_tb', 'print_exc', 'format_exc', 'print_exception', + 'print_last', 'print_stack', 'print_tb', + 'clear_frames'] + +# +# Formatting and printing lists of traceback lines. +# + +def _format_list_iter(extracted_list): + for filename, lineno, name, line in extracted_list: + item = ' File "{0}", line {1}, in {2}\n'.format(filename, lineno, name) + if line: + item = item + ' {0}\n'.format(line.strip()) + yield item + +def print_list(extracted_list, file=None): + """Print the list of tuples as returned by extract_tb() or + extract_stack() as a formatted stack trace to the given file.""" + if file is None: + file = sys.stderr + for item in _format_list_iter(extracted_list): + file.write(item) + +def format_list(extracted_list): + """Format a list of traceback entry tuples for printing. + + Given a list of tuples as returned by extract_tb() or + extract_stack(), return a list of strings ready for printing. + Each string in the resulting list corresponds to the item with the + same index in the argument list. Each string ends in a newline; + the strings may contain internal newlines as well, for those items + whose source text line is not None. + """ + return list(_format_list_iter(extracted_list)) + +# +# Printing and Extracting Tracebacks. +# + +# extractor takes curr and needs to return a tuple of: +# - Frame object +# - Line number +# - Next item (same type as curr) +# In practice, curr is either a traceback or a frame. +def _extract_tb_or_stack_iter(curr, limit, extractor): + if limit is None: + limit = getattr(sys, 'tracebacklimit', None) + + n = 0 + while curr is not None and (limit is None or n < limit): + f, lineno, next_item = extractor(curr) + co = f.f_code + filename = co.co_filename + name = co.co_name + + linecache.checkcache(filename) + line = linecache.getline(filename, lineno, f.f_globals) + + if line: + line = line.strip() + else: + line = None + + yield (filename, lineno, name, line) + curr = next_item + n += 1 + +def _extract_tb_iter(tb, limit): + return _extract_tb_or_stack_iter( + tb, limit, + operator.attrgetter("tb_frame", "tb_lineno", "tb_next")) + +def print_tb(tb, limit=None, file=None): + """Print up to 'limit' stack trace entries from the traceback 'tb'. + + If 'limit' is omitted or None, all entries are printed. If 'file' + is omitted or None, the output goes to sys.stderr; otherwise + 'file' should be an open file or file-like object with a write() + method. + """ + print_list(extract_tb(tb, limit=limit), file=file) + +def format_tb(tb, limit=None): + """A shorthand for 'format_list(extract_tb(tb, limit))'.""" + return format_list(extract_tb(tb, limit=limit)) + +def extract_tb(tb, limit=None): + """Return list of up to limit pre-processed entries from traceback. + + This is useful for alternate formatting of stack traces. If + 'limit' is omitted or None, all entries are extracted. A + pre-processed stack trace entry is a quadruple (filename, line + number, function name, text) representing the information that is + usually printed for a stack trace. The text is a string with + leading and trailing whitespace stripped; if the source is not + available it is None. + """ + return list(_extract_tb_iter(tb, limit=limit)) + +# +# Exception formatting and output. +# + +_cause_message = ( + "\nThe above exception was the direct cause " + "of the following exception:\n") + +_context_message = ( + "\nDuring handling of the above exception, " + "another exception occurred:\n") + +def _iter_chain(exc, custom_tb=None, seen=None): + if seen is None: + seen = set() + seen.add(exc) + its = [] + context = exc.__context__ + cause = exc.__cause__ + if cause is not None and cause not in seen: + its.append(_iter_chain(cause, False, seen)) + its.append([(_cause_message, None)]) + elif (context is not None and + not exc.__suppress_context__ and + context not in seen): + its.append(_iter_chain(context, None, seen)) + its.append([(_context_message, None)]) + its.append([(exc, custom_tb or exc.__traceback__)]) + # itertools.chain is in an extension module and may be unavailable + for it in its: + for thing in it: + yield thing + +def _format_exception_iter(etype, value, tb, limit, chain): + if chain: + values = _iter_chain(value, tb) + else: + values = [(value, tb)] + + for value, tb in values: + if isinstance(value, str): + # This is a cause/context message line + yield value + '\n' + continue + if tb: + yield 'Traceback (most recent call last):\n' + for it in _format_list_iter(_extract_tb_iter(tb, limit=limit)): + yield it + for it in _format_exception_only_iter(type(value), value): + yield it + +def print_exception(etype, value, tb, limit=None, file=None, chain=True): + """Print exception up to 'limit' stack trace entries from 'tb' to 'file'. + + This differs from print_tb() in the following ways: (1) if + traceback is not None, it prints a header "Traceback (most recent + call last):"; (2) it prints the exception type and value after the + stack trace; (3) if type is SyntaxError and value has the + appropriate format, it prints the line where the syntax error + occurred with a caret on the next line indicating the approximate + position of the error. + """ + if file is None: + file = sys.stderr + for line in _format_exception_iter(etype, value, tb, limit, chain): + file.write(line) + +def format_exception(etype, value, tb, limit=None, chain=True): + """Format a stack trace and the exception information. + + The arguments have the same meaning as the corresponding arguments + to print_exception(). The return value is a list of strings, each + ending in a newline and some containing internal newlines. When + these lines are concatenated and printed, exactly the same text is + printed as does print_exception(). + """ + return list(_format_exception_iter(etype, value, tb, limit, chain)) + +def format_exception_only(etype, value): + """Format the exception part of a traceback. + + The arguments are the exception type and value such as given by + sys.last_type and sys.last_value. The return value is a list of + strings, each ending in a newline. + + Normally, the list contains a single string; however, for + SyntaxError exceptions, it contains several lines that (when + printed) display detailed information about where the syntax + error occurred. + + The message indicating which exception occurred is always the last + string in the list. + + """ + return list(_format_exception_only_iter(etype, value)) + +def _format_exception_only_iter(etype, value): + # Gracefully handle (the way Python 2.4 and earlier did) the case of + # being called with (None, None). + if etype is None: + yield _format_final_exc_line(etype, value) + return + + stype = getattr(etype, '__qualname__', etype.__name__) + smod = etype.__module__ + if smod not in ("__main__", "builtins"): + stype = smod + '.' + stype + + if not issubclass(etype, SyntaxError): + yield _format_final_exc_line(stype, value) + return + + # It was a syntax error; show exactly where the problem was found. + filename = value.filename or "" + lineno = str(value.lineno) or '?' + yield ' File "{0}", line {1}\n'.format(filename, lineno) + + badline = value.text + offset = value.offset + if badline is not None: + yield ' {0}\n'.format(badline.strip()) + if offset is not None: + caretspace = badline.rstrip('\n') + offset = min(len(caretspace), offset) - 1 + caretspace = caretspace[:offset].lstrip() + # non-space whitespace (likes tabs) must be kept for alignment + caretspace = ((c.isspace() and c or ' ') for c in caretspace) + yield ' {0}^\n'.format(''.join(caretspace)) + msg = value.msg or "" + yield "{0}: {1}\n".format(stype, msg) + +def _format_final_exc_line(etype, value): + valuestr = _some_str(value) + if value is None or not valuestr: + line = "%s\n" % etype + else: + line = "%s: %s\n" % (etype, valuestr) + return line + +def _some_str(value): + try: + return str(value) + except: + return '' % type(value).__name__ + +def print_exc(limit=None, file=None, chain=True): + """Shorthand for 'print_exception(*sys.exc_info(), limit, file)'.""" + print_exception(*sys.exc_info(), limit=limit, file=file, chain=chain) + +def format_exc(limit=None, chain=True): + """Like print_exc() but return a string.""" + return "".join(format_exception(*sys.exc_info(), limit=limit, chain=chain)) + +def print_last(limit=None, file=None, chain=True): + """This is a shorthand for 'print_exception(sys.last_type, + sys.last_value, sys.last_traceback, limit, file)'.""" + if not hasattr(sys, "last_type"): + raise ValueError("no last exception") + print_exception(sys.last_type, sys.last_value, sys.last_traceback, + limit, file, chain) + +# +# Printing and Extracting Stacks. +# + +def _extract_stack_iter(f, limit=None): + return _extract_tb_or_stack_iter( + f, limit, lambda f: (f, f.f_lineno, f.f_back)) + +def _get_stack(f): + if f is None: + f = sys._getframe().f_back.f_back + return f + +def print_stack(f=None, limit=None, file=None): + """Print a stack trace from its invocation point. + + The optional 'f' argument can be used to specify an alternate + stack frame at which to start. The optional 'limit' and 'file' + arguments have the same meaning as for print_exception(). + """ + print_list(extract_stack(_get_stack(f), limit=limit), file=file) + +def format_stack(f=None, limit=None): + """Shorthand for 'format_list(extract_stack(f, limit))'.""" + return format_list(extract_stack(_get_stack(f), limit=limit)) + +def extract_stack(f=None, limit=None): + """Extract the raw traceback from the current stack frame. + + The return value has the same format as for extract_tb(). The + optional 'f' and 'limit' arguments have the same meaning as for + print_stack(). Each item in the list is a quadruple (filename, + line number, function name, text), and the entries are in order + from oldest to newest stack frame. + """ + stack = list(_extract_stack_iter(_get_stack(f), limit=limit)) + stack.reverse() + return stack + +def clear_frames(tb): + "Clear all references to local variables in the frames of a traceback." + while tb is not None: + try: + tb.tb_frame.clear() + except RuntimeError: + # Ignore the exception raised if the frame is still executing. + pass + tb = tb.tb_next diff --git a/traceback2/tests/test_traceback.py b/traceback2/tests/test_traceback.py new file mode 100644 index 0000000..3598d3f --- /dev/null +++ b/traceback2/tests/test_traceback.py @@ -0,0 +1,461 @@ +"""Test cases for traceback module""" + +import doctest +from io import StringIO +import sys +import re + +import contextlib2 as contextlib +import fixtures +import six +try: + from six import raise_from +except ImportError: +# support raise_from on 3.x: +# submitted to six: https://bitbucket.org/gutworth/six/issue/102/raise-foo-from-bar-is-a-syntax-error-on-27 + if sys.version_info[:2] > (3, 2): + six.exec_("""def raise_from(value, from_value): + raise value from from_value + """) + else: + def raise_from(value, from_value): + raise value +import unittest2 as unittest +import testtools +from testtools.matchers import DocTestMatches + +import traceback2 as traceback + + +@contextlib.contextmanager +def captured_output(streamname): + stream = StringIO() + patch = fixtures.MonkeyPatch('sys.%s' % streamname, stream) + with patch: + yield stream + + +class SyntaxTracebackCases(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. + + def get_exception_format(self, func, exc): + try: + func() + except exc as value: + return traceback.format_exception_only(exc, value) + else: + raise ValueError("call did not raise exception") + + def syntax_error_with_caret(self): + compile("def fact(x):\n\treturn x!\n", "?", "exec") + + def syntax_error_with_caret_2(self): + compile("1 +\n", "?", "exec") + + def syntax_error_bad_indentation(self): + compile("def spam():\n print(1)\n print(2)", "?", "exec") + + def syntax_error_with_caret_non_ascii(self): + compile('Python = "\u1e54\xfd\u0163\u0125\xf2\xf1" +', "?", "exec") + + def syntax_error_bad_indentation2(self): + compile(" print(2)", "?", "exec") + + def test_caret(self): + err = self.get_exception_format(self.syntax_error_with_caret, + SyntaxError) + self.assertEqual(len(err), 4) + self.assertTrue(err[1].strip() == "return x!") + self.assertIn("^", err[2]) # third line has caret + self.assertEqual(err[1].find("!"), err[2].find("^")) # in the right place + + err = self.get_exception_format(self.syntax_error_with_caret_2, + SyntaxError) + self.assertIn("^", err[2]) # third line has caret + self.assertEqual(err[2].count('\n'), 1) # and no additional newline + self.assertEqual(err[1].find("+"), err[2].find("^")) # in the right place + + err = self.get_exception_format(self.syntax_error_with_caret_non_ascii, + SyntaxError) + self.assertIn("^", err[2]) # third line has caret + self.assertEqual(err[2].count('\n'), 1) # and no additional newline + self.assertEqual(err[1].find("+"), err[2].find("^")) # in the right place + + def test_nocaret(self): + exc = SyntaxError("error", ("x.py", 23, None, "bad syntax")) + err = traceback.format_exception_only(SyntaxError, exc) + self.assertEqual(len(err), 3) + self.assertEqual(err[1].strip(), "bad syntax") + + def test_bad_indentation(self): + err = self.get_exception_format(self.syntax_error_bad_indentation, + IndentationError) + self.assertEqual(len(err), 4) + self.assertEqual(err[1].strip(), "print(2)") + self.assertIn("^", err[2]) + self.assertEqual(err[1].find(")"), err[2].find("^")) + + err = self.get_exception_format(self.syntax_error_bad_indentation2, + IndentationError) + self.assertEqual(len(err), 4) + self.assertEqual(err[1].strip(), "print(2)") + self.assertIn("^", err[2]) + self.assertEqual(err[1].find("p"), err[2].find("^")) + + def test_base_exception(self): + # Test that exceptions derived from BaseException are formatted right + e = KeyboardInterrupt() + lst = traceback.format_exception_only(e.__class__, e) + self.assertEqual(lst, ['KeyboardInterrupt\n']) + + def test_format_exception_only_bad__str__(self): + class X(Exception): + def __str__(self): + 1/0 + err = traceback.format_exception_only(X, X()) + self.assertEqual(len(err), 1) + str_value = '' % X.__name__ + if X.__module__ in ('__main__', 'builtins'): + str_name = X.__qualname__ + else: + str_name = '.'.join([X.__module__, X.__qualname__]) + self.assertEqual(err[0], "%s: %s\n" % (str_name, str_value)) + + def test_without_exception(self): + err = traceback.format_exception_only(None, None) + self.assertEqual(err, ['None\n']) + + def test_encoded_file(self): + # Test that tracebacks are correctly printed for encoded source files: + # - correct line number (Issue2384) + # - respect file encoding (Issue3975) + import tempfile, sys, subprocess, os + + # The spawned subprocess has its stdout redirected to a PIPE, and its + # encoding may be different from the current interpreter, on Windows + # at least. + process = subprocess.Popen([sys.executable, "-c", + "import sys; print(sys.stdout.encoding)"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + stdout, stderr = process.communicate() + output_encoding = str(stdout, 'ascii').splitlines()[0] + + def do_test(firstlines, message, charset, lineno): + # Raise the message in a subprocess, and catch the output + with fixtures.TempDir() as d: + TESTFN = d.path + '/fname' + output = open(TESTFN, "w", encoding=charset) + output.write("""{0}if 1: + import traceback; + raise RuntimeError('{1}') + """.format(firstlines, message)) + output.close() + process = subprocess.Popen([sys.executable, TESTFN], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + stdout, stderr = process.communicate() + stdout = stdout.decode(output_encoding).splitlines() + + # The source lines are encoded with the 'backslashreplace' handler + encoded_message = message.encode(output_encoding, + 'backslashreplace') + # and we just decoded them with the output_encoding. + message_ascii = encoded_message.decode(output_encoding) + + err_line = "raise RuntimeError('{0}')".format(message_ascii) + err_msg = "RuntimeError: {0}".format(message_ascii) + + self.assertIn(("line %s" % lineno), stdout[1], + "Invalid line number: {0!r} instead of {1}".format( + stdout[1], lineno)) + self.assertTrue(stdout[2].endswith(err_line), + "Invalid traceback line: {0!r} instead of {1!r}".format( + stdout[2], err_line)) + self.assertTrue(stdout[3] == err_msg, + "Invalid error message: {0!r} instead of {1!r}".format( + stdout[3], err_msg)) + + do_test("", "foo", "ascii", 3) + for charset in ("ascii", "iso-8859-1", "utf-8", "GBK"): + if charset == "ascii": + text = "foo" + elif charset == "GBK": + text = "\u4E02\u5100" + else: + text = "h\xe9 ho" + do_test("# coding: {0}\n".format(charset), + text, charset, 4) + do_test("#!shebang\n# coding: {0}\n".format(charset), + text, charset, 5) + do_test(" \t\f\n# coding: {0}\n".format(charset), + text, charset, 5) + # Issue #18960: coding spec should has no effect + do_test("0\n# coding: GBK\n", "h\xe9 ho", 'utf-8', 5) + + +class TracebackFormatTests(unittest.TestCase): + + def some_exception(self): + raise KeyError('blah') + + def check_traceback_format(self, cleanup_func=None): + try: + from _testcapi import traceback_print + except ImportError: + traceback_print = None + try: + self.some_exception() + except KeyError: + type_, value, tb = sys.exc_info() + if cleanup_func is not None: + # Clear the inner frames, not this one + cleanup_func(tb.tb_next) + traceback_fmt = 'Traceback (most recent call last):\n' + \ + ''.join(traceback.format_tb(tb)) + if traceback_print is not None: + file_ = StringIO() + traceback_print(tb, file_) + python_fmt = file_.getvalue() + # Call all _tb and _exc functions + with captured_output("stderr") as tbstderr: + traceback.print_tb(tb) + tbfile = StringIO() + traceback.print_tb(tb, file=tbfile) + with captured_output("stderr") as excstderr: + traceback.print_exc() + excfmt = traceback.format_exc() + excfile = StringIO() + traceback.print_exc(file=excfile) + else: + self.fail("unable to create test traceback string") + + # Make sure that Python and the traceback module format the same thing + if traceback_print is not None: + self.assertEqual(traceback_fmt, python_fmt) + # Now verify the _tb func output + self.assertEqual(tbstderr.getvalue(), tbfile.getvalue()) + # Now verify the _exc func output + self.assertEqual(excstderr.getvalue(), excfile.getvalue()) + self.assertEqual(excfmt, excfile.getvalue()) + + # Make sure that the traceback is properly indented. + tb_lines = traceback_fmt.splitlines() + self.assertEqual(len(tb_lines), 5) + banner = tb_lines[0] + location, source_line = tb_lines[-2:] + self.assertTrue(banner.startswith('Traceback')) + self.assertTrue(location.startswith(' File')) + self.assertTrue(source_line.startswith(' raise')) + + def test_traceback_format(self): + self.check_traceback_format() + + def test_traceback_format_with_cleared_frames(self): + # Check that traceback formatting also works with a clear()ed frame + def cleanup_tb(tb): + if getattr(tb.tb_frame, 'clear_frames', None): + tb.tb_frame.clear() + self.check_traceback_format(cleanup_tb) + + def test_stack_format(self): + # Verify _stack functions. Note we have to use _getframe(1) to + # compare them without this frame appearing in the output + with captured_output("stderr") as ststderr: + traceback.print_stack(sys._getframe(1)) + stfile = StringIO() + traceback.print_stack(sys._getframe(1), file=stfile) + self.assertEqual(ststderr.getvalue(), stfile.getvalue()) + + stfmt = traceback.format_stack(sys._getframe(1)) + + self.assertEqual(ststderr.getvalue(), "".join(stfmt)) + + +cause_message = ( + "\nThe above exception was the direct cause " + "of the following exception:\n\n") + +context_message = ( + "\nDuring handling of the above exception, " + "another exception occurred:\n\n") + +boundaries = re.compile( + '(%s|%s)' % (re.escape(cause_message), re.escape(context_message))) + + +class BaseExceptionReportingTests: + + def get_exception(self, exception_or_callable): + if isinstance(exception_or_callable, Exception): + return exception_or_callable + try: + exception_or_callable() + except Exception as e: + return e + + def zero_div(self): + 1/0 # In zero_div + + def check_zero_div(self, msg): + lines = msg.splitlines() + self.assertTrue(lines[-3].startswith(' File')) + self.assertIn('1/0 # In zero_div', lines[-2]) + self.assertTrue(lines[-1].startswith('ZeroDivisionError'), lines[-1]) + + def test_simple(self): + try: + 1/0 # Marker + except ZeroDivisionError as _: + e = _ + lines = self.get_report(e).splitlines() + self.assertEqual(len(lines), 4) + self.assertTrue(lines[0].startswith('Traceback')) + self.assertTrue(lines[1].startswith(' File')) + self.assertIn('1/0 # Marker', lines[2]) + self.assertTrue(lines[3].startswith('ZeroDivisionError')) + + def test_cause(self): + def inner_raise(): + try: + self.zero_div() + except ZeroDivisionError as e: + raise_from(KeyError, e) + def outer_raise(): + inner_raise() # Marker + blocks = boundaries.split(self.get_report(outer_raise)) + self.assertEqual(len(blocks), 3) + self.assertEqual(blocks[1], cause_message) + self.check_zero_div(blocks[0]) + self.assertIn('inner_raise() # Marker', blocks[2]) + + def test_context(self): + def inner_raise(): + try: + self.zero_div() + except ZeroDivisionError: + raise KeyError + def outer_raise(): + inner_raise() # Marker + blocks = boundaries.split(self.get_report(outer_raise)) + self.assertEqual(len(blocks), 3) + self.assertEqual(blocks[1], context_message) + self.check_zero_div(blocks[0]) + self.assertIn('inner_raise() # Marker', blocks[2]) + + def test_context_suppression(self): + try: + try: + raise Exception + except: + raise_from(ZeroDivisionError, None) + except ZeroDivisionError as _: + e = _ + lines = self.get_report(e) + self.assertThat(lines, DocTestMatches("""\ +Traceback (most recent call last): + File "...traceback2/tests/test_traceback.py", line ..., in test_context_suppression + raise_from(ZeroDivisionError, None) + File "", line 2, in raise_from +ZeroDivisionError +""", doctest.ELLIPSIS)) + + def test_cause_and_context(self): + # When both a cause and a context are set, only the cause should be + # displayed and the context should be muted. + def inner_raise(): + try: + self.zero_div() + except ZeroDivisionError as _e: + e = _e + try: + xyzzy + except NameError: + raise_from(KeyError, e) + def outer_raise(): + inner_raise() # Marker + blocks = boundaries.split(self.get_report(outer_raise)) + self.assertEqual(len(blocks), 3) + self.assertEqual(blocks[1], cause_message) + self.check_zero_div(blocks[0]) + self.assertIn('inner_raise() # Marker', blocks[2]) + + def test_cause_recursive(self): + def inner_raise(): + try: + try: + self.zero_div() + except ZeroDivisionError as e: + z = e + raise_from(KeyError, e) + except KeyError as e: + raise_from(z, e) + def outer_raise(): + inner_raise() # Marker + blocks = boundaries.split(self.get_report(outer_raise)) + self.assertEqual(len(blocks), 3) + self.assertEqual(blocks[1], cause_message) + # The first block is the KeyError raised from the ZeroDivisionError + self.assertIn('raise_from(KeyError, e)', blocks[0]) + self.assertNotIn('1/0', blocks[0]) + # The second block (apart from the boundary) is the ZeroDivisionError + # re-raised from the KeyError + self.assertIn('inner_raise() # Marker', blocks[2]) + self.check_zero_div(blocks[2]) + + def test_syntax_error_offset_at_eol(self): + # See #10186. + def e(): + raise SyntaxError('', ('', 0, 5, 'hello')) + msg = self.get_report(e).splitlines() + self.assertEqual(msg[-2], " ^") + def e(): + exec("x = 5 | 4 |") + msg = self.get_report(e).splitlines() + self.assertEqual(msg[-2], ' ^') + + +class PyExcReportingTests(BaseExceptionReportingTests, testtools.TestCase): + # + # This checks reporting through the 'traceback' module, with both + # format_exception() and print_exception(). + # + + def get_report(self, e): + e = self.get_exception(e) + s = ''.join( + traceback.format_exception(type(e), e, e.__traceback__)) + with captured_output("stderr") as sio: + traceback.print_exception(type(e), e, e.__traceback__) + self.assertEqual(sio.getvalue(), s) + return s + + +class MiscTracebackCases(unittest.TestCase): + # + # Check non-printing functions in traceback module + # + + def test_clear(self): + def outer(): + middle() + def middle(): + inner() + def inner(): + i = 1 + 1/0 + + try: + outer() + except: + type_, value, tb = sys.exc_info() + + # Initial assertion: there's one local in the inner frame. + inner_frame = tb.tb_next.tb_next.tb_next.tb_frame + self.assertEqual(len(inner_frame.f_locals), 1) + + # Clear traceback frames + traceback.clear_frames(tb) + + # Local variable dict should now be empty. + self.assertEqual(len(inner_frame.f_locals), 0)