Chromium Code Reviews| Index: recipe_engine/simulation_test.py |
| diff --git a/recipe_engine/simulation_test.py b/recipe_engine/simulation_test.py |
| index 17c1d1e511689cdb38340f1af3a4585b640e46df..01b8e55328d1b8d4731797e79f9dd24b8c88d722 100644 |
| --- a/recipe_engine/simulation_test.py |
| +++ b/recipe_engine/simulation_test.py |
| @@ -5,15 +5,23 @@ |
| """Provides simulator test coverage for individual recipes.""" |
| import StringIO |
| +import ast |
| import contextlib |
| +import copy |
| import json |
| import logging |
| import os |
| import re |
| import sys |
| +import textwrap |
| +import traceback |
| +import inspect |
| + |
| +from collections import OrderedDict, namedtuple |
| from . import env |
| from . import stream |
| +import astunparse |
| import expect_tests |
| # This variable must be set in the dynamic scope of the functions in this file. |
| @@ -21,38 +29,350 @@ import expect_tests |
| # doesn't know how to serialize it. |
| _UNIVERSE = None |
| +class _checkTransformer(ast.NodeTransformer): |
| + """_checkTransformer is an ast NodeTransformer which extracts the helpful |
| + subexpressions from a python expression (specificially, from an invocation of |
|
Michael Achenbach
2016/10/07 16:32:04
nit: s/specificially/specifically
|
| + the Checker). These subexpressions will be printed along with the check's |
| + source code statement to provide context for the failed check. |
| + |
| + It knows the following transformations: |
| + * all python identifiers will be resolved to their local variable meaning. |
| + * `___ in <instance of dict>` will cause dict.keys() to be printed in lieu |
| + of the entire dictionary. |
| + * `a[b][c]` will cause `a[b]` and `a[b][c]` to be printed (for an arbitrary |
| + level of recursion) |
| + |
| + The transformed ast is NOT a valid python AST... In particular, every reduced |
| + subexpression will be an ast.Name() where the id is the code for the |
| + subexpression (which may not be a valid name! It could be `foo.bar()`.), and |
| + the ctx will be the eval'd value for that element. |
| + |
| + In addition to this, there will be a list of ast.Name nodes in the |
| + transformer's `extra` attribute for additional expressions which should be |
| + printed for debugging usefulness, but didn't fit into the ast tree anywhere. |
| + """ |
| + |
| + def __init__(self, lvars, gvars): |
| + self.lvars = lvars |
| + self.gvars = gvars |
| + self.extras = [] |
| + |
| + def _eval(self, node): |
| + code = astunparse.unparse(node).strip() |
| + try: |
| + thing = eval(code, self.gvars, self.lvars) |
| + return ast.Name(code, thing) |
| + except NameError: |
| + return node |
| + |
| + def visit_Compare(self, node): |
| + # match `___ in instanceof(dict)` |
| + node = self.generic_visit(node) |
| + |
| + if len(node.ops) == 1 and isinstance(node.ops[0], ast.In): |
| + cmps = node.comparators |
| + if len(cmps) == 1 and isinstance(cmps[0], ast.Name): |
| + name = cmps[0] |
| + if isinstance(name.ctx, dict): |
| + node = ast.Compare( |
| + node.left, |
| + node.ops, |
| + [ast.Name(name.id+".keys()", name.ctx.keys())]) |
| + |
| + return node |
| + |
| + def visit_Subscript(self, node): |
| + # match __[a] |
| + node = self.generic_visit(node) |
| + if isinstance(node.slice, ast.Index): |
| + if isinstance(node.slice.value, ast.Name): |
| + self.extras.append(self._eval(node.slice.value)) |
| + node = self._eval(node) |
| + return node |
| + |
| + def visit_Name(self, node): |
| + # match foo |
| + return self._eval(node) |
| + |
| + |
| +def render_user_value(val): |
| + """Takes a subexpression user value, and attempts to render it in the most |
| + useful way possible. |
| + |
| + Currently this will use render_re for compiled regular expressions, and will |
| + fall back to repr() for everything else. |
| + |
| + It should be the goal of this function to return an `eval`able string that |
| + would yield the equivalent value in a python interpreter. |
| + """ |
| + if isinstance(val, re._pattern_type): |
| + return render_re(val) |
| + return repr(val) |
| + |
| + |
| +def render_re(regex): |
| + """Renders a repr()-style value for a compiled regular expression.""" |
| + actual_flags = [] |
| + if regex.flags: |
| + flags = [ |
| + (re.IGNORECASE, 'IGNORECASE'), |
| + (re.LOCALE, 'LOCALE'), |
| + (re.UNICODE, 'UNICODE'), |
| + (re.MULTILINE, 'MULTILINE'), |
| + (re.DOTALL, 'DOTALL'), |
| + (re.VERBOSE, 'VERBOSE'), |
| + ] |
| + for val, name in flags: |
| + if regex.flags & val: |
| + actual_flags.append(name) |
| + if actual_flags: |
| + return 're.compile(%r, %s)' % (regex.pattern, '|'.join(actual_flags)) |
| + else: |
| + return 're.compile(%r)' % regex.pattern |
| + |
| + |
| +class _checker(object): |
| + def __init__(self, filename, lineno, funcname, args, kwargs, *ignores): |
| + self._failed_checks = [] |
| + |
| + # _ignore_set is the set of objects that we should never print as local |
| + # variables. We start this set off by including the actual _checker object, |
| + # since there's no value to printing that. |
| + self._ignore_set = {id(x) for x in ignores+(self,)} |
| + |
| + self._ctx_filename = filename |
| + self._ctx_lineno = lineno |
| + self._ctx_funcname = funcname |
| + self._ctx_args = map(repr, args) |
| + self._ctx_kwargs = {k: repr(v) for k, v in kwargs.iteritems()} |
| + |
| + def _process_frame(self, frame, with_vars): |
| + """This processes a stack frame into an expect_tests.CheckFrame, which |
| + includes file name, line number, function name (of the function containing |
| + the frame), the parsed statement at that line, and the relevant local |
| + variables/subexpressions (if with_vars is True). |
| + |
| + In addition to transforming the expression with _checkTransformer, this |
| + will: |
| + * omit subexpressions which resolve to callable()'s |
| + * omit the overall step ordered dictionary |
| + * transform all subexpression values using render_user_value(). |
| + """ |
| + raw_frame, filename, lineno, func_name, _, _ = frame |
| + |
| + filelines, _ = inspect.findsource(raw_frame) |
| + |
| + i = lineno-1 |
| + # this dumb little loop will try to parse a node out of the ast which ends |
| + # at the line that shows up in the frame. To do this, tries parsing that |
| + # line, and if it fails, it adds a prefix line. It keeps doing this until |
| + # it gets a successful parse. |
| + for i in xrange(lineno-1, 0, -1): |
| + try: |
| + to_parse = ''.join(filelines[i:lineno]).strip() |
| + node = ast.parse(to_parse) |
| + break |
| + except SyntaxError: |
| + continue |
| + varmap = None |
| + if with_vars: |
| + xfrmr = _checkTransformer(raw_frame.f_locals, raw_frame.f_globals) |
| + |
| + varmap = {} |
| + def add_node(n): |
| + if isinstance(n, ast.Name): |
| + val = n.ctx |
| + if isinstance(val, ast.AST): |
| + return |
| + if callable(val) or id(val) in self._ignore_set: |
| + return |
| + if n.id not in varmap: |
| + varmap[n.id] = render_user_value(val) |
| + map(add_node, ast.walk(xfrmr.visit(copy.deepcopy(node)))) |
| + # TODO(iannucci): only add extras if verbose is True |
| + map(add_node, xfrmr.extras) |
| + |
| + return expect_tests.CheckFrame( |
| + filename, |
| + lineno, |
| + func_name, |
| + astunparse.unparse(node).strip(), |
| + varmap |
| + ) |
| + |
| + def _call_impl(self, hint, exp): |
| + """This implements the bulk of what happens when you run `check(exp)`. It |
| + will crawl back up the stack and extract information about all of the frames |
| + which are relevent to the check, including file:lineno and the code |
| + statement which occurs at that location for all the frames. |
| + |
| + On the last frame (the one that actually contains the check call), it will |
| + also try to obtain relevant local values in the check so they can be printed |
| + with the check to aid in debugging and diagnosis. It uses the parsed |
| + statement found at that line to find all referenced local variables in that |
| + frame. |
| + """ |
| + |
| + if exp: |
| + # TODO(iannucci): collect this in verbose mode. |
| + # this check passed |
| + return |
| -def RenderExpectation(test_data, raw_expectations): |
| - """Applies the step filters (e.g. whitelists, etc.) to the raw_expectations, |
| - if the TestData actually contains any filters. |
| + try: |
| + frames = inspect.stack()[2:] |
| + |
| + # grab all frames which have self as a local variable (e.g. frames |
| + # associated with this checker), excluding self.__call__. |
| + try: |
| + i = 0 |
| + for i, f in enumerate(frames): |
| + if self not in f[0].f_locals.itervalues(): |
| + break |
| + keep_frames = [self._process_frame(f, j == 0) |
| + for j, f in enumerate(frames[:i-1])] |
| + finally: |
| + del f |
| + |
| + # order it so that innermost frame is at the bottom |
| + keep_frames = keep_frames[::-1] |
| + |
| + self._failed_checks.append(expect_tests.Check( |
| + hint, |
| + self._ctx_filename, |
| + self._ctx_lineno, |
| + self._ctx_funcname, |
| + self._ctx_args, |
| + self._ctx_kwargs, |
| + keep_frames, |
| + False |
| + )) |
| + finally: |
| + # avoid reference cycle as suggested by inspect docs. |
| + del frames |
| + |
| + def __call__(self, arg1, arg2=None): |
| + if arg2 is not None: |
| + hint = arg1 |
| + exp = arg2 |
| + else: |
| + hint = None |
| + exp = arg1 |
| + self._call_impl(hint, exp) |
| + |
| + |
| +class PostProcessError(ValueError): |
| + def __init__(self, msg): |
| + super(PostProcessError, self).__init__("post_process %s" % (msg)) |
| + |
| + |
| +class PostProcessStepError(ValueError): |
| + def __init__(self, msg, step_name): |
| + super(PostProcessStepError, self).__init__( |
| + msg + " in step " + repr(step_name)) |
| + |
| + |
| +def _checkIsSubset(a, b): |
| + """This checks to see that a is a subset of b, where both a and b are |
| + OrderedDicts. This will raise a PostProcessError if any data in a is not |
| + present in b.""" |
| + |
| + # both a and b should be ordered step lists. |
| + if a is b: |
| + return |
| + |
| + typeOK = ( |
| + len(b) == 0 and isinstance(b, (OrderedDict, dict)) or |
| + len(b) != 0 and isinstance(b, OrderedDict)) |
| + if not typeOK: |
| + raise PostProcessError( |
| + 'must always return an OrderedDict() or the empty {}.') |
| + |
| + for step_name, step in a.iteritems(): |
| + if step_name not in b: |
| + raise PostProcessError('introduced new step %r' % step_name) |
| + |
| + if 'name' not in step: |
| + raise PostProcessStepError('removed "name"', step_name) |
| + |
| + orig = b[step_name] |
| + if step is orig: |
| + continue |
| + |
| + for k, v in step.iteritems(): |
| + if k not in orig: |
| + raise PostProcessStepError('introduced new key %r' % k, step_name) |
| + |
| + orig_v = orig[k] |
| + if v is orig_v: |
| + continue |
| + |
| + if type(v) != type(orig_v): |
| + raise PostProcessStepError( |
| + 'changed type of key %r from %s to %s' % ( |
| + k, type(orig_v).__name__, type(v).__name__), step_name) |
| + |
| + if isinstance(v, list): |
| + if len(v) > len(orig_v): |
| + raise PostProcessStepError( |
| + '%r is longer than the original (%d v %d)' % |
| + (k, len(v), len(orig_v)), step_name) |
| + idx_orig_v = idx_v = 0 |
| + while idx_orig_v < len(orig_v) - 1 and idx_v < len(v) - 1: |
| + if v[idx_v] == orig_v[idx_orig_v]: |
| + idx_v += 1 |
| + idx_orig_v += 1 |
| + if idx_v != len(v) - 1: |
| + raise PostProcessStepError( |
| + 'added elements to %r (%r)' % (k, v[idx_v:]), step_name) |
| + elif isinstance(v, dict): |
| + for subk, subv in v.iteritems(): |
| + el = orig_v.get(subk) |
| + if el is None: |
| + raise PostProcessStepError('added element %s[%r]' % (k, subk), |
| + step_name) |
| + if subv != el: |
| + raise PostProcessError( |
| + 'changed element %s[%r] in step %r: %r to %r' % ( |
| + k, subk, step_name, subv, orig_v[subk])) |
| + elif v != orig_v: |
| + raise PostProcessStepError( |
| + 'changed value of key %r from %s to %s' % (k, orig_v, v), step_name) |
| + |
| + |
| +def _nameOfCallable(c): |
| + if inspect.isfunction(c): |
| + return c.__name__ |
| + if inspect.ismethod(c): |
| + return c.im_class.__name__+'.'+c.__name__ |
| + if hasattr(c, '__class__') and hasattr(c, '__call__'): |
| + return c.__class__.__name__+'.__call__' |
| + return repr(c) |
| + |
| + |
| +def _renderExpectation(test_data, step_odict): |
| + """Applies the step post_process actions to the step_odict, if the |
| + TestData actually contains any. |
| Returns the final expect_tests.Result.""" |
| - if test_data.whitelist_data: |
| - whitelist_data = dict(test_data.whitelist_data) # copy so we can mutate it |
| - def filter_expectation(step): |
| - whitelist = whitelist_data.pop(step['name'], None) |
| - if whitelist is None: |
| - return |
| - |
| - whitelist = set(whitelist) # copy so we can mutate it |
| - if len(whitelist) > 0: |
| - whitelist.add('name') |
| - step = {k: v for k, v in step.iteritems() if k in whitelist} |
| - whitelist.difference_update(step.keys()) |
| - if whitelist: |
| - raise ValueError( |
| - "The whitelist includes fields %r in step %r, but those fields" |
| - " don't exist." |
| - % (whitelist, step['name'])) |
| - return step |
| - raw_expectations = filter(filter_expectation, raw_expectations) |
| - |
| - if whitelist_data: |
| - raise ValueError( |
| - "The step names %r were included in the whitelist, but were never run." |
| - % [s['name'] for s in whitelist_data]) |
| - |
| - return expect_tests.Result(raw_expectations) |
| + |
| + failed_checks = [] |
| + |
| + for hook, args, kwargs, filename, lineno in test_data.post_process_hooks: |
| + input_odict = copy.deepcopy(step_odict) |
| + # we ignore the input_odict so that it never gets printed in full. Usually |
| + # the check invocation itself will index the input_odict or will use it only |
| + # for a key membership comparison, which provides enough debugging context. |
| + checker = _checker(filename, lineno, _nameOfCallable(hook), args, kwargs, |
| + input_odict) |
| + rslt = hook(checker, input_odict, *args, **kwargs) |
| + failed_checks += checker._failed_checks |
| + if rslt is not None: |
| + _checkIsSubset(rslt, step_odict) |
| + step_odict = rslt |
| + |
| + # empty means drop expectation |
| + result_data = step_odict.values() if step_odict else None |
| + return expect_tests.Result(result_data, failed_checks) |
| class SimulationAnnotatorStreamEngine(stream.AnnotatorStreamEngine): |
| @@ -70,13 +390,24 @@ class SimulationAnnotatorStreamEngine(stream.AnnotatorStreamEngine): |
| self.step_buffer(step_config.name)) |
| -def RunRecipe(test_data): |
| +# This maps from (recipe_name,test_name) -> yielded test_data. It's outside of |
| +# RunRecipe so that it can persist between RunRecipe calls in the same process. |
| +_GEN_TEST_CACHE = {} |
| + |
| +def RunRecipe(recipe_name, test_name): |
| """Actually runs the recipe given the GenTests-supplied test_data.""" |
| from . import config_types |
| from . import loader |
| from . import run |
| from . import step_runner |
| - from . import stream |
| + |
| + if recipe_name not in _GEN_TEST_CACHE: |
| + recipe_script = _UNIVERSE.load_recipe(recipe_name) |
| + test_api = loader.create_test_api(recipe_script.LOADED_DEPS, _UNIVERSE) |
| + for test_data in recipe_script.GenTests(test_api): |
| + _GEN_TEST_CACHE[(recipe_name, test_data.name)] = test_data |
| + |
| + test_data = _GEN_TEST_CACHE[(recipe_name, test_name)] |
| config_types.ResetTostringFns() |
| @@ -88,18 +419,21 @@ def RunRecipe(test_data): |
| step_runner = step_runner.SimulationStepRunner(stream_engine, test_data, |
| annotator) |
| - engine = run.RecipeEngine(step_runner, test_data.properties, _UNIVERSE) |
| - recipe_script = _UNIVERSE.load_recipe(test_data.properties['recipe']) |
| + props = test_data.properties.copy() |
| + props['recipe'] = recipe_name |
| + engine = run.RecipeEngine(step_runner, props, _UNIVERSE) |
| + recipe_script = _UNIVERSE.load_recipe(recipe_name) |
| api = loader.create_recipe_api(recipe_script.LOADED_DEPS, engine, test_data) |
| result = engine.run(recipe_script, api) |
| # Don't include tracebacks in expectations because they are too sensitive to |
| # change. |
| result.result.pop('traceback', None) |
| - raw_expectations = step_runner.steps_ran + [result.result] |
| + raw_expectations = step_runner.steps_ran.copy() |
| + raw_expectations[result.result['name']] = result.result |
| try: |
| - return RenderExpectation(test_data, raw_expectations) |
| + return _renderExpectation(test_data, raw_expectations) |
| except: |
| print "The expectations would have been:" |
| @@ -130,7 +464,8 @@ def cover_omit(): |
| return omit |
| -class InsufficientTestCoverage(Exception): pass |
| +class InsufficientTestCoverage(Exception): |
| + pass |
| @expect_tests.covers(test_gen_coverage) |
| @@ -149,27 +484,18 @@ def GenerateTests(): |
| covers = cover_mods + [recipe_path] |
| - full_expectation_count = 0 |
| for test_data in recipe.GenTests(test_api): |
| - if not test_data.whitelist_data: |
| - full_expectation_count += 1 |
| root, name = os.path.split(recipe_path) |
| name = os.path.splitext(name)[0] |
| expect_path = os.path.join(root, '%s.expected' % name) |
| - |
| - test_data.properties['recipe'] = recipe_name.replace('\\', '/') |
| yield expect_tests.Test( |
| '%s.%s' % (recipe_name, test_data.name), |
| - expect_tests.FuncCall(RunRecipe, test_data), |
| + expect_tests.FuncCall(RunRecipe, recipe_name, test_data.name), |
| expect_dir=expect_path, |
| expect_base=test_data.name, |
| covers=covers, |
| break_funcs=(recipe.RunSteps,) |
| ) |
| - |
| - if full_expectation_count < 1: |
| - raise InsufficientTestCoverage( |
| - 'Must have at least 1 test without a whitelist!') |
| except: |
| info = sys.exc_info() |
| new_exec = Exception('While generating results for %r: %s: %s' % ( |