OLD | NEW |
---|---|
(Empty) | |
1 # Copyright 2014 The LUCI Authors. All rights reserved. | |
Michael Achenbach
2016/10/13 07:45:08
nit: current year for new files?
iannucci
2016/10/13 22:03:20
Done.
| |
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.""" | |
Michael Achenbach
2016/10/13 07:45:08
I'm not fully understanding this description. I un
iannucci
2016/10/13 22:03:20
This is a documentation bug, it got copypasta'd fr
| |
6 | |
7 import ast | |
8 import copy | |
9 import inspect | |
10 import re | |
11 import textwrap | |
12 import itertools | |
13 | |
14 from collections import OrderedDict, namedtuple | |
15 | |
16 from . import env | |
17 import astunparse | |
18 import expect_tests | |
19 | |
20 | |
21 class _resolved(ast.AST): | |
22 """_resolved is a fake AST node which represents a resolved sub-expression. | |
23 It's used by _checkTransformer to replace portions of its AST with their | |
24 resolved equivalents.""" | |
25 def __init__(self, representation, value): | |
26 super(_resolved, self).__init__() | |
27 self.representation = representation | |
28 self.value = value | |
29 | |
30 | |
31 def _maxLineOf(node): | |
32 return max(map(lambda n: getattr(n, 'lineno', 0), ast.walk(node))) | |
33 | |
34 | |
35 class _checkTransformer(ast.NodeTransformer): | |
36 """_checkTransformer is an ast NodeTransformer which extracts the helpful | |
37 subexpressions from a python expression (specificially, from an invocation of | |
38 the Checker). These subexpressions will be printed along with the check's | |
39 source code statement to provide context for the failed check. | |
40 | |
41 It knows the following transformations: | |
42 * all python identifiers will be resolved to their local variable meaning. | |
43 * `___ in <instance of dict>` will cause dict.keys() to be printed in lieu | |
44 of the entire dictionary. | |
45 * `a[b][c]` will cause `a[b]` and `a[b][c]` to be printed (for an arbitrary | |
46 level of recursion) | |
47 | |
48 The transformed ast is NOT a valid python AST... In particular, every reduced | |
49 subexpression will be a _resolved() where the `representation` is the code for | |
50 the subexpression (It could be any valid expression like `foo.bar()`), | |
51 and the `value` will be the eval'd value for that element. | |
52 | |
53 In addition to this, there will be a list of _resolved nodes in the | |
54 transformer's `extra` attribute for additional expressions which should be | |
55 printed for debugging usefulness, but didn't fit into the ast tree anywhere. | |
56 """ | |
57 | |
58 def __init__(self, lvars, gvars): | |
59 self.lvars = lvars | |
60 self.gvars = gvars | |
61 self.extras = [] | |
62 | |
63 def visit_Compare(self, node): | |
64 """Compare nodes occur for all sequences of comparison (`in`, gt, lt, etc.) | |
65 operators. We only want to match `___ in instanceof(dict)` here, so we | |
66 restrict this to Compare ops with a single operator which is `In`. | |
67 """ | |
68 node = self.generic_visit(node) | |
69 | |
70 if len(node.ops) == 1 and isinstance(node.ops[0], (ast.In, ast.NotIn)): | |
71 cmps = node.comparators | |
72 if len(cmps) == 1 and isinstance(cmps[0], _resolved): | |
73 rslvd = cmps[0] | |
74 if isinstance(rslvd.value, dict): | |
75 node = ast.Compare( | |
76 node.left, | |
77 node.ops, | |
78 [_resolved(rslvd.representation+".keys()", | |
79 sorted(rslvd.value.keys()))]) | |
80 | |
81 return node | |
82 | |
83 def visit_Subscript(self, node): | |
84 """Subscript nodes are anything which is __[__]. We only want to match __[x] | |
85 here so where the [x] is a regular Index expression (not an elipsis or | |
86 slice). We only handle cases where x is a constant, or a resolvable variable | |
87 lookup (so a variable lookup, index, etc.).""" | |
88 node = self.generic_visit(node) | |
89 | |
90 if (isinstance(node.slice, ast.Index) and | |
91 isinstance(node.value, _resolved)): | |
92 sliceVal = MISSING | |
93 sliceRepr = '' | |
94 if isinstance(node.slice.value, _resolved): | |
95 # (a[b])[c] | |
96 # will include `a[b]` in the extras. | |
97 self.extras.append(node.slice.value) | |
98 sliceVal = node.slice.value.value | |
99 sliceRepr = node.slice.value.representation | |
100 elif isinstance(node.slice.value, ast.Num): | |
101 sliceVal = node.slice.value.n | |
102 sliceRepr = repr(sliceVal) | |
103 elif isinstance(node.slice.value, ast.Str): | |
104 sliceVal = node.slice.value.s | |
105 sliceRepr = repr(sliceVal) | |
106 if sliceVal is not MISSING: | |
107 node = _resolved( | |
108 '%s[%s]' % (node.value.representation, sliceRepr), | |
109 node.value.value[sliceVal]) | |
110 | |
111 return node | |
112 | |
113 def visit_Name(self, node): | |
114 """Matches a single, simple identifier (e.g. variable). | |
115 | |
116 This will lookup the variable value from python constants (e.g. True), | |
117 followed by the frame's local variables, and finally by the frame's global | |
118 variables. | |
119 """ | |
120 consts = {'True': True, 'False': False, 'None': None} | |
121 val = consts.get( | |
122 node.id, self.lvars.get( | |
123 node.id, self.gvars.get( | |
124 node.id, MISSING))) | |
125 if val is not MISSING: | |
126 return _resolved(node.id, val) | |
127 return node | |
128 | |
129 | |
130 def render_user_value(val): | |
131 """Takes a subexpression user value, and attempts to render it in the most | |
132 useful way possible. | |
133 | |
134 Currently this will use render_re for compiled regular expressions, and will | |
135 fall back to repr() for everything else. | |
136 | |
137 It should be the goal of this function to return an `eval`able string that | |
138 would yield the equivalent value in a python interpreter. | |
139 """ | |
140 if isinstance(val, re._pattern_type): | |
141 return render_re(val) | |
142 return repr(val) | |
143 | |
144 | |
145 def render_re(regex): | |
146 """Renders a repr()-style value for a compiled regular expression.""" | |
147 actual_flags = [] | |
148 if regex.flags: | |
149 flags = [ | |
150 (re.IGNORECASE, 'IGNORECASE'), | |
151 (re.LOCALE, 'LOCALE'), | |
152 (re.UNICODE, 'UNICODE'), | |
153 (re.MULTILINE, 'MULTILINE'), | |
154 (re.DOTALL, 'DOTALL'), | |
155 (re.VERBOSE, 'VERBOSE'), | |
156 ] | |
157 for val, name in flags: | |
158 if regex.flags & val: | |
159 actual_flags.append(name) | |
160 if actual_flags: | |
161 return 're.compile(%r, %s)' % (regex.pattern, '|'.join(actual_flags)) | |
162 else: | |
163 return 're.compile(%r)' % regex.pattern | |
164 | |
165 | |
166 def extract_statements_on_line(node, line): | |
167 if not hasattr(node, 'body'): | |
168 maxLineOf = max(getattr(n, 'lineno', 0) for n in ast.walk(node)) | |
169 if maxLineOf == line: | |
170 yield node | |
171 return | |
172 | |
173 for stmt in node.body: | |
174 for substmt in extract_statements_on_line(stmt, line): | |
175 yield substmt | |
176 | |
177 | |
178 class Checker(object): | |
179 def __init__(self, filename, lineno, func, args, kwargs, *ignores): | |
180 self.failed_checks = [] | |
181 | |
182 # _ignore_set is the set of objects that we should never print as local | |
183 # variables. We start this set off by including the actual Checker object, | |
184 # since there's no value to printing that. | |
185 self._ignore_set = {id(x) for x in ignores+(self,)} | |
186 | |
187 self._ctx_filename = filename | |
188 self._ctx_lineno = lineno | |
189 self._ctx_funcname = _nameOfCallable(func) | |
190 self._ctx_args = map(repr, args) | |
191 self._ctx_kwargs = {k: repr(v) for k, v in kwargs.iteritems()} | |
192 | |
193 def _process_frame(self, frame, with_vars): | |
194 """This processes a stack frame into an expect_tests.CheckFrame, which | |
195 includes file name, line number, function name (of the function containing | |
196 the frame), the parsed statement at that line, and the relevant local | |
197 variables/subexpressions (if with_vars is True). | |
198 | |
199 In addition to transforming the expression with _checkTransformer, this | |
200 will: | |
201 * omit subexpressions which resolve to callable()'s | |
202 * omit the overall step ordered dictionary | |
203 * transform all subexpression values using render_user_value(). | |
204 """ | |
205 raw_frame, filename, lineno, func_name, _, _ = frame | |
206 | |
207 _, func_startline = inspect.getsourcelines(raw_frame) | |
208 | |
209 filelines, _ = inspect.findsource(raw_frame) | |
210 | |
211 # Find the maximal line which allows the surrounding function to parse | |
212 # correctly. This can occur when the stacktrace points us at a line in the | |
213 # middle of the function invocation, which will happen if the closing paren | |
214 # isn't on the last line of the function invocation. | |
215 # | |
216 # We parse from the beginning of the surrounding function until we get | |
217 # a clean parse, and then we take the last node in the parsed function's | |
218 # body as our statement. | |
219 parse_lineno = lineno | |
220 partial_func = None | |
221 while True: | |
222 try: | |
223 to_parse = ''.join(filelines[func_startline-1:parse_lineno]) | |
224 partial_func = ast.parse(textwrap.dedent(to_parse)) | |
225 break | |
226 except SyntaxError: | |
227 parse_lineno += 1 | |
228 | |
229 node = ast.Module( | |
230 list(extract_statements_on_line(partial_func, lineno-func_startline+1))) | |
231 | |
232 varmap = None | |
233 if with_vars: | |
234 xfrmr = _checkTransformer(raw_frame.f_locals, raw_frame.f_globals) | |
235 | |
236 varmap = {} | |
237 def add_node(n): | |
238 if isinstance(n, _resolved): | |
239 val = n.value | |
240 if isinstance(val, ast.AST): | |
241 return | |
242 if n.representation in ('True', 'False', 'None'): | |
243 return | |
244 if callable(val) or id(val) in self._ignore_set: | |
245 return | |
246 if n.representation not in varmap: | |
247 varmap[n.representation] = render_user_value(val) | |
248 | |
249 xfrmd = xfrmr.visit(copy.deepcopy(node)) | |
250 | |
251 map(add_node, ast.walk(xfrmd)) | |
252 map(add_node, xfrmr.extras) | |
253 | |
254 return expect_tests.CheckFrame( | |
255 filename, | |
256 parse_lineno, | |
257 func_name, | |
258 '; '.join(astunparse.unparse(n).strip() for n in node.body), | |
259 varmap | |
260 ) | |
261 | |
262 def _call_impl(self, hint, exp): | |
263 """This implements the bulk of what happens when you run `check(exp)`. It | |
264 will crawl back up the stack and extract information about all of the frames | |
265 which are relevent to the check, including file:lineno and the code | |
266 statement which occurs at that location for all the frames. | |
267 | |
268 On the last frame (the one that actually contains the check call), it will | |
269 also try to obtain relevant local values in the check so they can be printed | |
270 with the check to aid in debugging and diagnosis. It uses the parsed | |
271 statement found at that line to find all referenced local variables in that | |
272 frame. | |
273 """ | |
274 | |
275 if exp: | |
276 # TODO(iannucci): collect this in verbose mode. | |
277 # this check passed | |
278 return | |
279 | |
280 try: | |
281 frames = inspect.stack()[2:] | |
282 | |
283 # grab all frames which have self as a local variable (e.g. frames | |
284 # associated with this checker), excluding self.__call__. | |
285 try: | |
286 i = 0 | |
287 for i, f in enumerate(frames): | |
288 if self not in f[0].f_locals.itervalues(): | |
289 break | |
290 keep_frames = [self._process_frame(f, j == 0) | |
291 for j, f in enumerate(frames[:i-1])] | |
292 finally: | |
293 del f | |
294 | |
295 # order it so that innermost frame is at the bottom | |
296 keep_frames = keep_frames[::-1] | |
297 | |
298 self.failed_checks.append(expect_tests.Check( | |
299 hint, | |
300 self._ctx_filename, | |
301 self._ctx_lineno, | |
302 self._ctx_funcname, | |
303 self._ctx_args, | |
304 self._ctx_kwargs, | |
305 keep_frames, | |
306 False | |
307 )) | |
308 finally: | |
309 # avoid reference cycle as suggested by inspect docs. | |
310 del frames | |
311 | |
312 def __call__(self, arg1, arg2=None): | |
313 if arg2 is not None: | |
314 hint = arg1 | |
315 exp = arg2 | |
316 else: | |
317 hint = None | |
318 exp = arg1 | |
319 self._call_impl(hint, exp) | |
320 | |
321 | |
322 MISSING = object() | |
323 | |
324 | |
325 def VerifySubset(a, b): | |
326 """Verify subset verifies that `a` is a subset of `b` where a and b are both | |
327 JSON-ish types. They are also permitted to be OrderedDicts instead of | |
328 dictionaries. | |
329 | |
330 This verifies that a introduces no extra dictionary keys, list elements, etc. | |
331 and also ensures that the order of entries in an ordered type (such as a list | |
332 or an OrderedDict) remain the same from a to b. This also verifies that types | |
333 are consistent between a and b. | |
334 | |
335 As a special case, empty and single-element dictionaries are considered | |
336 subsets of an OrderedDict, even though their types don't precisely match. | |
337 | |
338 If a is a vaild subset of b, this returns None. Otherwise this returns | |
339 a descriptive message of what went wrong. | |
340 | |
341 Example: | |
342 print 'object'+VerifySubset({'a': 'thing'}, {'b': 'other', 'a': 'prime'}) | |
343 | |
344 OUTPUT: | |
345 object['a']: 'thing' != 'prime' | |
346 """ | |
347 if a is b: | |
348 return | |
349 | |
350 if isinstance(b, OrderedDict) and isinstance(a, dict): | |
351 # 0 and 1-element dicts can stand in for OrderedDicts. | |
352 if len(a) == 0: | |
353 return | |
354 elif len(a) == 1: | |
355 a = OrderedDict([a.popitem()]) | |
356 | |
357 if type(a) != type(b): | |
358 return ': type mismatch: %r v %r' % (type(a).__name__, type(b).__name__) | |
359 | |
360 if isinstance(a, OrderedDict): | |
361 last_idx = 0 | |
362 b_reverse_index = {k: (i, v) for i, (k, v) in enumerate(b.iteritems())} | |
363 for k, v in a.iteritems(): | |
364 j, b_val = b_reverse_index.get(k, (MISSING, MISSING)) | |
365 if j is MISSING: | |
366 return ': added key %r' % k | |
367 | |
368 if j < last_idx: | |
369 return ': key %r is out of order' % k | |
370 # j == last_idx is not possible, these are OrderedDicts | |
371 last_idx = j | |
372 | |
373 msg = VerifySubset(v, b_val) | |
374 if msg: | |
375 return '[%r]%s' % (k, msg) | |
376 | |
377 elif isinstance(a, dict): | |
378 for k, v in a.iteritems(): | |
379 b_val = b.get(k, MISSING) | |
380 if b_val is MISSING: | |
381 return ': added key %r' % k | |
382 | |
383 msg = VerifySubset(v, b_val) | |
384 if msg: | |
385 return '[%r]%s' % (k, msg) | |
386 | |
387 elif isinstance(a, list): | |
388 if len(a) > len(b): | |
389 return ': too long: %d v %d' % (len(a), len(b)) | |
390 | |
391 bi = ai = 0 | |
392 while bi < len(b) - 1 and ai < len(a) - 1: | |
393 msg = VerifySubset(a[ai], b[bi]) | |
394 if msg is None: | |
395 ai += 1 | |
396 bi += 1 | |
397 if ai != len(a) - 1: | |
398 return ': added %d elements' % (len(a)-1-ai) | |
399 | |
400 elif isinstance(a, (basestring, int, bool, type(None))): | |
401 if a != b: | |
402 return ': %r != %r' % (a, b) | |
403 | |
404 else: | |
405 return ': unknown type: %r' % (type(a).__name__) | |
406 | |
407 | |
408 def _nameOfCallable(c): | |
409 if hasattr(c, '__call__'): | |
410 return c.__class__.__name__+'.__call__' | |
411 if inspect.ismethod(c): | |
412 return c.im_class.__name__+'.'+c.__name__ | |
413 if inspect.isfunction(c): | |
414 return c.__name__ | |
415 return repr(c) | |
OLD | NEW |