How can I modify a Python traceback object when raising an exception?

Viewed 13687

I'm working on a Python library used by third-party developers to write extensions for our core application.

I'd like to know if it's possible to modify the traceback when raising exceptions, so the last stack frame is the call to the library function in the developer's code, rather than the line in the library that raised the exception. There are also a few frames at the bottom of the stack containing references to functions used when first loading the code that I'd ideally like to remove too.

Thanks in advance for any advice!

7 Answers

Starting with Python 3.7, you can instantiate a new traceback object and use the .with_traceback() method when throwing. Here's some demo code using either sys._getframe(1) (or a more robust alternative) that raises an AssertionError while making your debugger believe the error occurred in myassert(False): sys._getframe(1) omits the top stack frame.

What I should add is that while this looks fine in the debugger, the console behavior unveils what this is really doing:

Traceback (most recent call last):
  File ".\test.py", line 35, in <module>
    myassert_false()
  File ".\test.py", line 31, in myassert_false
    myassert(False)
  File ".\test.py", line 26, in myassert
    raise AssertionError().with_traceback(back_tb)
  File ".\test.py", line 31, in myassert_false
    myassert(False)
AssertionError

Rather than removing the top of the stack, I have added a duplicate of the second-to-last frame.

Anyway, I focus on how the debugger behaves, and it seems this one works correctly:

"""Modify traceback on exception.

See also https://github.com/python/cpython/commit/e46a8a
"""

import sys
import types


def myassert(condition):
    """Throw AssertionError with modified traceback if condition is False."""
    if condition:
        return

    # This function ... is not guaranteed to exist in all implementations of Python.
    # https://docs.python.org/3/library/sys.html#sys._getframe
    # back_frame = sys._getframe(1)
    try:
        raise AssertionError
    except AssertionError:
        traceback = sys.exc_info()[2]
        back_frame = traceback.tb_frame.f_back

    back_tb = types.TracebackType(tb_next=None,
                                  tb_frame=back_frame,
                                  tb_lasti=back_frame.f_lasti,
                                  tb_lineno=back_frame.f_lineno)
    raise AssertionError().with_traceback(back_tb)


def myassert_false():
    """Test myassert(). Debugger should point at the next line."""
    myassert(False)


if __name__ == "__main__":
    myassert_false()

enter image description here

For python3, here's my answer. Please read the comments for an explanation:

def pop_exception_traceback(exception,n=1):
    #Takes an exception, mutates it, then returns it
    #Often when writing my repl, tracebacks will contain an annoying level of function calls (including the 'exec' that ran the code)
    #This function pops 'n' levels off of the stack trace generated by exception
    #For example, if print_stack_trace(exception) originally printed:
    #   Traceback (most recent call last):
    #   File "<string>", line 2, in <module>
    #   File "<string>", line 2, in f
    #   File "<string>", line 2, in g
    #   File "<string>", line 2, in h
    #   File "<string>", line 2, in j
    #   File "<string>", line 2, in k
    #Then print_stack_trace(pop_exception_traceback(exception),3) would print: 
    #   File "<string>", line 2, in <module>
    #   File "<string>", line 2, in j
    #   File "<string>", line 2, in k
    #(It popped the first 3 levels, aka f g and h off the traceback)
    for _ in range(n):
        exception.__traceback__=exception.__traceback__.tb_next
    return exception

This code might be of interest for you.

It takes a traceback and removes the first file, which should not be shown. Then it simulates the Python behavior:

Traceback (most recent call last):

will only be shown if the traceback contains more than one file. This looks exactly as if my extra frame was not there.

Here my code, assuming there is a string text:

try:
    exec(text)
except:
    # we want to format the exception as if no frame was on top.
    exp, val, tb = sys.exc_info()
    listing = traceback.format_exception(exp, val, tb)
    # remove the entry for the first frame
    del listing[1]
    files = [line for line in listing if line.startswith("  File")]
    if len(files) == 1:
        # only one file, remove the header.
        del listing[0]
    print("".join(listing), file=sys.stderr)
    sys.exit(1)
Related