Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(98)

Side by Side Diff: recipe_engine/checker.py

Issue 2387763003: Add initial postprocess unit test thingy. (Closed)
Patch Set: Add nested statement too Created 4 years, 2 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « no previous file | recipe_engine/env.py » ('j') | recipe_engine/post_process.py » ('J')
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(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)
OLDNEW
« no previous file with comments | « no previous file | recipe_engine/env.py » ('j') | recipe_engine/post_process.py » ('J')

Powered by Google App Engine
This is Rietveld 408576698