Parse Python Stack Trace and Open Selected Source References for Editing in OS X

UPDATE Nov 7, 2009: Better parsing of traceback.

UPDATE Nov 4, 2009: Now passing a "-b" flag to the script opens the parsed stack frame references in a BBEdit results browser, inspired by an AppleScript script by Marc Liyanage.

When things go wrong in a Python script, the interpreter dumps a stack trace, which looks something like this:

$ python y.py
Calling f1 ...
Traceback (most recent call last):
  File "y.py", line 6, in 
    x.f3()
  File "/Users/jeet/Scratch/snippets/x.py", line 15, in f3
    f2()
  File "/Users/jeet/Scratch/snippets/x.py", line 11, in f2
    f1()
  File "/Users/jeet/Scratch/snippets/x.py", line 7, in f1
    print "Hello, %s" % value
NameError: global name 'value' is not defined

I got tired of hunting through the stack trace for the line number, and then returning to my text editor to find the file and then navigating down to the line number (yes, I can be that lazy). So I wrote the following script.

The following Python script searches for text that resembles a Python stack trace in the current history of the OS X Terminal application and parses it into its components (source file, line number, statement). By default, it opens the source file associated with the most recent stack frame for editing at the appropriate line. By passing the flag "-a", it opens all the source files referenced throughout the trace at the appropriate lines. Alternatively, specific frames/files can be selected by specifying the the index at the command line. You can also simply list the parsed stack trace ("-l"), or enter an interactive mode ("-l"), where by typing an index opens the associated file for editing at the correct location. The option "-c" displays the stack frame list in color, while the "-r" option restricts the results to only the most recent traceback. BBEdit users will appreciate the "-b" option, which opens a BBEdit results browser on all the parsed stacked frames.

#! /usr/bin/env python

import subprocess
import StringIO
import re
import os
import sys

from optparse import OptionGroup
from optparse import OptionParser

class StackDesc(object):
    def __init__(self, items):
        self.filepath = os.path.abspath(items[0])
        self.line_num = int(items[1])
        if len(items) >= 4 and items[3] is not None:
            self.object_name = items[3]
        else:
            self.object_name = ""
        self.statement = None

def scrape_terminal_history():
    script = """\
/usr/bin/osascript -s o -e   '
tell application "Terminal"
activate
    tell front window
        return (the history of selected tab)
    end tell
end tell
'
"""
    p = subprocess.Popen(script, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, stderr = p.communicate()
    return stdout, stderr

def scrape_traceback_lines(most_recent_only=False):
    stdout, stderr = scrape_terminal_history()
    stdout = stdout.split("\n")[::-1]
    if most_recent_only:
        for idx, f in enumerate(stdout):
            if f.startswith("Traceback (most recent call last)"):
                tb = (stdout[0:idx])[::-1]
                return tb
    else:
        return stdout[::-1]
    return []

def parse_traceback(traceback_lines):
    pattern = re.compile("""^\s+File \"(.*)\", line (\d+)(, in (.*))*\s*""")
    if traceback_lines is None or len(traceback_lines) == 0:
        return [], ""
    stack_descs = []
    error_str = None
    prev_line_is_stack_frame = False
    for tb in traceback_lines:
        m = pattern.match(tb)
        if m is not None:
            items = m.groups()
            stack_descs.append(StackDesc(items))
            prev_line_is_stack_frame = True
        elif prev_line_is_stack_frame:
            stack_descs[-1].statement = tb.strip()
            prev_line_is_stack_frame = False
        elif error_str is None and len(stack_descs) > 0:
            tbs = tb.strip()
            if tbs and tbs != "^":
                error_str = tbs
            elif tbs == "^":
                error_str = tb
            prev_line_is_stack_frame = False
        elif (error_str is not None and error_str.strip() == "^"):
            error_str = error_str + "\n" + tb.strip()
            prev_line_is_stack_frame = False
    return stack_descs, error_str

def get_traceback_stack(most_recent_only=False):
    traceback_lines = scrape_traceback_lines(most_recent_only)
    stack_descs, error_str = parse_traceback(traceback_lines)
    return stack_descs, error_str

def compose_ansi_color(code):
    return chr(27) + '[' + code + 'm'

def display_stack_descs(stack_descs, error_str, color):
    if color:
        title_color = compose_ansi_color("1;91")
        error_color = compose_ansi_color("1;91")
        index_color = compose_ansi_color("0;37;40")
        filepath_color = compose_ansi_color("1;94")
        line_num_color = compose_ansi_color("1;94")
        obj_color = compose_ansi_color("1;34")
        statement_color = compose_ansi_color("0;31")
        clear_color = compose_ansi_color("0")
    else:
        title_color = ""
        error_color = ""
        index_color = ""
        filepath_color = ""
        line_num_color = ""
        obj_color = ""
        statement_color = ""
        clear_color = ""
    sys.stdout.write("%sPython stack trace (most recent call last):%s\n" % (title_color, clear_color))
    for sdi, sd in enumerate(stack_descs):
        if sd.object_name:
            obj_name = ": %s%s%s" % (obj_color, sd.object_name, clear_color)
        else:
            obj_name = ""
        sys.stdout.write("%s% 3d %s: %s%s%s [%s%d%s]%s\n" % (
                index_color, sdi, clear_color,
                filepath_color, sd.filepath, clear_color,
                line_num_color, sd.line_num, clear_color,
                obj_name))
        if sd.statement:
            sys.stdout.write("      >>> %s%s%s\n" % (statement_color, sd.statement, clear_color))
    if error_str:
        sys.stdout.write("%s%s%s\n" % (error_color, error_str, clear_color))


def bbedit_browse(stack_descs, error_str):
    script_template = """
/usr/bin/osascript 2>/dev/null <= len(stack_descs):
                sys.stderr.write("Index %d is out of bounds [0, %d]" % (idx, len(stack_descs)-1))
                idx = None
            if idx is not None:
                sd = stack_descs[idx]
                command = "%s +%d %s" % (editor_invocation, sd.line_num, sd.filepath)
                edp = subprocess.Popen(command, shell=True)
                idx = None
    else:
        indexes = []
        if opts.open_all_files:
            indexes = range(0, len(stack_descs)-1)
        elif len(args) == 0:
            indexes = [-1]
        else:
            for i in args:
                if i.startswith("^"):
                    i = -1 * int(i[1:])
                else:
                    i = int(i)
                if i > len(stack_descs):
                    sys.exit("Index %d is out of bounds [0, %d]" % (i, len(stack_descs)-1))
                indexes.append(i)
        editor_args = []
        for i in indexes:
            editor_args.append("+%d %s" % (stack_descs[i].line_num, stack_descs[i].filepath))
        editor_args = " ".join(editor_args)
        command = "%s %s" % (editor_invocation, editor_args)
        edp = subprocess.Popen(command, shell=True)

if __name__ == '__main__':
    main()

Leave a Reply

Your email address will not be published. Required fields are marked *