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

Side by Side Diff: recipe_engine/simulation_test.py

Issue 2387763003: Add initial postprocess unit test thingy. (Closed)
Patch Set: more comments 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
OLDNEW
1 # Copyright 2014 The LUCI Authors. All rights reserved. 1 # Copyright 2014 The LUCI Authors. All rights reserved.
2 # Use of this source code is governed under the Apache License, Version 2.0 2 # Use of this source code is governed under the Apache License, Version 2.0
3 # that can be found in the LICENSE file. 3 # that can be found in the LICENSE file.
4 4
5 """Provides simulator test coverage for individual recipes.""" 5 """Provides simulator test coverage for individual recipes."""
6 6
7 import StringIO 7 import StringIO
8 import ast
8 import contextlib 9 import contextlib
10 import copy
9 import json 11 import json
10 import logging 12 import logging
11 import os 13 import os
12 import re 14 import re
13 import sys 15 import sys
16 import textwrap
17 import traceback
18 import inspect
19
20 from collections import OrderedDict, namedtuple
14 21
15 from . import env 22 from . import env
16 from . import stream 23 from . import stream
24 import astunparse
17 import expect_tests 25 import expect_tests
18 26
19 # This variable must be set in the dynamic scope of the functions in this file. 27 # This variable must be set in the dynamic scope of the functions in this file.
20 # We do this instead of passing because the threading system of expect tests 28 # We do this instead of passing because the threading system of expect tests
21 # doesn't know how to serialize it. 29 # doesn't know how to serialize it.
22 _UNIVERSE = None 30 _UNIVERSE = None
23 31
24 32 class _checkTransformer(ast.NodeTransformer):
25 def RenderExpectation(test_data, raw_expectations): 33 """_checkTransformer is an ast NodeTransformer which extracts the helpful
26 """Applies the step filters (e.g. whitelists, etc.) to the raw_expectations, 34 subexpressions from a python expression (specificially, from an invocation of
Michael Achenbach 2016/10/07 16:32:04 nit: s/specificially/specifically
27 if the TestData actually contains any filters. 35 the Checker). These subexpressions will be printed along with the check's
36 source code statement to provide context for the failed check.
37
38 It knows the following transformations:
39 * all python identifiers will be resolved to their local variable meaning.
40 * `___ in <instance of dict>` will cause dict.keys() to be printed in lieu
41 of the entire dictionary.
42 * `a[b][c]` will cause `a[b]` and `a[b][c]` to be printed (for an arbitrary
43 level of recursion)
44
45 The transformed ast is NOT a valid python AST... In particular, every reduced
46 subexpression will be an ast.Name() where the id is the code for the
47 subexpression (which may not be a valid name! It could be `foo.bar()`.), and
48 the ctx will be the eval'd value for that element.
49
50 In addition to this, there will be a list of ast.Name nodes in the
51 transformer's `extra` attribute for additional expressions which should be
52 printed for debugging usefulness, but didn't fit into the ast tree anywhere.
53 """
54
55 def __init__(self, lvars, gvars):
56 self.lvars = lvars
57 self.gvars = gvars
58 self.extras = []
59
60 def _eval(self, node):
61 code = astunparse.unparse(node).strip()
62 try:
63 thing = eval(code, self.gvars, self.lvars)
64 return ast.Name(code, thing)
65 except NameError:
66 return node
67
68 def visit_Compare(self, node):
69 # match `___ in instanceof(dict)`
70 node = self.generic_visit(node)
71
72 if len(node.ops) == 1 and isinstance(node.ops[0], ast.In):
73 cmps = node.comparators
74 if len(cmps) == 1 and isinstance(cmps[0], ast.Name):
75 name = cmps[0]
76 if isinstance(name.ctx, dict):
77 node = ast.Compare(
78 node.left,
79 node.ops,
80 [ast.Name(name.id+".keys()", name.ctx.keys())])
81
82 return node
83
84 def visit_Subscript(self, node):
85 # match __[a]
86 node = self.generic_visit(node)
87 if isinstance(node.slice, ast.Index):
88 if isinstance(node.slice.value, ast.Name):
89 self.extras.append(self._eval(node.slice.value))
90 node = self._eval(node)
91 return node
92
93 def visit_Name(self, node):
94 # match foo
95 return self._eval(node)
96
97
98 def render_user_value(val):
99 """Takes a subexpression user value, and attempts to render it in the most
100 useful way possible.
101
102 Currently this will use render_re for compiled regular expressions, and will
103 fall back to repr() for everything else.
104
105 It should be the goal of this function to return an `eval`able string that
106 would yield the equivalent value in a python interpreter.
107 """
108 if isinstance(val, re._pattern_type):
109 return render_re(val)
110 return repr(val)
111
112
113 def render_re(regex):
114 """Renders a repr()-style value for a compiled regular expression."""
115 actual_flags = []
116 if regex.flags:
117 flags = [
118 (re.IGNORECASE, 'IGNORECASE'),
119 (re.LOCALE, 'LOCALE'),
120 (re.UNICODE, 'UNICODE'),
121 (re.MULTILINE, 'MULTILINE'),
122 (re.DOTALL, 'DOTALL'),
123 (re.VERBOSE, 'VERBOSE'),
124 ]
125 for val, name in flags:
126 if regex.flags & val:
127 actual_flags.append(name)
128 if actual_flags:
129 return 're.compile(%r, %s)' % (regex.pattern, '|'.join(actual_flags))
130 else:
131 return 're.compile(%r)' % regex.pattern
132
133
134 class _checker(object):
135 def __init__(self, filename, lineno, funcname, args, kwargs, *ignores):
136 self._failed_checks = []
137
138 # _ignore_set is the set of objects that we should never print as local
139 # variables. We start this set off by including the actual _checker object,
140 # since there's no value to printing that.
141 self._ignore_set = {id(x) for x in ignores+(self,)}
142
143 self._ctx_filename = filename
144 self._ctx_lineno = lineno
145 self._ctx_funcname = funcname
146 self._ctx_args = map(repr, args)
147 self._ctx_kwargs = {k: repr(v) for k, v in kwargs.iteritems()}
148
149 def _process_frame(self, frame, with_vars):
150 """This processes a stack frame into an expect_tests.CheckFrame, which
151 includes file name, line number, function name (of the function containing
152 the frame), the parsed statement at that line, and the relevant local
153 variables/subexpressions (if with_vars is True).
154
155 In addition to transforming the expression with _checkTransformer, this
156 will:
157 * omit subexpressions which resolve to callable()'s
158 * omit the overall step ordered dictionary
159 * transform all subexpression values using render_user_value().
160 """
161 raw_frame, filename, lineno, func_name, _, _ = frame
162
163 filelines, _ = inspect.findsource(raw_frame)
164
165 i = lineno-1
166 # this dumb little loop will try to parse a node out of the ast which ends
167 # at the line that shows up in the frame. To do this, tries parsing that
168 # line, and if it fails, it adds a prefix line. It keeps doing this until
169 # it gets a successful parse.
170 for i in xrange(lineno-1, 0, -1):
171 try:
172 to_parse = ''.join(filelines[i:lineno]).strip()
173 node = ast.parse(to_parse)
174 break
175 except SyntaxError:
176 continue
177 varmap = None
178 if with_vars:
179 xfrmr = _checkTransformer(raw_frame.f_locals, raw_frame.f_globals)
180
181 varmap = {}
182 def add_node(n):
183 if isinstance(n, ast.Name):
184 val = n.ctx
185 if isinstance(val, ast.AST):
186 return
187 if callable(val) or id(val) in self._ignore_set:
188 return
189 if n.id not in varmap:
190 varmap[n.id] = render_user_value(val)
191 map(add_node, ast.walk(xfrmr.visit(copy.deepcopy(node))))
192 # TODO(iannucci): only add extras if verbose is True
193 map(add_node, xfrmr.extras)
194
195 return expect_tests.CheckFrame(
196 filename,
197 lineno,
198 func_name,
199 astunparse.unparse(node).strip(),
200 varmap
201 )
202
203 def _call_impl(self, hint, exp):
204 """This implements the bulk of what happens when you run `check(exp)`. It
205 will crawl back up the stack and extract information about all of the frames
206 which are relevent to the check, including file:lineno and the code
207 statement which occurs at that location for all the frames.
208
209 On the last frame (the one that actually contains the check call), it will
210 also try to obtain relevant local values in the check so they can be printed
211 with the check to aid in debugging and diagnosis. It uses the parsed
212 statement found at that line to find all referenced local variables in that
213 frame.
214 """
215
216 if exp:
217 # TODO(iannucci): collect this in verbose mode.
218 # this check passed
219 return
220
221 try:
222 frames = inspect.stack()[2:]
223
224 # grab all frames which have self as a local variable (e.g. frames
225 # associated with this checker), excluding self.__call__.
226 try:
227 i = 0
228 for i, f in enumerate(frames):
229 if self not in f[0].f_locals.itervalues():
230 break
231 keep_frames = [self._process_frame(f, j == 0)
232 for j, f in enumerate(frames[:i-1])]
233 finally:
234 del f
235
236 # order it so that innermost frame is at the bottom
237 keep_frames = keep_frames[::-1]
238
239 self._failed_checks.append(expect_tests.Check(
240 hint,
241 self._ctx_filename,
242 self._ctx_lineno,
243 self._ctx_funcname,
244 self._ctx_args,
245 self._ctx_kwargs,
246 keep_frames,
247 False
248 ))
249 finally:
250 # avoid reference cycle as suggested by inspect docs.
251 del frames
252
253 def __call__(self, arg1, arg2=None):
254 if arg2 is not None:
255 hint = arg1
256 exp = arg2
257 else:
258 hint = None
259 exp = arg1
260 self._call_impl(hint, exp)
261
262
263 class PostProcessError(ValueError):
264 def __init__(self, msg):
265 super(PostProcessError, self).__init__("post_process %s" % (msg))
266
267
268 class PostProcessStepError(ValueError):
269 def __init__(self, msg, step_name):
270 super(PostProcessStepError, self).__init__(
271 msg + " in step " + repr(step_name))
272
273
274 def _checkIsSubset(a, b):
275 """This checks to see that a is a subset of b, where both a and b are
276 OrderedDicts. This will raise a PostProcessError if any data in a is not
277 present in b."""
278
279 # both a and b should be ordered step lists.
280 if a is b:
281 return
282
283 typeOK = (
284 len(b) == 0 and isinstance(b, (OrderedDict, dict)) or
285 len(b) != 0 and isinstance(b, OrderedDict))
286 if not typeOK:
287 raise PostProcessError(
288 'must always return an OrderedDict() or the empty {}.')
289
290 for step_name, step in a.iteritems():
291 if step_name not in b:
292 raise PostProcessError('introduced new step %r' % step_name)
293
294 if 'name' not in step:
295 raise PostProcessStepError('removed "name"', step_name)
296
297 orig = b[step_name]
298 if step is orig:
299 continue
300
301 for k, v in step.iteritems():
302 if k not in orig:
303 raise PostProcessStepError('introduced new key %r' % k, step_name)
304
305 orig_v = orig[k]
306 if v is orig_v:
307 continue
308
309 if type(v) != type(orig_v):
310 raise PostProcessStepError(
311 'changed type of key %r from %s to %s' % (
312 k, type(orig_v).__name__, type(v).__name__), step_name)
313
314 if isinstance(v, list):
315 if len(v) > len(orig_v):
316 raise PostProcessStepError(
317 '%r is longer than the original (%d v %d)' %
318 (k, len(v), len(orig_v)), step_name)
319 idx_orig_v = idx_v = 0
320 while idx_orig_v < len(orig_v) - 1 and idx_v < len(v) - 1:
321 if v[idx_v] == orig_v[idx_orig_v]:
322 idx_v += 1
323 idx_orig_v += 1
324 if idx_v != len(v) - 1:
325 raise PostProcessStepError(
326 'added elements to %r (%r)' % (k, v[idx_v:]), step_name)
327 elif isinstance(v, dict):
328 for subk, subv in v.iteritems():
329 el = orig_v.get(subk)
330 if el is None:
331 raise PostProcessStepError('added element %s[%r]' % (k, subk),
332 step_name)
333 if subv != el:
334 raise PostProcessError(
335 'changed element %s[%r] in step %r: %r to %r' % (
336 k, subk, step_name, subv, orig_v[subk]))
337 elif v != orig_v:
338 raise PostProcessStepError(
339 'changed value of key %r from %s to %s' % (k, orig_v, v), step_name)
340
341
342 def _nameOfCallable(c):
343 if inspect.isfunction(c):
344 return c.__name__
345 if inspect.ismethod(c):
346 return c.im_class.__name__+'.'+c.__name__
347 if hasattr(c, '__class__') and hasattr(c, '__call__'):
348 return c.__class__.__name__+'.__call__'
349 return repr(c)
350
351
352 def _renderExpectation(test_data, step_odict):
353 """Applies the step post_process actions to the step_odict, if the
354 TestData actually contains any.
28 355
29 Returns the final expect_tests.Result.""" 356 Returns the final expect_tests.Result."""
30 if test_data.whitelist_data: 357
31 whitelist_data = dict(test_data.whitelist_data) # copy so we can mutate it 358 failed_checks = []
32 def filter_expectation(step): 359
33 whitelist = whitelist_data.pop(step['name'], None) 360 for hook, args, kwargs, filename, lineno in test_data.post_process_hooks:
34 if whitelist is None: 361 input_odict = copy.deepcopy(step_odict)
35 return 362 # we ignore the input_odict so that it never gets printed in full. Usually
36 363 # the check invocation itself will index the input_odict or will use it only
37 whitelist = set(whitelist) # copy so we can mutate it 364 # for a key membership comparison, which provides enough debugging context.
38 if len(whitelist) > 0: 365 checker = _checker(filename, lineno, _nameOfCallable(hook), args, kwargs,
39 whitelist.add('name') 366 input_odict)
40 step = {k: v for k, v in step.iteritems() if k in whitelist} 367 rslt = hook(checker, input_odict, *args, **kwargs)
41 whitelist.difference_update(step.keys()) 368 failed_checks += checker._failed_checks
42 if whitelist: 369 if rslt is not None:
43 raise ValueError( 370 _checkIsSubset(rslt, step_odict)
44 "The whitelist includes fields %r in step %r, but those fields" 371 step_odict = rslt
45 " don't exist." 372
46 % (whitelist, step['name'])) 373 # empty means drop expectation
47 return step 374 result_data = step_odict.values() if step_odict else None
48 raw_expectations = filter(filter_expectation, raw_expectations) 375 return expect_tests.Result(result_data, failed_checks)
49
50 if whitelist_data:
51 raise ValueError(
52 "The step names %r were included in the whitelist, but were never run."
53 % [s['name'] for s in whitelist_data])
54
55 return expect_tests.Result(raw_expectations)
56 376
57 377
58 class SimulationAnnotatorStreamEngine(stream.AnnotatorStreamEngine): 378 class SimulationAnnotatorStreamEngine(stream.AnnotatorStreamEngine):
59 379
60 def __init__(self): 380 def __init__(self):
61 self._step_buffer_map = {} 381 self._step_buffer_map = {}
62 super(SimulationAnnotatorStreamEngine, self).__init__( 382 super(SimulationAnnotatorStreamEngine, self).__init__(
63 self.step_buffer(None)) 383 self.step_buffer(None))
64 384
65 def step_buffer(self, step_name): 385 def step_buffer(self, step_name):
66 return self._step_buffer_map.setdefault(step_name, StringIO.StringIO()) 386 return self._step_buffer_map.setdefault(step_name, StringIO.StringIO())
67 387
68 def new_step_stream(self, step_config): 388 def new_step_stream(self, step_config):
69 return self._create_step_stream(step_config, 389 return self._create_step_stream(step_config,
70 self.step_buffer(step_config.name)) 390 self.step_buffer(step_config.name))
71 391
72 392
73 def RunRecipe(test_data): 393 # This maps from (recipe_name,test_name) -> yielded test_data. It's outside of
394 # RunRecipe so that it can persist between RunRecipe calls in the same process.
395 _GEN_TEST_CACHE = {}
396
397 def RunRecipe(recipe_name, test_name):
74 """Actually runs the recipe given the GenTests-supplied test_data.""" 398 """Actually runs the recipe given the GenTests-supplied test_data."""
75 from . import config_types 399 from . import config_types
76 from . import loader 400 from . import loader
77 from . import run 401 from . import run
78 from . import step_runner 402 from . import step_runner
79 from . import stream 403
404 if recipe_name not in _GEN_TEST_CACHE:
405 recipe_script = _UNIVERSE.load_recipe(recipe_name)
406 test_api = loader.create_test_api(recipe_script.LOADED_DEPS, _UNIVERSE)
407 for test_data in recipe_script.GenTests(test_api):
408 _GEN_TEST_CACHE[(recipe_name, test_data.name)] = test_data
409
410 test_data = _GEN_TEST_CACHE[(recipe_name, test_name)]
80 411
81 config_types.ResetTostringFns() 412 config_types.ResetTostringFns()
82 413
83 annotator = SimulationAnnotatorStreamEngine() 414 annotator = SimulationAnnotatorStreamEngine()
84 stream_engine = stream.ProductStreamEngine( 415 stream_engine = stream.ProductStreamEngine(
85 stream.StreamEngineInvariants(), 416 stream.StreamEngineInvariants(),
86 annotator) 417 annotator)
87 with stream_engine: 418 with stream_engine:
88 step_runner = step_runner.SimulationStepRunner(stream_engine, test_data, 419 step_runner = step_runner.SimulationStepRunner(stream_engine, test_data,
89 annotator) 420 annotator)
90 421
91 engine = run.RecipeEngine(step_runner, test_data.properties, _UNIVERSE) 422 props = test_data.properties.copy()
92 recipe_script = _UNIVERSE.load_recipe(test_data.properties['recipe']) 423 props['recipe'] = recipe_name
424 engine = run.RecipeEngine(step_runner, props, _UNIVERSE)
425 recipe_script = _UNIVERSE.load_recipe(recipe_name)
93 api = loader.create_recipe_api(recipe_script.LOADED_DEPS, engine, test_data) 426 api = loader.create_recipe_api(recipe_script.LOADED_DEPS, engine, test_data)
94 result = engine.run(recipe_script, api) 427 result = engine.run(recipe_script, api)
95 428
96 # Don't include tracebacks in expectations because they are too sensitive to 429 # Don't include tracebacks in expectations because they are too sensitive to
97 # change. 430 # change.
98 result.result.pop('traceback', None) 431 result.result.pop('traceback', None)
99 raw_expectations = step_runner.steps_ran + [result.result] 432 raw_expectations = step_runner.steps_ran.copy()
433 raw_expectations[result.result['name']] = result.result
100 434
101 try: 435 try:
102 return RenderExpectation(test_data, raw_expectations) 436 return _renderExpectation(test_data, raw_expectations)
103 except: 437 except:
104 print 438 print
105 print "The expectations would have been:" 439 print "The expectations would have been:"
106 json.dump(raw_expectations, sys.stdout, indent=2) 440 json.dump(raw_expectations, sys.stdout, indent=2)
107 raise 441 raise
108 442
109 443
110 def test_gen_coverage(): 444 def test_gen_coverage():
111 cover = [] 445 cover = []
112 446
(...skipping 10 matching lines...) Expand all
123 def cover_omit(): 457 def cover_omit():
124 omit = [ ] 458 omit = [ ]
125 459
126 for mod_dir_base in _UNIVERSE.module_dirs: 460 for mod_dir_base in _UNIVERSE.module_dirs:
127 if os.path.isdir(mod_dir_base): 461 if os.path.isdir(mod_dir_base):
128 omit.append(os.path.join(mod_dir_base, '*', 'resources', '*')) 462 omit.append(os.path.join(mod_dir_base, '*', 'resources', '*'))
129 463
130 return omit 464 return omit
131 465
132 466
133 class InsufficientTestCoverage(Exception): pass 467 class InsufficientTestCoverage(Exception):
468 pass
134 469
135 470
136 @expect_tests.covers(test_gen_coverage) 471 @expect_tests.covers(test_gen_coverage)
137 def GenerateTests(): 472 def GenerateTests():
138 from . import loader 473 from . import loader
139 474
140 cover_mods = [ ] 475 cover_mods = [ ]
141 for mod_dir_base in _UNIVERSE.module_dirs: 476 for mod_dir_base in _UNIVERSE.module_dirs:
142 if os.path.isdir(mod_dir_base): 477 if os.path.isdir(mod_dir_base):
143 cover_mods.append(os.path.join(mod_dir_base, '*.py')) 478 cover_mods.append(os.path.join(mod_dir_base, '*.py'))
144 479
145 for recipe_path, recipe_name in _UNIVERSE.loop_over_recipes(): 480 for recipe_path, recipe_name in _UNIVERSE.loop_over_recipes():
146 try: 481 try:
147 recipe = _UNIVERSE.load_recipe(recipe_name) 482 recipe = _UNIVERSE.load_recipe(recipe_name)
148 test_api = loader.create_test_api(recipe.LOADED_DEPS, _UNIVERSE) 483 test_api = loader.create_test_api(recipe.LOADED_DEPS, _UNIVERSE)
149 484
150 covers = cover_mods + [recipe_path] 485 covers = cover_mods + [recipe_path]
151 486
152 full_expectation_count = 0
153 for test_data in recipe.GenTests(test_api): 487 for test_data in recipe.GenTests(test_api):
154 if not test_data.whitelist_data:
155 full_expectation_count += 1
156 root, name = os.path.split(recipe_path) 488 root, name = os.path.split(recipe_path)
157 name = os.path.splitext(name)[0] 489 name = os.path.splitext(name)[0]
158 expect_path = os.path.join(root, '%s.expected' % name) 490 expect_path = os.path.join(root, '%s.expected' % name)
159
160 test_data.properties['recipe'] = recipe_name.replace('\\', '/')
161 yield expect_tests.Test( 491 yield expect_tests.Test(
162 '%s.%s' % (recipe_name, test_data.name), 492 '%s.%s' % (recipe_name, test_data.name),
163 expect_tests.FuncCall(RunRecipe, test_data), 493 expect_tests.FuncCall(RunRecipe, recipe_name, test_data.name),
164 expect_dir=expect_path, 494 expect_dir=expect_path,
165 expect_base=test_data.name, 495 expect_base=test_data.name,
166 covers=covers, 496 covers=covers,
167 break_funcs=(recipe.RunSteps,) 497 break_funcs=(recipe.RunSteps,)
168 ) 498 )
169
170 if full_expectation_count < 1:
171 raise InsufficientTestCoverage(
172 'Must have at least 1 test without a whitelist!')
173 except: 499 except:
174 info = sys.exc_info() 500 info = sys.exc_info()
175 new_exec = Exception('While generating results for %r: %s: %s' % ( 501 new_exec = Exception('While generating results for %r: %s: %s' % (
176 recipe_name, info[0].__name__, str(info[1]))) 502 recipe_name, info[0].__name__, str(info[1])))
177 raise new_exec.__class__, new_exec, info[2] 503 raise new_exec.__class__, new_exec, info[2]
178 504
179 505
180 def main(universe, args=None): 506 def main(universe, args=None):
181 """Runs simulation tests on a given repo of recipes. 507 """Runs simulation tests on a given repo of recipes.
182 508
(...skipping 10 matching lines...) Expand all
193 'TESTING_SLAVENAME']: 519 'TESTING_SLAVENAME']:
194 if env_var in os.environ: 520 if env_var in os.environ:
195 logging.warn("Ignoring %s environment variable." % env_var) 521 logging.warn("Ignoring %s environment variable." % env_var)
196 os.environ.pop(env_var) 522 os.environ.pop(env_var)
197 523
198 global _UNIVERSE 524 global _UNIVERSE
199 _UNIVERSE = universe 525 _UNIVERSE = universe
200 526
201 expect_tests.main('recipe_simulation_test', GenerateTests, 527 expect_tests.main('recipe_simulation_test', GenerateTests,
202 cover_omit=cover_omit(), args=args) 528 cover_omit=cover_omit(), args=args)
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698