Index: recipe_engine/simulation_test.py |
diff --git a/recipe_engine/simulation_test.py b/recipe_engine/simulation_test.py |
index 17c1d1e511689cdb38340f1af3a4585b640e46df..96c0d479e92b16e7fd80bb730fa6f301a44900d1 100644 |
--- a/recipe_engine/simulation_test.py |
+++ b/recipe_engine/simulation_test.py |
@@ -5,15 +5,22 @@ |
"""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 |
+ |
+from collections import OrderedDict |
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. |
@@ -22,37 +29,159 @@ import expect_tests |
_UNIVERSE = None |
-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. |
- 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) |
+linecache = {} |
+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.
|
+ filename = os.path.abspath(filename) |
+ if filename in linecache: |
+ return linecache[filename] |
+ |
+ with open(filename, 'r') as f: |
+ ret = f.readlines() |
+ linecache[filename] = ret |
+ return ret |
+ |
+ |
+def getReferencedNamesAndNode(filename, lineno): |
+ lines = getLines(filename) |
+ i = lineno-1 |
+ 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
|
+ try: |
+ to_parse = ''.join(lines[i:lineno]).strip() |
+ node = ast.parse(to_parse) |
+ break |
+ except SyntaxError: |
+ continue |
martiniss
2016/10/03 19:14:19
Why is this here? comment? :)
iannucci
2016/10/06 22:47:11
Done.
|
+ names = set(n.id for n in ast.walk(node) if isinstance(n, ast.Name)) |
+ return (names, node, i) |
+ |
+ |
+class Checker(object): |
+ def __init__(self): |
+ self._failed_checks = [] |
+ 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.
|
+ |
+ def _ignore(self, obj): |
+ self._ignore_set.extend(obj) |
+ |
+ 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.
|
+ if exp: |
+ # this check passed |
+ return |
+ |
+ 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.
|
+ try: |
+ raise ZeroDivisionError |
+ except ZeroDivisionError: |
+ stk = sys.exc_info()[2].tb_frame.f_back.f_back |
+ |
+ refNames, node, actual_lineno = getReferencedNamesAndNode( |
+ stk.f_code.co_filename, stk.f_lineno) |
+ local_vars = OrderedDict() |
+ for k, v in stk.f_locals.iteritems(): |
+ if k not in refNames: |
+ continue |
+ if any(v is itm for itm in self._ignore_set): |
+ continue |
+ local_vars[k] = v |
+ self._failed_checks.append(expect_tests.Check( |
+ hint, |
+ astunparse.unparse(node), |
+ local_vars, |
+ stk.f_code.co_filename, |
+ actual_lineno, |
+ False |
+ )) |
+ |
+ def __call__(self, arg1, arg2=None): |
+ if arg2 is not None: |
+ hint = arg1 |
+ exp = arg2 |
+ else: |
+ hint = None |
+ exp = arg1 |
+ self._inner(hint, exp) |
+ |
+ |
+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.
|
+ # both a and b should be ordered step lists. |
+ 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
|
+ return |
+ |
+ for step_name, step in a.iteritems(): |
+ if step_name not in b: |
+ raise ValueError( |
dnj
2016/10/04 16:42:34
Maybe define a PostProcessError type?
iannucci
2016/10/06 22:47:10
Done.
|
+ 'post_process introduced new step %r' % step_name) |
- if whitelist_data: |
+ if 'name' not in step: |
raise ValueError( |
- "The step names %r were included in the whitelist, but were never run." |
- % [s['name'] for s in whitelist_data]) |
+ '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.
|
- return expect_tests.Result(raw_expectations) |
+ orig = b[step_name] |
+ if step is orig: |
dnj
2016/10/04 16:42:34
== ?
|
+ continue |
+ |
+ for k, v in step.iteritems(): |
+ if k not in orig: |
+ raise ValueError( |
+ 'post_process introduced new key %r in step %r' % ( |
+ k, step_name)) |
+ |
+ orig_v = orig[k] |
+ 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,
|
+ continue |
+ |
+ if type(v) != type(orig_v): |
+ raise ValueError( |
+ 'post_process changed type of key %r in step %r from %s to %s' % ( |
+ 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.
|
+ |
+ 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
|
+ if len(v) > len(orig_v): |
+ raise ValueError( |
+ 'post_process %r in step %r is longer than the original: %d v %d' % |
+ (k, step_name, len(v), len(orig_v))) |
+ 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.
|
+ while iov < len(orig_v) - 1 and iv < len(v) - 1: |
+ if v[iv] == orig_v[iov]: |
+ iv += 1 |
+ iov += 1 |
+ if iv != len(v) - 1: |
+ raise ValueError( |
+ 'post_process added elements to %r in step %r: %r' % ( |
+ k, step_name, v[iv:])) |
+ elif isinstance(v, dict): |
+ for subk, subv in v.iteritems(): |
+ 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.
|
+ raise ValueError( |
+ 'post_process added element %s[%r] in step %r' % ( |
+ k, subk, step_name)) |
+ if subv != orig_v[subk]: |
+ raise ValueError( |
+ 'post_process changed element %s[%r] in step %r: %r to %r' % ( |
+ k, subk, step_name, subv, orig_v[subk])) |
+ elif v != orig_v: |
+ raise ValueError( |
+ 'post_process changed value of key %r in step %r from %s to %s' % ( |
+ k, step_name, orig_v, v)) |
+ |
+ |
+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.""" |
+ checker = Checker() |
+ |
+ for hook, args, kwargs in test_data.post_process_hooks: |
+ input_odict = copy.deepcopy(step_odict) |
+ 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.
|
+ rslt = hook(checker, input_odict, *args, **kwargs) |
+ if rslt is not None: |
+ checkIsSubset(rslt, step_odict) |
+ step_odict = rslt |
+ |
+ return expect_tests.Result(step_odict.values(), checker._failed_checks) |
class SimulationAnnotatorStreamEngine(stream.AnnotatorStreamEngine): |
@@ -70,13 +199,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 |
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
|
+# 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,15 +228,18 @@ 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() |
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.
|
+ 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) |
@@ -130,7 +273,8 @@ def cover_omit(): |
return omit |
-class InsufficientTestCoverage(Exception): pass |
+class InsufficientTestCoverage(Exception): |
+ pass |
@expect_tests.covers(test_gen_coverage) |
@@ -149,27 +293,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' % ( |