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

Side by Side Diff: recipe_engine/checker.py

Issue 2387763003: Add initial postprocess unit test thingy. (Closed)
Patch Set: Implement partial checker_test, rewrite VerifySubset 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.
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)
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