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 |