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

Side by Side Diff: recipe_engine/simulation_test.py

Issue 2387763003: Add initial postprocess unit test thingy. (Closed)
Patch Set: 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
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
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
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)
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698