Chromium Code Reviews| OLD | NEW |
|---|---|
| 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 | |
| 19 from collections import OrderedDict | |
| 14 | 20 |
| 15 from . import env | 21 from . import env |
| 16 from . import stream | 22 from . import stream |
| 23 import astunparse | |
| 17 import expect_tests | 24 import expect_tests |
| 18 | 25 |
| 19 # This variable must be set in the dynamic scope of the functions in this file. | 26 # 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 | 27 # We do this instead of passing because the threading system of expect tests |
| 21 # doesn't know how to serialize it. | 28 # doesn't know how to serialize it. |
| 22 _UNIVERSE = None | 29 _UNIVERSE = None |
| 23 | 30 |
| 24 | 31 |
| 25 def RenderExpectation(test_data, raw_expectations): | 32 |
| 26 """Applies the step filters (e.g. whitelists, etc.) to the raw_expectations, | 33 linecache = {} |
| 27 if the TestData actually contains any filters. | 34 def getLines(filename): |
|
dnj
2016/10/04 16:42:35
nit: Add underscores to these global functions and
iannucci
2016/10/06 20:28:09
Done.
| |
| 35 filename = os.path.abspath(filename) | |
| 36 if filename in linecache: | |
| 37 return linecache[filename] | |
| 38 | |
| 39 with open(filename, 'r') as f: | |
| 40 ret = f.readlines() | |
| 41 linecache[filename] = ret | |
| 42 return ret | |
| 43 | |
| 44 | |
| 45 def getReferencedNamesAndNode(filename, lineno): | |
| 46 lines = getLines(filename) | |
| 47 i = lineno-1 | |
| 48 for i in range(lineno-1, 0, -1): | |
|
dnj
2016/10/04 16:42:35
nit: xrange
dnj
2016/10/06 21:20:19
(This and other comments in this file).
iannucci
2016/10/06 22:47:11
oops
| |
| 49 try: | |
| 50 to_parse = ''.join(lines[i:lineno]).strip() | |
| 51 node = ast.parse(to_parse) | |
| 52 break | |
| 53 except SyntaxError: | |
| 54 continue | |
|
martiniss
2016/10/03 19:14:19
Why is this here? comment? :)
iannucci
2016/10/06 22:47:11
Done.
| |
| 55 names = set(n.id for n in ast.walk(node) if isinstance(n, ast.Name)) | |
| 56 return (names, node, i) | |
| 57 | |
| 58 | |
| 59 class Checker(object): | |
| 60 def __init__(self): | |
| 61 self._failed_checks = [] | |
| 62 self._ignore_set = [self] | |
|
martiniss
2016/10/03 19:14:19
Don't really understand ignore set
iannucci
2016/10/06 22:47:11
Done.
| |
| 63 | |
| 64 def _ignore(self, obj): | |
| 65 self._ignore_set.extend(obj) | |
| 66 | |
| 67 def _inner(self, hint, exp): | |
|
martiniss
2016/10/03 19:14:19
Some docs on the magic in this function would be n
dnj
2016/10/04 16:42:34
Since "_inner" isn't recursive or anything, let's
dnj
2016/10/04 16:42:35
+176 on docs here. This is probably the most magic
iannucci
2016/10/06 22:47:11
Done.
| |
| 68 if exp: | |
| 69 # this check passed | |
| 70 return | |
| 71 | |
| 72 stk = None | |
|
dnj
2016/10/04 16:42:35
nit: this shouldn't be needed, since all paths cau
iannucci
2016/10/06 22:47:11
Done.
| |
| 73 try: | |
| 74 raise ZeroDivisionError | |
| 75 except ZeroDivisionError: | |
| 76 stk = sys.exc_info()[2].tb_frame.f_back.f_back | |
| 77 | |
| 78 refNames, node, actual_lineno = getReferencedNamesAndNode( | |
| 79 stk.f_code.co_filename, stk.f_lineno) | |
| 80 local_vars = OrderedDict() | |
| 81 for k, v in stk.f_locals.iteritems(): | |
| 82 if k not in refNames: | |
| 83 continue | |
| 84 if any(v is itm for itm in self._ignore_set): | |
| 85 continue | |
| 86 local_vars[k] = v | |
| 87 self._failed_checks.append(expect_tests.Check( | |
| 88 hint, | |
| 89 astunparse.unparse(node), | |
| 90 local_vars, | |
| 91 stk.f_code.co_filename, | |
| 92 actual_lineno, | |
| 93 False | |
| 94 )) | |
| 95 | |
| 96 def __call__(self, arg1, arg2=None): | |
| 97 if arg2 is not None: | |
| 98 hint = arg1 | |
| 99 exp = arg2 | |
| 100 else: | |
| 101 hint = None | |
| 102 exp = arg1 | |
| 103 self._inner(hint, exp) | |
| 104 | |
| 105 | |
| 106 def checkIsSubset(a, b): | |
|
martiniss
2016/10/03 19:14:19
tests?
dnj
2016/10/04 16:42:34
docs?
iannucci
2016/10/06 22:47:11
Done.
| |
| 107 # both a and b should be ordered step lists. | |
| 108 if a is b: | |
|
dnj
2016/10/04 16:42:35
Could use "==" here to catch duplicates that aren'
iannucci
2016/10/06 22:47:11
I'm not certain how that would ever happen in prac
| |
| 109 return | |
| 110 | |
| 111 for step_name, step in a.iteritems(): | |
| 112 if step_name not in b: | |
| 113 raise ValueError( | |
|
dnj
2016/10/04 16:42:34
Maybe define a PostProcessError type?
iannucci
2016/10/06 22:47:10
Done.
| |
| 114 'post_process introduced new step %r' % step_name) | |
| 115 | |
| 116 if 'name' not in step: | |
| 117 raise ValueError( | |
| 118 'post_process removed "name" from step %r' % step_name) | |
|
dnj
2016/10/04 16:42:35
Should document that this is not allowed in "post_
iannucci
2016/10/06 22:47:10
Done.
| |
| 119 | |
| 120 orig = b[step_name] | |
| 121 if step is orig: | |
|
dnj
2016/10/04 16:42:34
== ?
| |
| 122 continue | |
| 123 | |
| 124 for k, v in step.iteritems(): | |
| 125 if k not in orig: | |
| 126 raise ValueError( | |
| 127 'post_process introduced new key %r in step %r' % ( | |
| 128 k, step_name)) | |
| 129 | |
| 130 orig_v = orig[k] | |
| 131 if v is orig_v: | |
|
dnj
2016/10/04 16:42:34
== ?
iannucci
2016/10/06 22:47:11
Again, not sure how this would happen in practice,
| |
| 132 continue | |
| 133 | |
| 134 if type(v) != type(orig_v): | |
| 135 raise ValueError( | |
| 136 'post_process changed type of key %r in step %r from %s to %s' % ( | |
| 137 k, step_name, type(orig_v), type(v))) | |
|
dnj
2016/10/04 16:42:35
For rendering type names, "type(x).__name__" is a
iannucci
2016/10/06 22:47:11
Done.
| |
| 138 | |
| 139 if isinstance(v, list): | |
|
dnj
2016/10/04 16:42:35
Unless we specifically care about "list" and "dict
iannucci
2016/10/06 22:47:10
We do, expectations will never have something othe
| |
| 140 if len(v) > len(orig_v): | |
| 141 raise ValueError( | |
| 142 'post_process %r in step %r is longer than the original: %d v %d' % | |
| 143 (k, step_name, len(v), len(orig_v))) | |
| 144 iov = iv = 0 | |
|
martiniss
2016/10/03 19:14:19
not immediately obvious what these variables are f
iannucci
2016/10/06 22:47:11
Done.
| |
| 145 while iov < len(orig_v) - 1 and iv < len(v) - 1: | |
| 146 if v[iv] == orig_v[iov]: | |
| 147 iv += 1 | |
| 148 iov += 1 | |
| 149 if iv != len(v) - 1: | |
| 150 raise ValueError( | |
| 151 'post_process added elements to %r in step %r: %r' % ( | |
| 152 k, step_name, v[iv:])) | |
| 153 elif isinstance(v, dict): | |
| 154 for subk, subv in v.iteritems(): | |
| 155 if subk not in orig_v: | |
|
dnj
2016/10/04 16:42:35
Can get by with only one lookup:
orig_item = orig
iannucci
2016/10/06 22:47:10
Done.
| |
| 156 raise ValueError( | |
| 157 'post_process added element %s[%r] in step %r' % ( | |
| 158 k, subk, step_name)) | |
| 159 if subv != orig_v[subk]: | |
| 160 raise ValueError( | |
| 161 'post_process changed element %s[%r] in step %r: %r to %r' % ( | |
| 162 k, subk, step_name, subv, orig_v[subk])) | |
| 163 elif v != orig_v: | |
| 164 raise ValueError( | |
| 165 'post_process changed value of key %r in step %r from %s to %s' % ( | |
| 166 k, step_name, orig_v, v)) | |
| 167 | |
| 168 | |
| 169 def RenderExpectation(test_data, step_odict): | |
| 170 """Applies the step post_process actions to the step_odict, if the | |
| 171 TestData actually contains any. | |
| 28 | 172 |
| 29 Returns the final expect_tests.Result.""" | 173 Returns the final expect_tests.Result.""" |
| 30 if test_data.whitelist_data: | 174 checker = Checker() |
| 31 whitelist_data = dict(test_data.whitelist_data) # copy so we can mutate it | |
| 32 def filter_expectation(step): | |
| 33 whitelist = whitelist_data.pop(step['name'], None) | |
| 34 if whitelist is None: | |
| 35 return | |
| 36 | 175 |
| 37 whitelist = set(whitelist) # copy so we can mutate it | 176 for hook, args, kwargs in test_data.post_process_hooks: |
| 38 if len(whitelist) > 0: | 177 input_odict = copy.deepcopy(step_odict) |
| 39 whitelist.add('name') | 178 checker._ignore(input_odict) |
|
martiniss
2016/10/03 19:14:19
why do we always ignore the input dict?
iannucci
2016/10/06 22:47:10
Done.
| |
| 40 step = {k: v for k, v in step.iteritems() if k in whitelist} | 179 rslt = hook(checker, input_odict, *args, **kwargs) |
| 41 whitelist.difference_update(step.keys()) | 180 if rslt is not None: |
| 42 if whitelist: | 181 checkIsSubset(rslt, step_odict) |
| 43 raise ValueError( | 182 step_odict = rslt |
| 44 "The whitelist includes fields %r in step %r, but those fields" | |
| 45 " don't exist." | |
| 46 % (whitelist, step['name'])) | |
| 47 return step | |
| 48 raw_expectations = filter(filter_expectation, raw_expectations) | |
| 49 | 183 |
| 50 if whitelist_data: | 184 return expect_tests.Result(step_odict.values(), checker._failed_checks) |
| 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 | 185 |
| 57 | 186 |
| 58 class SimulationAnnotatorStreamEngine(stream.AnnotatorStreamEngine): | 187 class SimulationAnnotatorStreamEngine(stream.AnnotatorStreamEngine): |
| 59 | 188 |
| 60 def __init__(self): | 189 def __init__(self): |
| 61 self._step_buffer_map = {} | 190 self._step_buffer_map = {} |
| 62 super(SimulationAnnotatorStreamEngine, self).__init__( | 191 super(SimulationAnnotatorStreamEngine, self).__init__( |
| 63 self.step_buffer(None)) | 192 self.step_buffer(None)) |
| 64 | 193 |
| 65 def step_buffer(self, step_name): | 194 def step_buffer(self, step_name): |
| 66 return self._step_buffer_map.setdefault(step_name, StringIO.StringIO()) | 195 return self._step_buffer_map.setdefault(step_name, StringIO.StringIO()) |
| 67 | 196 |
| 68 def new_step_stream(self, step_config): | 197 def new_step_stream(self, step_config): |
| 69 return self._create_step_stream(step_config, | 198 return self._create_step_stream(step_config, |
| 70 self.step_buffer(step_config.name)) | 199 self.step_buffer(step_config.name)) |
| 71 | 200 |
| 72 | 201 |
| 73 def RunRecipe(test_data): | 202 # This maps from (recipe_name,test_name) -> yielded test_data. It's outside of |
|
dnj
2016/10/04 16:42:35
Can you make this a namedtuple?
iannucci
2016/10/06 22:47:11
I tried, it makes it more verbose for very little
| |
| 203 # RunRecipe so that it can persist between RunRecipe calls in the same process. | |
| 204 _GEN_TEST_CACHE = {} | |
| 205 | |
| 206 def RunRecipe(recipe_name, test_name): | |
| 74 """Actually runs the recipe given the GenTests-supplied test_data.""" | 207 """Actually runs the recipe given the GenTests-supplied test_data.""" |
| 75 from . import config_types | 208 from . import config_types |
| 76 from . import loader | 209 from . import loader |
| 77 from . import run | 210 from . import run |
| 78 from . import step_runner | 211 from . import step_runner |
| 79 from . import stream | 212 |
| 213 if recipe_name not in _GEN_TEST_CACHE: | |
| 214 recipe_script = _UNIVERSE.load_recipe(recipe_name) | |
| 215 test_api = loader.create_test_api(recipe_script.LOADED_DEPS, _UNIVERSE) | |
| 216 for test_data in recipe_script.GenTests(test_api): | |
| 217 _GEN_TEST_CACHE[(recipe_name, test_data.name)] = test_data | |
| 218 | |
| 219 test_data = _GEN_TEST_CACHE[(recipe_name, test_name)] | |
| 80 | 220 |
| 81 config_types.ResetTostringFns() | 221 config_types.ResetTostringFns() |
| 82 | 222 |
| 83 annotator = SimulationAnnotatorStreamEngine() | 223 annotator = SimulationAnnotatorStreamEngine() |
| 84 stream_engine = stream.ProductStreamEngine( | 224 stream_engine = stream.ProductStreamEngine( |
| 85 stream.StreamEngineInvariants(), | 225 stream.StreamEngineInvariants(), |
| 86 annotator) | 226 annotator) |
| 87 with stream_engine: | 227 with stream_engine: |
| 88 step_runner = step_runner.SimulationStepRunner(stream_engine, test_data, | 228 step_runner = step_runner.SimulationStepRunner(stream_engine, test_data, |
| 89 annotator) | 229 annotator) |
| 90 | 230 |
| 91 engine = run.RecipeEngine(step_runner, test_data.properties, _UNIVERSE) | 231 props = test_data.properties.copy() |
|
dnj
2016/10/04 16:42:34
deepcopy? I think properties can be nested.
iannucci
2016/10/06 22:47:11
they can, but it doesn't matter.
| |
| 92 recipe_script = _UNIVERSE.load_recipe(test_data.properties['recipe']) | 232 props['recipe'] = recipe_name |
| 233 engine = run.RecipeEngine(step_runner, props, _UNIVERSE) | |
| 234 recipe_script = _UNIVERSE.load_recipe(recipe_name) | |
| 93 api = loader.create_recipe_api(recipe_script.LOADED_DEPS, engine, test_data) | 235 api = loader.create_recipe_api(recipe_script.LOADED_DEPS, engine, test_data) |
| 94 result = engine.run(recipe_script, api) | 236 result = engine.run(recipe_script, api) |
| 95 | 237 |
| 96 # Don't include tracebacks in expectations because they are too sensitive to | 238 # Don't include tracebacks in expectations because they are too sensitive to |
| 97 # change. | 239 # change. |
| 98 result.result.pop('traceback', None) | 240 result.result.pop('traceback', None) |
| 99 raw_expectations = step_runner.steps_ran + [result.result] | 241 raw_expectations = step_runner.steps_ran.copy() |
| 242 raw_expectations[result.result['name']] = result.result | |
| 100 | 243 |
| 101 try: | 244 try: |
| 102 return RenderExpectation(test_data, raw_expectations) | 245 return RenderExpectation(test_data, raw_expectations) |
| 103 except: | 246 except: |
| 104 print | 247 print |
| 105 print "The expectations would have been:" | 248 print "The expectations would have been:" |
| 106 json.dump(raw_expectations, sys.stdout, indent=2) | 249 json.dump(raw_expectations, sys.stdout, indent=2) |
| 107 raise | 250 raise |
| 108 | 251 |
| 109 | 252 |
| (...skipping 13 matching lines...) Expand all Loading... | |
| 123 def cover_omit(): | 266 def cover_omit(): |
| 124 omit = [ ] | 267 omit = [ ] |
| 125 | 268 |
| 126 for mod_dir_base in _UNIVERSE.module_dirs: | 269 for mod_dir_base in _UNIVERSE.module_dirs: |
| 127 if os.path.isdir(mod_dir_base): | 270 if os.path.isdir(mod_dir_base): |
| 128 omit.append(os.path.join(mod_dir_base, '*', 'resources', '*')) | 271 omit.append(os.path.join(mod_dir_base, '*', 'resources', '*')) |
| 129 | 272 |
| 130 return omit | 273 return omit |
| 131 | 274 |
| 132 | 275 |
| 133 class InsufficientTestCoverage(Exception): pass | 276 class InsufficientTestCoverage(Exception): |
| 277 pass | |
| 134 | 278 |
| 135 | 279 |
| 136 @expect_tests.covers(test_gen_coverage) | 280 @expect_tests.covers(test_gen_coverage) |
| 137 def GenerateTests(): | 281 def GenerateTests(): |
| 138 from . import loader | 282 from . import loader |
| 139 | 283 |
| 140 cover_mods = [ ] | 284 cover_mods = [ ] |
| 141 for mod_dir_base in _UNIVERSE.module_dirs: | 285 for mod_dir_base in _UNIVERSE.module_dirs: |
| 142 if os.path.isdir(mod_dir_base): | 286 if os.path.isdir(mod_dir_base): |
| 143 cover_mods.append(os.path.join(mod_dir_base, '*.py')) | 287 cover_mods.append(os.path.join(mod_dir_base, '*.py')) |
| 144 | 288 |
| 145 for recipe_path, recipe_name in _UNIVERSE.loop_over_recipes(): | 289 for recipe_path, recipe_name in _UNIVERSE.loop_over_recipes(): |
| 146 try: | 290 try: |
| 147 recipe = _UNIVERSE.load_recipe(recipe_name) | 291 recipe = _UNIVERSE.load_recipe(recipe_name) |
| 148 test_api = loader.create_test_api(recipe.LOADED_DEPS, _UNIVERSE) | 292 test_api = loader.create_test_api(recipe.LOADED_DEPS, _UNIVERSE) |
| 149 | 293 |
| 150 covers = cover_mods + [recipe_path] | 294 covers = cover_mods + [recipe_path] |
| 151 | 295 |
| 152 full_expectation_count = 0 | |
| 153 for test_data in recipe.GenTests(test_api): | 296 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) | 297 root, name = os.path.split(recipe_path) |
| 157 name = os.path.splitext(name)[0] | 298 name = os.path.splitext(name)[0] |
| 158 expect_path = os.path.join(root, '%s.expected' % name) | 299 expect_path = os.path.join(root, '%s.expected' % name) |
| 159 | |
| 160 test_data.properties['recipe'] = recipe_name.replace('\\', '/') | |
| 161 yield expect_tests.Test( | 300 yield expect_tests.Test( |
| 162 '%s.%s' % (recipe_name, test_data.name), | 301 '%s.%s' % (recipe_name, test_data.name), |
| 163 expect_tests.FuncCall(RunRecipe, test_data), | 302 expect_tests.FuncCall(RunRecipe, recipe_name, test_data.name), |
| 164 expect_dir=expect_path, | 303 expect_dir=expect_path, |
| 165 expect_base=test_data.name, | 304 expect_base=test_data.name, |
| 166 covers=covers, | 305 covers=covers, |
| 167 break_funcs=(recipe.RunSteps,) | 306 break_funcs=(recipe.RunSteps,) |
| 168 ) | 307 ) |
| 169 | |
| 170 if full_expectation_count < 1: | |
| 171 raise InsufficientTestCoverage( | |
| 172 'Must have at least 1 test without a whitelist!') | |
| 173 except: | 308 except: |
| 174 info = sys.exc_info() | 309 info = sys.exc_info() |
| 175 new_exec = Exception('While generating results for %r: %s: %s' % ( | 310 new_exec = Exception('While generating results for %r: %s: %s' % ( |
| 176 recipe_name, info[0].__name__, str(info[1]))) | 311 recipe_name, info[0].__name__, str(info[1]))) |
| 177 raise new_exec.__class__, new_exec, info[2] | 312 raise new_exec.__class__, new_exec, info[2] |
| 178 | 313 |
| 179 | 314 |
| 180 def main(universe, args=None): | 315 def main(universe, args=None): |
| 181 """Runs simulation tests on a given repo of recipes. | 316 """Runs simulation tests on a given repo of recipes. |
| 182 | 317 |
| (...skipping 10 matching lines...) Expand all Loading... | |
| 193 'TESTING_SLAVENAME']: | 328 'TESTING_SLAVENAME']: |
| 194 if env_var in os.environ: | 329 if env_var in os.environ: |
| 195 logging.warn("Ignoring %s environment variable." % env_var) | 330 logging.warn("Ignoring %s environment variable." % env_var) |
| 196 os.environ.pop(env_var) | 331 os.environ.pop(env_var) |
| 197 | 332 |
| 198 global _UNIVERSE | 333 global _UNIVERSE |
| 199 _UNIVERSE = universe | 334 _UNIVERSE = universe |
| 200 | 335 |
| 201 expect_tests.main('recipe_simulation_test', GenerateTests, | 336 expect_tests.main('recipe_simulation_test', GenerateTests, |
| 202 cover_omit=cover_omit(), args=args) | 337 cover_omit=cover_omit(), args=args) |
| OLD | NEW |