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 <module> 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 <<EOF set locationData to {%s} tell application "BBEdit" activate set resultItems to {} set numLocations to count locationData repeat with locationIndex from 1 to numLocations set {locationMessage, locationFile, locationLine} to item locationIndex of locationData set locationLine to locationLine as number if locationIndex equals numLocations then set rKind to error_kind else set rKind to note_kind end if set resultEntry to {message:locationMessage, result_kind:rKind, result_file:locationFile , result_line:locationLine, message_script:0} copy resultEntry to end of resultItems end repeat make new results browser with data resultItems with properties {name:"Python Traceback"} end tell on |splittext|(delimiter, someText) set prevTIDs to AppleScript's text item delimiters set AppleScript's text item delimiters to delimiter set output to text items of someText set AppleScript's text item delimiters to prevTIDs return output end |splittext| EOF """ list_items = [] for sd in stack_descs: if sd.statement is None: message = "" else: message = sd.statement.replace('"',r'\"') list_items.append('{"%s", "%s", "%s"}' % (message, sd.filepath, sd.line_num)) list_items_text = ",".join(list_items) script = script_template % list_items_text p = subprocess.Popen(script, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) _prog_usage = '%prog [options] [ID# [ID# [ID# [...]]]]' _prog_version = 'Python Stack Trace Parser Version 1.0' _prog_description = """\ Parses last Python stack trace in current Terminal history and opens most recently referenced source file for editing at the correct line. If ID# is given, then the file referenced in the stack frame indexed by ID# will be opened (the most recent call will be indexed by 0, the previous stack frame will be indexed by 1, etc. all the way to the first stack frame). If ID# is preceded by a '^', then indexed starts at the first stack frame (i.e., the first stack frame will be "^1", the second stack frame is "^2" etc.). """ def main(): """ Main CLI handler. """ parser = OptionParser(usage=_prog_usage, add_help_option=True, version=_prog_version, description=_prog_description) parser.add_option('-r', '--recent-only', action='store_true', dest='most_recent_only', default=False, help='only open most recent stack track in Terminal history (default is all)') parser.add_option('-e', '--execute', action='store', dest='execute', metavar='<PYTHON>', default=None, help="Python traceback will be read from results of <PYTHON> (e.g. 'pystack.py -e test.py', 'pystack.py -e '-c print(\"hello\")'") parser.add_option('--stdin', action='store_true', dest='from_stdin', default=False, help="Python traceback will be read from standard input (e.g. 'python test.py 2>&1 | pystack.py --stdin'") parser.add_option('-l', '--list', action='store_true', dest='show_list', default=False, help='list parsed stack track') parser.add_option('-b', '--browse', action='store_true', dest='bbedit_browse', default=False, help='browse list in BBEdit') parser.add_option('-a', '--open-all', action='store_true', dest='open_all_files', default=False, help='open all files referenced in stack trace') parser.add_option('-i', '--interactive', action='store_true', dest='interactive', default=False, help='list parsed stack track and prompt for file to edit') parser.add_option('-c', '--color', action='store_true', dest='color', default=False, help='display in color') parser.add_option('--editor', action='store', dest='editor_app', default='bbedit', help="path to editor application (default = '%default')") parser.add_option('-o', '--editor-opts', dest='editor_opts', help='options to be passed to editor') parser.add_option('-n', '--new-window', action='store_const', dest='editor_new_window', const='--new-window', help='open new text editor window (BBEdit only)') parser.add_option('-f', '--front-window', action='store_const', dest='editor_front_window', const='--front-window', help='open in front text editor window (BBEdit only)') parser.add_option('-q', '--quiet', action='store_true', dest='quiet', help='suppress messages and standard output when executing python') (opts, args) = parser.parse_args() if opts.execute: pycmd = "python " + opts.execute p = subprocess.Popen(pycmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) p.wait() stdout_lines = p.stdout.readlines() stderr_lines = p.stderr.readlines() if not opts.quiet: sys.stdout.write("\n".join(stdout_lines)) stack_descs, error_str = parse_traceback(stderr_lines) elif opts.from_stdin: traceback_lines = sys.stdin.readlines() stack_descs, error_str = parse_traceback(traceback_lines) else: stack_descs, error_str = get_traceback_stack(most_recent_only=opts.most_recent_only) if len(stack_descs) == 0: sys.exit("Python stack trace not found.") if opts.show_list: display_stack_descs(stack_descs, error_str, opts.color) sys.exit(0) if opts.bbedit_browse: bbedit_browse(stack_descs, error_str) sys.exit(0) if opts.editor_opts is not None: editor_opts = opts.editor_opts else: editor_opts = "" if opts.editor_new_window: editor_opts = "--new-window " + editor_opts if opts.editor_front_window: editor_opts = "--front-window " + editor_opts editor_invocation = "%s %s" % (opts.editor_app, editor_opts) if opts.interactive: display_stack_descs(stack_descs, error_str, color=opts.color) idx = None while idx is None: idx = raw_input("Stack frame reference to edit ('q' to quit): ") if idx == "" or idx.lower() == "q": sys.exit(0) try: idx = int(idx) except ValueError, e: sys.stderr.write("'%s' is an invalid selection.\n" % idx) idx = None if idx >= 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()
feed
Comments
0 comments postedPost new comment