Chromium Code Reviews| 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 |