Index: third_party/recipe_engine/expect_tests/type_definitions.py |
diff --git a/third_party/recipe_engine/expect_tests/type_definitions.py b/third_party/recipe_engine/expect_tests/type_definitions.py |
new file mode 100644 |
index 0000000000000000000000000000000000000000..e357156f0f1b9a5a54f407dcfea2b32badd63253 |
--- /dev/null |
+++ b/third_party/recipe_engine/expect_tests/type_definitions.py |
@@ -0,0 +1,433 @@ |
+# Copyright 2014 The Chromium Authors. All rights reserved. |
+# Use of this source code is governed by a BSD-style license that can be |
+# found in the LICENSE file. |
+ |
+import inspect |
+import os |
+import re |
+ |
+from collections import namedtuple |
+ |
+# These have to do with deriving classes from namedtuple return values. |
+# Pylint can't tell that namedtuple returns a new-style type() object. |
+# |
+# "no __init__ method" pylint: disable=W0232 |
+# "use of super on an old style class" pylint: disable=E1002 |
+ |
+UnknownError = namedtuple('UnknownError', 'message') |
+NoMatchingTestsError = namedtuple('NoMatchingTestsError', '') |
+Result = namedtuple('Result', 'data') |
+MultiResult = namedtuple('MultiResult', 'results') |
+DirSeen = namedtuple('DirSeen', 'dir') |
+ |
+class ResultStageAbort(Exception): |
+ pass |
+ |
+ |
+class Failure(object): |
+ pass |
+ |
+ |
+class TestError(namedtuple('TestError', 'test message log_lines')): |
+ def __new__(cls, test, message, log_lines=()): |
+ return super(TestError, cls).__new__(cls, test, message, log_lines) |
+ |
+ |
+class Bind(namedtuple('_Bind', 'loc name')): |
+ """A placeholder argument for a FuncCall. |
+ |
+ A Bind instance either indicates a 0-based index into the args argument, |
+ or a name in kwargs when calling .bind(). |
+ """ |
+ |
+ def __new__(cls, loc=None, name=None): |
+ """Either loc or name must be defined.""" |
+ assert ((loc is None and isinstance(name, str)) or |
+ (name is None and 0 <= loc)) |
+ return super(Bind, cls).__new__(cls, loc, name) |
+ |
+ def bind(self, args=(), kwargs=None): |
+ """Return the appropriate value for this Bind when binding against args and |
+ kwargs. |
+ |
+ >>> b = Bind(2) |
+ >>> # A bind will return itself if a matching arg value isn't present |
+ >>> b.bind(['cat'], {'arg': 100}) is b |
+ True |
+ >>> # Otherwise the matching value is returned |
+ >>> v = 'money' |
+ >>> b.bind(['happy', 'cool', v]) is v |
+ True |
+ >>> b2 = Bind(name='cat') |
+ >>> b2.bind((), {'cat': 'cool'}) |
+ 'cool' |
+ """ |
+ kwargs = kwargs or {} |
+ if self.loc is not None: |
+ v = args[self.loc:self.loc+1] |
+ return self if not v else v[0] |
+ else: |
+ return kwargs.get(self.name, self) |
+ |
+ @staticmethod |
+ def maybe_bind(value, args, kwargs): |
+ """Helper which binds value with (args, kwargs) if value is a Bind.""" |
+ return value.bind(args, kwargs) if isinstance(value, Bind) else value |
+ |
+ |
+class FuncCall(object): |
+ def __init__(self, func, *args, **kwargs): |
+ """FuncCall is a trivial single-function closure which is pickleable. |
+ |
+ This assumes that func, args and kwargs are all pickleable. |
+ |
+ When constructing the FuncCall, you may also set any positional or named |
+ argument to a Bind instance. A FuncCall can then be bound with the |
+ .bind(*args, **kwargs) method, and finally called by invoking func_call(). |
+ |
+ A FuncCall may also be directly invoked with func_call(*args, **kwargs), |
+ which is equivalent to func_call.bind(*args, **kwargs)(). |
+ |
+ Invoking a FuncCall with an unbound Bind instance is an error. |
+ |
+ >>> def func(alpha, beta=None, gamma=None): |
+ ... return '%s-%s-%s' % (alpha, beta, gamma) |
+ >>> f = FuncCall(func, Bind(2), beta=Bind(name='context'), gamma=Bind(2)) |
+ >>> # the first arg and the named arg 'gamma' are bound to index 2 of args. |
+ >>> # the named arg 'beta' is bound to the named kwarg 'context'. |
+ >>> # |
+ >>> # The FuncCall is equivalent to (py3 pattern syntax): |
+ >>> # UNSET = object() |
+ >>> # def f(_, _, arg1, *_, context=UNSET, **_): |
+ >>> # assert pickle is not UNSET |
+ >>> # return func(arg1, beta=context, gamma=arg1) |
+ >>> bound = f.bind('foo', 'bar', 'baz', context=100, extra=None) |
+ >>> # At this point, bound is a FuncCall with no Bind arguments, and can be |
+ >>> # invoked. This would be equivalent to: |
+ >>> # func('baz', beta=100, gamma='baz') |
+ >>> bound() |
+ baz-100-baz |
+ |
+ Unused arguments in the .bind() call are ignored, which allows you to build |
+ value-agnostic invocations to FuncCall.bind(). |
+ """ |
+ self._func = func |
+ self._args = args |
+ self._kwargs = kwargs |
+ self._fully_bound = None |
+ |
+ # "access to a protected member" pylint: disable=W0212 |
+ func = property(lambda self: self._func) |
+ args = property(lambda self: self._args) |
+ kwargs = property(lambda self: self._kwargs) |
+ |
+ @property |
+ def fully_bound(self): |
+ if self._fully_bound is None: |
+ self._fully_bound = not ( |
+ any(isinstance(v, Bind) for v in self._args) or |
+ any(isinstance(v, Bind) for v in self._kwargs.itervalues()) |
+ ) |
+ return self._fully_bound |
+ |
+ def bind(self, *args, **kwargs): |
+ if self.fully_bound or not (args or kwargs): |
+ return self |
+ |
+ new = FuncCall(self._func) |
+ new._args = [Bind.maybe_bind(a, args, kwargs) for a in self.args] |
+ new._kwargs = {k: Bind.maybe_bind(v, args, kwargs) |
+ for k, v in self.kwargs.iteritems()} |
+ return new |
+ |
+ def __call__(self, *args, **kwargs): |
+ f = self.bind(args, kwargs) |
+ assert f.fully_bound |
+ return f.func(*f.args, **f.kwargs) |
+ |
+ def __repr__(self): |
+ return 'FuncCall(%r, *%r, **%r)' % (self.func, self.args, self.kwargs) |
+ |
+ |
+_Test = namedtuple( |
+ 'Test', 'name func_call expect_dir expect_base ext covers breakpoints') |
+ |
+class Test(_Test): |
+ TEST_COVERS_MATCH = re.compile(r'.*/test/([^/]*)_test\.py$') |
+ |
+ def __new__(cls, name, func_call, expect_dir=None, expect_base=None, |
+ ext='json', covers=None, breakpoints=None, break_funcs=()): |
+ """Create a new test. |
+ |
+ @param name: The name of the test. Will be used as the default expect_base |
+ |
+ @param func_call: A FuncCall object |
+ |
+ @param expect_dir: The directory which holds the expectation file for this |
+ Test. |
+ @param expect_base: The basename (without extension) of the expectation |
+ file. Defaults to |name|. |
+ @param ext: The extension of the expectation file. Affects the serializer |
+ used to write the expectations to disk. Valid values are |
+ 'json' and 'yaml' (Keys in SERIALIZERS). |
+ @param covers: A list of coverage file patterns to include for this Test. |
+ By default, a Test covers the file in which its function |
+ was defined, as well as the source file matching the test |
+ according to TEST_COVERS_MATCH. |
+ |
+ @param breakpoints: A list of (path, lineno, func_name) tuples. These will |
+ turn into breakpoints when the tests are run in 'debug' |
+ mode. See |break_funcs| for an easier way to set this. |
+ @param break_funcs: A list of functions for which to set breakpoints. |
+ """ |
+ breakpoints = breakpoints or [] |
+ if not breakpoints or break_funcs: |
+ for f in break_funcs or (func_call.func,): |
+ if hasattr(f, 'im_func'): |
+ f = f.im_func |
+ breakpoints.append((f.func_code.co_filename, |
+ f.func_code.co_firstlineno, |
+ f.func_code.co_name)) |
+ |
+ expect_dir = expect_dir.rstrip('/') |
+ return super(Test, cls).__new__(cls, name, func_call, expect_dir, |
+ expect_base, ext, covers, breakpoints) |
+ |
+ def coverage_includes(self): |
+ if self.covers is not None: |
+ return self.covers |
+ |
+ test_file = inspect.getabsfile(self.func_call.func) |
+ covers = [test_file] |
+ match = Test.TEST_COVERS_MATCH.match(test_file) |
+ if match: |
+ covers.append(os.path.join( |
+ os.path.dirname(os.path.dirname(test_file)), |
+ match.group(1) + '.py' |
+ )) |
+ |
+ return covers |
+ |
+ def expect_path(self, ext=None): |
+ expect_dir = self.expect_dir |
+ if expect_dir is None: |
+ test_file = inspect.getabsfile(self.func_call.func) |
+ expect_dir = os.path.splitext(test_file)[0] + '.expected' |
+ name = self.expect_base or self.name |
+ name = ''.join('_' if c in '<>:"\\/|?*\0' else c for c in name) |
+ return os.path.join(expect_dir, name + ('.%s' % (ext or self.ext))) |
+ |
+ def run(self, context=None): |
+ return self.func_call(context=context) |
+ |
+ def process(self, func=lambda test: test.run()): |
+ """Applies |func| to the test, and yields (self, func(self)). |
+ |
+ For duck-typing compatibility with MultiTest. |
+ |
+ Bind(name='context') if used by your test function, is bound to None. |
+ |
+ Used interally by expect_tests, you're not expected to call this yourself. |
+ """ |
+ yield self, func(self.bind(context=None)) |
+ |
+ def bind(self, *args, **kwargs): |
+ return self._replace(func_call=self.func_call.bind(*args, **kwargs)) |
+ |
+ def restrict(self, tests): |
+ assert tests[0] is self |
+ return self |
+ |
+ @property |
+ def tests(self): |
+ """Returns a list of all one tests in this singleton test. For uniformity |
+ with MultiTest.""" |
+ return [self] |
+ |
+ |
+_MultiTest = namedtuple( |
+ 'MultiTest', 'name make_ctx_call destroy_ctx_call tests atomic') |
+ |
+class MultiTest(_MultiTest): |
+ """A wrapper around one or more Test instances. |
+ |
+ Allows the entire group to have common pre- and post- actions and an optional |
+ shared context between the Test methods (represented by Bind(name='context')). |
+ |
+ Args: |
+ name - The name of the MultiTest. Each Test's name should be prefixed with |
+ this name, though this is not enforced. |
+ make_ctx_call - A FuncCall which will be called once before any test in this |
+ MultiTest runs. The return value of this FuncCall will become bound |
+ to the name 'context' for both the |destroy_ctx_call| as well as every |
+ test in |tests|. |
+ destroy_ctx_call - A FuncCall which will be called once after all tests in |
+ this MultiTest runs. The context object produced by |make_ctx_call| is |
+ bound to the name 'context'. |
+ tests - A list of Test instances. The context object produced by |
+ |make_ctx_call| is bound to the name 'context'. |
+ atomic - A boolean which indicates that this MultiTest must be executed |
+ either all at once, or not at all (i.e., subtests may not be filtered). |
+ """ |
+ |
+ def restrict(self, tests): |
+ """A helper method to re-cast the MultiTest with fewer subtests. |
+ |
+ All fields will be identical except for tests. If this MultiTest is atomic, |
+ then this method returns |self|. |
+ |
+ Used interally by expect_tests, you're not expected to call this yourself. |
+ """ |
+ if self.atomic: |
+ return self |
+ assert all(t in self.tests for t in tests) |
+ return self._replace(tests=tests) |
+ |
+ def process(self, func=lambda test: test.run()): |
+ """Applies |func| to each sub-test, with properly bound context. |
+ |
+ make_ctx_call will be called before any test, and its return value becomes |
+ bound to the name 'context'. All sub-tests will be bound with this value |
+ as well as destroy_ctx_call, which will be invoked after all tests have |
+ been yielded. |
+ |
+ Optionally, you may specify a different function to apply to each test |
+ (by default it is `lambda test: test.run()`). The context will be bound |
+ to the test before your function receives it. |
+ |
+ Used interally by expect_tests, you're not expected to call this yourself. |
+ """ |
+ # TODO(iannucci): pass list of test names? |
+ ctx_object = self.make_ctx_call() |
+ try: |
+ for test in self.tests: |
+ yield test, func(test.bind(context=ctx_object)) |
+ finally: |
+ self.destroy_ctx_call.bind(context=ctx_object)() |
+ |
+ @staticmethod |
+ def expect_path(_ext=None): |
+ return None |
+ |
+ |
+class Handler(object): |
+ """Handler object. |
+ |
+ Defines 3 handler methods for each stage of the test pipeline. The pipeline |
+ looks like: |
+ |
+ -> -> |
+ -> jobs -> (main) |
+ GenStage -> test_queue -> * -> result_queue -> ResultStage |
+ -> RunStage -> |
+ -> -> |
+ |
+ Each process will have an instance of one of the nested handler classes, which |
+ will be called on each test / result. |
+ |
+ You can skip the RunStage phase by setting SKIP_RUNLOOP to True on your |
+ implementation class. |
+ |
+ Tips: |
+ * Only do printing in ResultStage, since it's running on the main process. |
+ """ |
+ SKIP_RUNLOOP = False |
+ |
+ @classmethod |
+ def add_options(cls, parser): |
+ """ |
+ @type parser: argparse.ArgumentParser() |
+ """ |
+ pass |
+ |
+ @classmethod |
+ def gen_stage_loop(cls, _opts, tests, put_next_stage, _put_result_stage): |
+ """Called in the GenStage portion of the pipeline. |
+ |
+ @param opts: Parsed CLI options |
+ @param tests: |
+ Iteraterable of type_definitions.Test or type_definitions.MultiTest |
+ objects. |
+ @param put_next_stage: |
+ Function to push an object to the next stage of the pipeline (RunStage). |
+ Note that you should push the item you got from |tests|, not the |
+ subtests, in the case that the item is a MultiTest. |
+ @param put_result_stage: |
+ Function to push an object to the result stage of the pipeline. |
+ """ |
+ for test in tests: |
+ put_next_stage(test) |
+ |
+ @classmethod |
+ def run_stage_loop(cls, _opts, tests_results, put_next_stage): |
+ """Called in the RunStage portion of the pipeline. |
+ |
+ @param opts: Parsed CLI options |
+ @param tests_results: Iteraterable of (type_definitions.Test, |
+ type_definitions.Result) objects |
+ @param put_next_stage: Function to push an object to the next stage of the |
+ pipeline (ResultStage). |
+ """ |
+ for _, result in tests_results: |
+ put_next_stage(result) |
+ |
+ @classmethod |
+ def result_stage_loop(cls, opts, objects): |
+ """Called in the ResultStage portion of the pipeline. |
+ |
+ Consider subclassing ResultStageHandler instead as it provides a more |
+ flexible interface for dealing with |objects|. |
+ |
+ @param opts: Parsed CLI options |
+ @param objects: Iteraterable of objects from GenStage and RunStage. |
+ """ |
+ error = False |
+ aborted = False |
+ handler = cls.ResultStageHandler(opts) |
+ try: |
+ for obj in objects: |
+ error |= isinstance(handler(obj), Failure) |
+ except ResultStageAbort: |
+ aborted = True |
+ handler.finalize(aborted) |
+ return error |
+ |
+ class ResultStageHandler(object): |
+ """SAX-like event handler dispatches to self.handle_{type(obj).__name__} |
+ |
+ So if |obj| is a Test, this would call self.handle_Test(obj). |
+ |
+ self.__unknown is called to handle objects which have no defined handler. |
+ |
+ self.finalize is called after all objects are processed. |
+ """ |
+ def __init__(self, opts): |
+ self.opts = opts |
+ |
+ def __call__(self, obj): |
+ """Called to handle each object in the ResultStage |
+ |
+ @type obj: Anything passed to put_result in GenStage or RunStage. |
+ |
+ @return: If the handler method returns Failure(), then it will |
+ cause the entire test run to ultimately return an error code. |
+ """ |
+ return getattr(self, 'handle_' + type(obj).__name__, self.__unknown)(obj) |
+ |
+ def handle_NoMatchingTestsError(self, _error): |
+ print 'No tests found that match the glob: %s' % ( |
+ ' '.join(self.opts.test_glob),) |
+ return Failure() |
+ |
+ def __unknown(self, obj): |
+ if self.opts.verbose: |
+ print 'UNHANDLED:', obj |
+ return Failure() |
+ |
+ def finalize(self, aborted): |
+ """Called after __call__() has been called for all results. |
+ |
+ @param aborted: True if the user aborted the run. |
+ @type aborted: bool |
+ """ |
+ pass |