1 | """Simple traceback introspection. Used to add additional information to |
---|
2 | AssertionErrors in tests, so that failure messages may be more informative. |
---|
3 | """ |
---|
4 | import inspect |
---|
5 | import logging |
---|
6 | import re |
---|
7 | import sys |
---|
8 | import textwrap |
---|
9 | import tokenize |
---|
10 | |
---|
11 | try: |
---|
12 | from cStringIO import StringIO |
---|
13 | except ImportError: |
---|
14 | from StringIO import StringIO |
---|
15 | |
---|
16 | log = logging.getLogger(__name__) |
---|
17 | |
---|
18 | def inspect_traceback(tb): |
---|
19 | """Inspect a traceback and its frame, returning source for the expression |
---|
20 | where the exception was raised, with simple variable replacement performed |
---|
21 | and the line on which the exception was raised marked with '>>' |
---|
22 | """ |
---|
23 | log.debug('inspect traceback %s', tb) |
---|
24 | |
---|
25 | # we only want the innermost frame, where the exception was raised |
---|
26 | while tb.tb_next: |
---|
27 | tb = tb.tb_next |
---|
28 | |
---|
29 | frame = tb.tb_frame |
---|
30 | lines, exc_line = tbsource(tb) |
---|
31 | |
---|
32 | # figure out the set of lines to grab. |
---|
33 | inspect_lines, mark_line = find_inspectable_lines(lines, exc_line) |
---|
34 | src = StringIO(textwrap.dedent(''.join(inspect_lines))) |
---|
35 | exp = Expander(frame.f_locals, frame.f_globals) |
---|
36 | |
---|
37 | while inspect_lines: |
---|
38 | try: |
---|
39 | tokenize.tokenize(src.readline, exp) |
---|
40 | except tokenize.TokenError, e: |
---|
41 | # this can happen if our inspectable region happens to butt up |
---|
42 | # against the end of a construct like a docstring with the closing |
---|
43 | # """ on separate line |
---|
44 | log.debug("Tokenizer error: %s", e) |
---|
45 | inspect_lines.pop(0) |
---|
46 | mark_line -= 1 |
---|
47 | src = StringIO(textwrap.dedent(''.join(inspect_lines))) |
---|
48 | exp = Expander(frame.f_locals, frame.f_globals) |
---|
49 | continue |
---|
50 | break |
---|
51 | padded = [] |
---|
52 | if exp.expanded_source: |
---|
53 | exp_lines = exp.expanded_source.split('\n') |
---|
54 | ep = 0 |
---|
55 | for line in exp_lines: |
---|
56 | if ep == mark_line: |
---|
57 | padded.append('>> ' + line) |
---|
58 | else: |
---|
59 | padded.append(' ' + line) |
---|
60 | ep += 1 |
---|
61 | return '\n'.join(padded) |
---|
62 | |
---|
63 | |
---|
64 | def tbsource(tb, context=6): |
---|
65 | """Get source from a traceback object. |
---|
66 | |
---|
67 | A tuple of two things is returned: a list of lines of context from |
---|
68 | the source code, and the index of the current line within that list. |
---|
69 | The optional second argument specifies the number of lines of context |
---|
70 | to return, which are centered around the current line. |
---|
71 | |
---|
72 | .. Note :: |
---|
73 | This is adapted from inspect.py in the python 2.4 standard library, |
---|
74 | since a bug in the 2.3 version of inspect prevents it from correctly |
---|
75 | locating source lines in a traceback frame. |
---|
76 | """ |
---|
77 | |
---|
78 | lineno = tb.tb_lineno |
---|
79 | frame = tb.tb_frame |
---|
80 | |
---|
81 | if context > 0: |
---|
82 | start = lineno - 1 - context//2 |
---|
83 | log.debug("lineno: %s start: %s", lineno, start) |
---|
84 | |
---|
85 | try: |
---|
86 | lines, dummy = inspect.findsource(frame) |
---|
87 | except IOError: |
---|
88 | lines, index = [''], 0 |
---|
89 | else: |
---|
90 | all_lines = lines |
---|
91 | start = max(start, 1) |
---|
92 | start = max(0, min(start, len(lines) - context)) |
---|
93 | lines = lines[start:start+context] |
---|
94 | index = lineno - 1 - start |
---|
95 | |
---|
96 | # python 2.5 compat: if previous line ends in a continuation, |
---|
97 | # decrement start by 1 to match 2.4 behavior |
---|
98 | if sys.version_info >= (2, 5) and index > 0: |
---|
99 | while lines[index-1].strip().endswith('\\'): |
---|
100 | start -= 1 |
---|
101 | lines = all_lines[start:start+context] |
---|
102 | else: |
---|
103 | lines, index = [''], 0 |
---|
104 | log.debug("tbsource lines '''%s''' around index %s", lines, index) |
---|
105 | return (lines, index) |
---|
106 | |
---|
107 | |
---|
108 | def find_inspectable_lines(lines, pos): |
---|
109 | """Find lines in home that are inspectable. |
---|
110 | |
---|
111 | Walk back from the err line up to 3 lines, but don't walk back over |
---|
112 | changes in indent level. |
---|
113 | |
---|
114 | Walk forward up to 3 lines, counting \ separated lines as 1. Don't walk |
---|
115 | over changes in indent level (unless part of an extended line) |
---|
116 | """ |
---|
117 | cnt = re.compile(r'\\[\s\n]*$') |
---|
118 | df = re.compile(r':[\s\n]*$') |
---|
119 | ind = re.compile(r'^(\s*)') |
---|
120 | toinspect = [] |
---|
121 | home = lines[pos] |
---|
122 | home_indent = ind.match(home).groups()[0] |
---|
123 | |
---|
124 | before = lines[max(pos-3, 0):pos] |
---|
125 | before.reverse() |
---|
126 | after = lines[pos+1:min(pos+4, len(lines))] |
---|
127 | |
---|
128 | for line in before: |
---|
129 | if ind.match(line).groups()[0] == home_indent: |
---|
130 | toinspect.append(line) |
---|
131 | else: |
---|
132 | break |
---|
133 | toinspect.reverse() |
---|
134 | toinspect.append(home) |
---|
135 | home_pos = len(toinspect)-1 |
---|
136 | continued = cnt.search(home) |
---|
137 | for line in after: |
---|
138 | if ((continued or ind.match(line).groups()[0] == home_indent) |
---|
139 | and not df.search(line)): |
---|
140 | toinspect.append(line) |
---|
141 | continued = cnt.search(line) |
---|
142 | else: |
---|
143 | break |
---|
144 | log.debug("Inspecting lines '''%s''' around %s", toinspect, home_pos) |
---|
145 | return toinspect, home_pos |
---|
146 | |
---|
147 | |
---|
148 | class Expander: |
---|
149 | """Simple expression expander. Uses tokenize to find the names and |
---|
150 | expands any that can be looked up in the frame. |
---|
151 | """ |
---|
152 | def __init__(self, locals, globals): |
---|
153 | self.locals = locals |
---|
154 | self.globals = globals |
---|
155 | self.lpos = None |
---|
156 | self.expanded_source = '' |
---|
157 | |
---|
158 | def __call__(self, ttype, tok, start, end, line): |
---|
159 | # TODO |
---|
160 | # deal with unicode properly |
---|
161 | |
---|
162 | # TODO |
---|
163 | # Dealing with instance members |
---|
164 | # always keep the last thing seen |
---|
165 | # if the current token is a dot, |
---|
166 | # get ready to getattr(lastthing, this thing) on the |
---|
167 | # next call. |
---|
168 | |
---|
169 | if self.lpos is not None and start[1] >= self.lpos: |
---|
170 | self.expanded_source += ' ' * (start[1]-self.lpos) |
---|
171 | elif start[1] < self.lpos: |
---|
172 | # newline, indent correctly |
---|
173 | self.expanded_source += ' ' * start[1] |
---|
174 | self.lpos = end[1] |
---|
175 | |
---|
176 | if ttype == tokenize.INDENT: |
---|
177 | pass |
---|
178 | elif ttype == tokenize.NAME: |
---|
179 | # Clean this junk up |
---|
180 | try: |
---|
181 | val = self.locals[tok] |
---|
182 | if callable(val): |
---|
183 | val = tok |
---|
184 | else: |
---|
185 | val = repr(val) |
---|
186 | except KeyError: |
---|
187 | try: |
---|
188 | val = self.globals[tok] |
---|
189 | if callable(val): |
---|
190 | val = tok |
---|
191 | else: |
---|
192 | val = repr(val) |
---|
193 | |
---|
194 | except KeyError: |
---|
195 | val = tok |
---|
196 | # FIXME... not sure how to handle things like funcs, classes |
---|
197 | # FIXME this is broken for some unicode strings |
---|
198 | self.expanded_source += val |
---|
199 | else: |
---|
200 | self.expanded_source += tok |
---|
201 | # if this is the end of the line and the line ends with |
---|
202 | # \, then tack a \ and newline onto the output |
---|
203 | # print line[end[1]:] |
---|
204 | if re.match(r'\s+\\\n', line[end[1]:]): |
---|
205 | self.expanded_source += ' \\\n' |
---|