OLD | NEW |
---|---|
(Empty) | |
1 # Copyright 2014 The LUCI Authors. All rights reserved. | |
2 # Use of this source code is governed under the Apache License, Version 2.0 | |
3 # that can be found in the LICENSE file. | |
4 | |
5 """Provides simulator test coverage for individual recipes.""" | |
6 | |
7 import ast | |
8 import copy | |
9 import re | |
10 import inspect | |
11 | |
12 from collections import OrderedDict | |
13 | |
14 from . import env | |
15 import astunparse | |
16 import expect_tests | |
17 | |
18 class _checkTransformer(ast.NodeTransformer): | |
19 """_checkTransformer is an ast NodeTransformer which extracts the helpful | |
20 subexpressions from a python expression (specificially, from an invocation of | |
21 the Checker). These subexpressions will be printed along with the check's | |
22 source code statement to provide context for the failed check. | |
23 | |
24 It knows the following transformations: | |
25 * all python identifiers will be resolved to their local variable meaning. | |
26 * `___ in <instance of dict>` will cause dict.keys() to be printed in lieu | |
27 of the entire dictionary. | |
28 * `a[b][c]` will cause `a[b]` and `a[b][c]` to be printed (for an arbitrary | |
29 level of recursion) | |
30 | |
31 The transformed ast is NOT a valid python AST... In particular, every reduced | |
32 subexpression will be an ast.Name() where the id is the code for the | |
33 subexpression (which may not be a valid name! It could be `foo.bar()`.), and | |
34 the ctx will be the eval'd value for that element. | |
35 | |
36 In addition to this, there will be a list of ast.Name nodes in the | |
37 transformer's `extra` attribute for additional expressions which should be | |
38 printed for debugging usefulness, but didn't fit into the ast tree anywhere. | |
39 """ | |
40 | |
41 def __init__(self, lvars, gvars): | |
42 self.lvars = lvars | |
43 self.gvars = gvars | |
44 self.extras = [] | |
45 | |
46 def _eval(self, node): | |
47 code = astunparse.unparse(node).strip() | |
48 try: | |
49 thing = eval(code, self.gvars, self.lvars) | |
50 return ast.Name(code, thing) | |
51 except NameError: | |
52 return node | |
53 | |
54 def visit_Compare(self, node): | |
55 # match `___ in instanceof(dict)` | |
martiniss
2016/10/10 18:53:12
Could you turn this into a doc string? I didn't re
| |
56 node = self.generic_visit(node) | |
57 | |
58 if len(node.ops) == 1 and isinstance(node.ops[0], ast.In): | |
59 cmps = node.comparators | |
60 if len(cmps) == 1 and isinstance(cmps[0], ast.Name): | |
61 name = cmps[0] | |
62 if isinstance(name.ctx, dict): | |
63 node = ast.Compare( | |
64 node.left, | |
65 node.ops, | |
66 [ast.Name(name.id+".keys()", name.ctx.keys())]) | |
67 | |
68 return node | |
69 | |
70 def visit_Subscript(self, node): | |
71 # match __[a] | |
martiniss
2016/10/10 18:53:12
Turn into docstring :)
| |
72 node = self.generic_visit(node) | |
73 if isinstance(node.slice, ast.Index): | |
74 if isinstance(node.slice.value, ast.Name): | |
75 self.extras.append(self._eval(node.slice.value)) | |
76 node = self._eval(node) | |
77 return node | |
78 | |
79 def visit_Name(self, node): | |
80 # match foo | |
martiniss
2016/10/10 18:53:12
Turn into docstring :)
| |
81 return self._eval(node) | |
82 | |
83 | |
84 def render_user_value(val): | |
85 """Takes a subexpression user value, and attempts to render it in the most | |
86 useful way possible. | |
87 | |
88 Currently this will use render_re for compiled regular expressions, and will | |
89 fall back to repr() for everything else. | |
90 | |
91 It should be the goal of this function to return an `eval`able string that | |
92 would yield the equivalent value in a python interpreter. | |
93 """ | |
94 if isinstance(val, re._pattern_type): | |
95 return render_re(val) | |
96 return repr(val) | |
97 | |
98 | |
99 def render_re(regex): | |
100 """Renders a repr()-style value for a compiled regular expression.""" | |
101 actual_flags = [] | |
102 if regex.flags: | |
103 flags = [ | |
104 (re.IGNORECASE, 'IGNORECASE'), | |
105 (re.LOCALE, 'LOCALE'), | |
106 (re.UNICODE, 'UNICODE'), | |
107 (re.MULTILINE, 'MULTILINE'), | |
108 (re.DOTALL, 'DOTALL'), | |
109 (re.VERBOSE, 'VERBOSE'), | |
110 ] | |
111 for val, name in flags: | |
112 if regex.flags & val: | |
113 actual_flags.append(name) | |
114 if actual_flags: | |
115 return 're.compile(%r, %s)' % (regex.pattern, '|'.join(actual_flags)) | |
116 else: | |
117 return 're.compile(%r)' % regex.pattern | |
118 | |
119 | |
120 class Checker(object): | |
121 def __init__(self, filename, lineno, funcname, args, kwargs, *ignores): | |
122 self._failed_checks = [] | |
123 | |
124 # _ignore_set is the set of objects that we should never print as local | |
125 # variables. We start this set off by including the actual Checker object, | |
126 # since there's no value to printing that. | |
127 self._ignore_set = {id(x) for x in ignores+(self,)} | |
128 | |
129 self._ctx_filename = filename | |
130 self._ctx_lineno = lineno | |
131 self._ctx_funcname = funcname | |
132 self._ctx_args = map(repr, args) | |
133 self._ctx_kwargs = {k: repr(v) for k, v in kwargs.iteritems()} | |
134 | |
135 def _process_frame(self, frame, with_vars): | |
136 """This processes a stack frame into an expect_tests.CheckFrame, which | |
137 includes file name, line number, function name (of the function containing | |
138 the frame), the parsed statement at that line, and the relevant local | |
139 variables/subexpressions (if with_vars is True). | |
140 | |
141 In addition to transforming the expression with _checkTransformer, this | |
142 will: | |
143 * omit subexpressions which resolve to callable()'s | |
144 * omit the overall step ordered dictionary | |
145 * transform all subexpression values using render_user_value(). | |
146 """ | |
147 raw_frame, filename, lineno, func_name, _, _ = frame | |
148 | |
149 filelines, _ = inspect.findsource(raw_frame) | |
150 | |
151 i = lineno-1 | |
martiniss
2016/10/10 18:53:12
redundant?
| |
152 # this dumb little loop will try to parse a node out of the ast which ends | |
153 # at the line that shows up in the frame. To do this, tries parsing that | |
154 # line, and if it fails, it adds a prefix line. It keeps doing this until | |
155 # it gets a successful parse. | |
156 for i in xrange(lineno-1, 0, -1): | |
martiniss
2016/10/10 18:53:12
Couldn't you run into a situation like this:
def
| |
157 try: | |
158 to_parse = ''.join(filelines[i:lineno]).strip() | |
159 node = ast.parse(to_parse) | |
160 break | |
161 except SyntaxError: | |
162 continue | |
163 varmap = None | |
164 if with_vars: | |
165 xfrmr = _checkTransformer(raw_frame.f_locals, raw_frame.f_globals) | |
166 | |
167 varmap = {} | |
168 def add_node(n): | |
169 if isinstance(n, ast.Name): | |
170 val = n.ctx | |
171 if isinstance(val, ast.AST): | |
172 return | |
173 if callable(val) or id(val) in self._ignore_set: | |
174 return | |
175 if n.id not in varmap: | |
176 varmap[n.id] = render_user_value(val) | |
177 map(add_node, ast.walk(xfrmr.visit(copy.deepcopy(node)))) | |
178 # TODO(iannucci): only add extras if verbose is True | |
179 map(add_node, xfrmr.extras) | |
180 | |
181 return expect_tests.CheckFrame( | |
182 filename, | |
183 lineno, | |
184 func_name, | |
185 astunparse.unparse(node).strip(), | |
186 varmap | |
187 ) | |
188 | |
189 def _call_impl(self, hint, exp): | |
190 """This implements the bulk of what happens when you run `check(exp)`. It | |
191 will crawl back up the stack and extract information about all of the frames | |
192 which are relevent to the check, including file:lineno and the code | |
193 statement which occurs at that location for all the frames. | |
194 | |
195 On the last frame (the one that actually contains the check call), it will | |
196 also try to obtain relevant local values in the check so they can be printed | |
197 with the check to aid in debugging and diagnosis. It uses the parsed | |
198 statement found at that line to find all referenced local variables in that | |
199 frame. | |
200 """ | |
201 | |
202 if exp: | |
203 # TODO(iannucci): collect this in verbose mode. | |
204 # this check passed | |
205 return | |
206 | |
207 try: | |
208 frames = inspect.stack()[2:] | |
209 | |
210 # grab all frames which have self as a local variable (e.g. frames | |
211 # associated with this checker), excluding self.__call__. | |
212 try: | |
213 i = 0 | |
214 for i, f in enumerate(frames): | |
215 if self not in f[0].f_locals.itervalues(): | |
216 break | |
217 keep_frames = [self._process_frame(f, j == 0) | |
218 for j, f in enumerate(frames[:i-1])] | |
219 finally: | |
220 del f | |
221 | |
222 # order it so that innermost frame is at the bottom | |
martiniss
2016/10/10 18:53:12
bottom? it's a list right?
| |
223 keep_frames = keep_frames[::-1] | |
224 | |
225 self._failed_checks.append(expect_tests.Check( | |
226 hint, | |
227 self._ctx_filename, | |
228 self._ctx_lineno, | |
229 self._ctx_funcname, | |
230 self._ctx_args, | |
231 self._ctx_kwargs, | |
232 keep_frames, | |
233 False | |
234 )) | |
235 finally: | |
236 # avoid reference cycle as suggested by inspect docs. | |
237 del frames | |
238 | |
239 def __call__(self, arg1, arg2=None): | |
240 if arg2 is not None: | |
241 hint = arg1 | |
242 exp = arg2 | |
243 else: | |
244 hint = None | |
245 exp = arg1 | |
246 self._call_impl(hint, exp) | |
247 | |
248 | |
249 MISSING = object() | |
250 | |
251 | |
252 def VerifySubset(a, b): | |
martiniss
2016/10/10 18:53:12
Is a supposed to be a subset of b? docstring?
| |
253 if a is b: | |
254 return | |
255 | |
256 if isinstance(b, OrderedDict) and isinstance(a, dict): | |
257 if len(a) == 0: | |
258 # {} is an order-preserving subset of OrderedDict(). | |
259 return | |
260 elif len(a) == 1: | |
martiniss
2016/10/10 18:53:12
what happens if len(a) > 1?
martiniss
2016/10/13 22:54:12
?
| |
261 a = OrderedDict([a.popitem()]) | |
262 | |
263 if type(a) != type(b): | |
264 return ': type mismatch: %r v %r' % (type(a).__name__, type(b).__name__) | |
265 | |
266 if isinstance(a, OrderedDict): | |
267 last_idx = 0 | |
268 b_reverse_index = {k: (i, v) for i, (k, v) in enumerate(b.iteritems())} | |
269 for k, v in a.iteritems(): | |
270 j, b_val = b_reverse_index.get(k, (MISSING, MISSING)) | |
271 if j is MISSING: | |
272 return ': added key %r' % k | |
273 | |
274 if j < last_idx: | |
275 return ': key %r is out of order' % k | |
276 # j == last_idx is not possible, these are OrderedDicts | |
277 last_idx = j | |
278 | |
279 msg = VerifySubset(v, b_val) | |
280 if msg: | |
281 return '[%r]%s' % (k, msg) | |
282 | |
283 elif isinstance(a, dict): | |
284 for k, v in a.iteritems(): | |
285 b_val = b.get(k, MISSING) | |
286 if b_val is MISSING: | |
287 return ': added key %r' % k | |
288 | |
289 msg = VerifySubset(v, b_val) | |
290 if msg: | |
291 return '[%r]%s' % (k, msg) | |
292 | |
293 elif isinstance(a, list): | |
294 # technically we could implement an edit-distance algorithm to show what the | |
295 # smallest delta is between a and b... probably extreme. | |
296 # | |
297 # I'm not entirely convinced that the following is correct when a list | |
298 # contains non-simple types (e.g. dicts, other lists), but it works for | |
299 # simple types and it may work for complex types too. The only place where | |
300 # complex types may show up would be in a `$result`, which can hold | |
301 # arbitrary JSON, so I'm not too worried about it. | |
302 | |
303 if len(a) > len(b): | |
304 return ': too long: %d v %d' % (len(a), len(b)) | |
305 | |
306 bi = ai = 0 | |
307 while bi < len(b) - 1 and ai < len(a) - 1: | |
308 msg = VerifySubset(a[ai], b[bi]) | |
309 if msg is None: | |
310 ai += 1 | |
311 bi += 1 | |
312 if ai != len(a) - 1: | |
313 return ': added %d elements' % (len(a)-1-ai) | |
314 | |
315 elif isinstance(a, (basestring, int, bool, type(None))): | |
316 if a != b: | |
317 return ': %r != %r' % (a, b) | |
318 | |
319 else: | |
320 return ': unknown type: %r' % (type(a).__name__) | |
321 | |
322 | |
323 def _nameOfCallable(c): | |
324 if inspect.isfunction(c): | |
325 return c.__name__ | |
326 if inspect.ismethod(c): | |
327 return c.im_class.__name__+'.'+c.__name__ | |
328 if hasattr(c, '__class__') and hasattr(c, '__call__'): | |
329 return c.__class__.__name__+'.__call__' | |
330 return repr(c) | |
OLD | NEW |