Chromium Code Reviews| Index: expect_tests/pipeline.py |
| diff --git a/expect_tests/pipeline.py b/expect_tests/pipeline.py |
| index 04da90309c81da86e7ef537d18c3d6e82ec0c68d..03d89ac8ed89a8a70e41fad5e71957ed451621d0 100644 |
| --- a/expect_tests/pipeline.py |
| +++ b/expect_tests/pipeline.py |
| @@ -7,6 +7,7 @@ import ConfigParser |
| import glob |
| import imp |
| import inspect |
| +import itertools |
| import logging |
| import multiprocessing |
| import os |
| @@ -52,6 +53,31 @@ class ResetableStringIO(object): |
| return getattr(self._stream, key) |
| +def get_python_root(path): |
| + """Get the lowest directory with no __init__.py file. |
| + |
| + When ``path`` is pointing inside a Python package, this function returns the |
| + directory directly containing this package. If ``path`` points outside of |
| + a Python package, the it returns ``path``. |
| + |
| + Args: |
| + path (str): arbitrary path |
| + Returns: |
| + root (str): ancestor directory, with no __init__.py file in it. |
| + """ |
| + if not os.path.exists(path): |
| + raise ValueError('path must exist: %s') |
| + |
| + while path != os.path.dirname(path): |
| + if not os.path.exists(os.path.join(path, '__init__.py')): |
| + return path |
| + path = os.path.dirname(path) |
| + |
| + # This is not supposed to happen, but in case somebody adds a __init__.py |
| + # at the filesystem root ... |
|
agable
2014/11/12 03:15:09
Love the French spacing between words and punctuat
pgervais
2014/11/13 00:28:26
ALERT: troll detected in comment line 77.
|
| + raise IOError("Unable to find a python root for %s" % path) |
| + |
| + |
| def get_package_path(package_name, path): |
| """Return path toward 'package_name'. |
| @@ -114,7 +140,7 @@ def is_test_file(filename): |
| return filename.endswith('_test.py') |
| -def walk_package(package_name, path): |
| +def walk_package(package_name, path, subpath=None): |
| """Return all test files inside a single package. |
| In all cases, this function returns the full package name of files ending |
| @@ -139,6 +165,8 @@ def walk_package(package_name, path): |
| Args: |
| package_name (str): name of the package, as expected by import. |
| path (str): path containing the above module (optional) |
| + subpath (str, optional): path inside the package, pointing to a subpackage. |
| + This is used to restrict the listing to part of the package. |
| Returns: |
| test_modules (list of str): name of modules containing tests. Each element is |
| @@ -164,7 +192,14 @@ def walk_package(package_name, path): |
| explored = set() |
| - for dirpath, dirnames, filenames in os.walk(package_path, followlinks=True): |
| + if subpath: |
| + start_path = os.path.join(package_path, subpath) |
| + if not os.path.exists(os.path.join(start_path)): |
|
agable
2014/11/12 03:15:10
os.path.join not necessary
pgervais
2014/11/13 00:28:26
Done.
|
| + raise ValueError('Provided subpath does not exist: %s' % start_path) |
| + else: |
| + start_path = package_path |
| + |
| + for dirpath, dirnames, filenames in os.walk(start_path, followlinks=True): |
| # Keep only submodules not blacklisted, break symlink cycles |
| blacklist = get_config(dirpath) |
| dirnames[:] = [d for d in dirnames |
| @@ -174,7 +209,7 @@ def walk_package(package_name, path): |
| realpaths = [os.path.realpath(os.path.join(dirpath, d)) for d in dirnames] |
| explored.update(realpaths) |
| - assert dirpath.startswith(package_path) |
| + assert dirpath.startswith(start_path) |
| base_module_name = os.path.relpath(dirpath, base_path).split(os.path.sep) |
| test_modules.extend(['.'.join(base_module_name |
| + [inspect.getmodulename(filename)]) |
| @@ -199,32 +234,8 @@ def load_module(modname): |
| return mod |
| -def get_test_gens_directory(path, cwd): |
| - """Given a path, return list of MultiTest or Test instances. |
| - |
| - See UnittestTestCase for possible return values. |
| - |
| - This function loads modules, thus no two conflicting packages (like appengine) |
| - can be loaded at the same time: use separate processes for that. |
| - """ |
| - assert isinstance(path, basestring), 'path must be a string' |
| - assert os.path.isdir(path), 'path is not a directory: %s' % path |
| - sys.path.insert(0, os.path.abspath(path)) |
| - |
| - test_gens = [] |
| - black_list = get_config(path) |
| - |
| - for filename in filter(lambda x: x not in black_list, os.listdir(path)): |
| - abs_filename = os.path.join(path, filename) |
| - if (os.path.isdir(abs_filename) |
| - and os.path.isfile(os.path.join(abs_filename, '__init__.py'))): |
| - test_gens += get_test_gens_package(abs_filename, cwd, |
| - update_syspath=False) |
| - return test_gens |
| - |
| - |
| -def get_test_gens_package(package, cwd, update_syspath=True): |
| - """Given a path, return list of MultiTest or Test instances. |
| +def get_test_gens_package(testing_context, subpath=None): |
| + """Given a testing context, return list of generators of *Test instances. |
| See UnittestTestCase for possible return values. |
| @@ -232,24 +243,21 @@ def get_test_gens_package(package, cwd, update_syspath=True): |
| should be loaded at the same time: use separate processes for that. |
| Args: |
| - package (str): path to a Python package. |
| - update_syspath (boolean): if True, the parent directory of 'package' is |
| - prepended to sys.path. |
| - """ |
| - assert isinstance(package, basestring), "package name should be a string." |
| - assert os.path.isfile(os.path.join(package, '__init__.py')), \ |
| - "'package' is not pointing to a package. It must be a " + \ |
| - "path to a directory containing a __init__.py file." |
| + testing_context (PackageTestingContext): what to test. |
| + subpath (str): relative path in the tested package to restrict the search |
| + to. Relative to |
| + os.path.join(testing_context.cwd, testing_context.package_name) |
|
agable
2014/11/12 03:15:09
Haven't read all the way down yet, but it seems li
|
| + Returns: |
| + gens_list (list of generator of tests): tests are instances of Test |
| + or MultiTest. |
| + """ |
| test_gens = [] |
| - path = os.path.abspath(os.path.dirname(package)) |
| - if update_syspath: |
| - sys.path.insert(0, path) |
| - package_name = os.path.split(package.rstrip(os.path.sep))[-1] |
| - |
| - for modname in walk_package(package_name, path): |
| - with use_chdir(cwd): |
| + # TODO(pgervais) add filtering on test names (use testing_context.filters) |
| + for modname in walk_package(testing_context.package_name, |
| + testing_context.cwd, subpath=subpath): |
| + with use_chdir(testing_context.cwd): |
| mod = load_module(modname) |
| for obj in mod.__dict__.values(): |
| if util.is_test_generator(obj): |
| @@ -259,89 +267,85 @@ def get_test_gens_package(package, cwd, update_syspath=True): |
| return test_gens |
| -def gen_loop_process(gens, test_queue, result_queue, opts, kill_switch, |
| - cover_ctx, temp_dir): |
| +def gen_loop_process(testing_contexts, test_queue, result_queue, opts, |
| + kill_switch, cover_ctx, temp_dir): |
| """Generate `Test`s from |gens|, and feed them into |test_queue|. |
| Non-Test instances will be translated into `UnknownError` objects. |
| Args: |
| - gens: list of generators yielding Test() instances. |
| + testing_contexts (list of PackageTestingContext): describe tests to |
| + process. |
|
agable
2014/11/12 03:15:09
Indent +4 for continuation? I dunno.
|
| test_queue (multiprocessing.Queue): |
|
agable
2014/11/12 03:15:10
Love these args with no descriptions
iannucci
2014/11/12 20:26:24
I think this was my fault :)
pgervais
2014/11/13 00:28:26
This time I think it was mine, I added them last t
|
| result_queue (multiprocessing.Queue): |
| opts (argparse.Namespace): |
| kill_switch (multiprocessing.Event): |
| cover_ctx (cover.CoverageContext().create_subprocess_context) |
| """ |
| - tempfile.tempdir = temp_dir |
| - |
| - # Implicitly append '*' to globs that don't specify it. |
| - globs = ['%s%s' % (g, '*' if '*' not in g else '') for g in opts.test_glob] |
| - |
| - matcher = re.compile( |
| - '^%s$' % '|'.join('(?:%s)' % glob.fnmatch.translate(g) |
| - for g in globs if g[0] != '-')) |
| - if matcher.pattern == '^$': |
| - matcher = re.compile('^.*$') |
| - |
| - neg_matcher = re.compile( |
| - '^%s$' % '|'.join('(?:%s)' % glob.fnmatch.translate(g[1:]) |
| - for g in globs if g[0] == '-')) |
| SENTINEL = object() |
| + tempfile.tempdir = temp_dir |
| def generate_tests(): |
| - paths_seen = set() |
| seen_tests = False |
| try: |
| - for gen in gens: |
| - gen_cover_ctx = cover_ctx(include=util.get_cover_list(gen)) |
| - |
| - with gen_cover_ctx: |
| - gen_inst = gen() |
| - |
| - while not kill_switch.is_set(): |
| - with gen_cover_ctx: |
| - root_test = next(gen_inst, SENTINEL) |
| - |
| - if root_test is SENTINEL: |
| - break |
| - |
| - if kill_switch.is_set(): |
| - break |
| - |
| - ok_tests = [] |
| - |
| - if isinstance(root_test, MultiTest): |
| - subtests = root_test.tests |
| - else: |
| - subtests = [root_test] |
| - |
| - for subtest in subtests: |
| - if not isinstance(subtest, Test): |
| - result_queue.put_nowait( |
| - UnknownError( |
| - 'Got non-[Multi]Test isinstance from generator: %r.' |
| - % subtest)) |
| - continue |
| - |
| - test_path = subtest.expect_path() |
| - if test_path is not None and test_path in paths_seen: |
| - result_queue.put_nowait( |
| - TestError(subtest, 'Duplicate expectation path.')) |
| - else: |
| - if test_path is not None: |
| - paths_seen.add(test_path) |
| - name = subtest.name |
| - if not neg_matcher.match(name) and matcher.match(name): |
| - ok_tests.append(subtest) |
| - |
| - if ok_tests: |
| - seen_tests = True |
| - yield root_test.restrict(ok_tests) |
| + for testing_context in testing_contexts: |
| + for subpath, matcher in testing_context.itermatchers(): |
| + paths_seen = set() |
| + |
| + with cover_ctx: |
| + gens = get_test_gens_package(testing_context, subpath=subpath) |
| + |
| + for gen in gens: |
| + gen_cover_ctx = cover_ctx(include=util.get_cover_list(gen)) |
| + |
| + with gen_cover_ctx: |
| + gen_inst = gen() |
| + |
| + while not kill_switch.is_set(): |
| + with gen_cover_ctx: |
| + root_test = next(gen_inst, SENTINEL) |
| + |
| + if root_test is SENTINEL: |
| + break |
| + |
| + if kill_switch.is_set(): |
|
agable
2014/11/12 03:15:09
I know this is copied, but doesn't it make sense t
iannucci
2014/11/12 20:26:25
We do that in the loop condition.
|
| + break |
| + |
| + ok_tests = [] |
| + |
| + if isinstance(root_test, MultiTest): |
| + subtests = root_test.tests |
| + else: |
| + subtests = [root_test] |
| + |
| + for subtest in subtests: |
| + if not isinstance(subtest, Test): |
| + result_queue.put_nowait( |
| + UnknownError( |
| + 'Got non-[Multi]Test isinstance from generator: %r.' |
| + % subtest)) |
| + continue |
| + |
| + test_path = subtest.expect_path() |
| + if test_path is not None and test_path in paths_seen: |
| + result_queue.put_nowait( |
| + TestError(subtest, 'Duplicate expectation path.')) |
| + else: |
| + if test_path is not None: |
| + paths_seen.add(test_path) |
| + name = subtest.name |
| + # if not neg_matcher.match(name) and matcher.match(name): |
| + if matcher.match(name): |
| + ok_tests.append(subtest) |
| + |
| + if ok_tests: |
| + seen_tests = True |
| + yield root_test.restrict(ok_tests) |
| if not seen_tests: |
| result_queue.put_nowait(NoMatchingTestsError()) |
| + |
| except KeyboardInterrupt: |
| pass |
| @@ -439,8 +443,8 @@ def run_loop_process(test_queue, result_queue, opts, |
| result_queue.put_nowait) |
| -def result_loop_single_path(cover_ctx, kill_switch, result_queue, opts, |
| - path, path_is_package): |
| +def result_loop_single_context(cover_ctx, kill_switch, result_queue, opts, |
| + processing_context): |
| """Run the specified operation on a single path. |
| The path provided by the `path` argument is considered to be either a Python |
| @@ -458,22 +462,9 @@ def result_loop_single_path(cover_ctx, kill_switch, result_queue, opts, |
| kill_switch (multiprocessing.Event): |
| result_queue (multiprocessing.Queue): |
| opts: output of argparse.ArgumentParser.parse_args (see main.py) |
| - path (str): path a a Python package or a directory containing Python |
| - packages. |
| - path_is_package (boolean): tells whether 'path' is a package or not. |
| - """ |
| - assert isinstance(path, basestring), 'path must be a string' |
| - |
| - if path_is_package: |
| - work_path = os.path.dirname(os.path.abspath(path)) |
| - else: |
| - work_path = path |
| - |
| - with cover_ctx: |
| - if path_is_package: |
| - test_gens = get_test_gens_package(path, work_path) |
| - else: |
| - test_gens = get_test_gens_directory(path, work_path) |
| + processing_context (ProcessingContext): the task to perform. |
| + """ |
| + sys.path.insert(0, processing_context.cwd) |
| # This flag is set when test generation has finished. |
| test_gen_finished = multiprocessing.Event() |
| @@ -481,7 +472,7 @@ def result_loop_single_path(cover_ctx, kill_switch, result_queue, opts, |
| with TempDir() as temp_dir: |
| test_gen_args = ( |
| - test_gens, test_queue, result_queue, opts, |
| + processing_context.testing_contexts, test_queue, result_queue, opts, |
| kill_switch, cover_ctx, temp_dir |
| ) |
| @@ -494,7 +485,7 @@ def result_loop_single_path(cover_ctx, kill_switch, result_queue, opts, |
| target=run_loop_process, |
| args=(test_queue, result_queue, opts, |
| kill_switch, test_gen_finished, cover_ctx, temp_dir, |
| - work_path), |
| + processing_context.cwd), |
| name='run_loop_process %d' % job_num) |
| for job_num in xrange(opts.jobs) |
| ] |
| @@ -511,6 +502,169 @@ def result_loop_single_path(cover_ctx, kill_switch, result_queue, opts, |
| p.join() |
| +def parse_test_glob(test_glob): |
| + """A test glob is composed of a path and a glob expression like: |
| + '<path>:<glob>'. The path should point to a directory or a file inside |
| + a Python package (it can be the root directory of that package). |
| + The glob is a Python name used to filter tests. |
| + |
| + Example: |
| + 'my/nice/package/test1/:TestA*', the package root being 'my/nice/package': |
| + this matches all tests whose name starts with 'TestA' inside all files |
| + matching test1/*_test.py. |
| + |
| + Args: |
| + test_glob (str): a test glob |
| + Returns: |
| + (path, test_filter): absolute path and test filter glob. |
| + """ |
| + parts = test_glob.split(':') |
| + if len(parts) > 2: |
| + raise ValueError('A test_glob should contain at most one colon (got %s)' |
| + % test_glob) |
| + if len(parts) == 2: |
| + path, test_filter = parts |
| + if '/' in test_filter: |
| + raise ValueError('A test filter cannot contain a slash (got %s)', |
| + test_filter) |
| + |
| + if not test_filter: # empty string case |
| + test_filter = '*' |
| + else: |
| + path, test_filter = parts[0], '*' |
| + |
| + path = os.path.abspath(path) |
| + return path, test_filter |
| + |
| + |
| +class PackageTestingContext(object): |
| + def __init__(self, cwd, package_name, filters): |
| + """Information to run a set of tests in a single package. |
| + |
| + See also parse_test_glob. |
| + """ |
| + self.cwd = cwd |
| + self.package_name = package_name |
| + # list of (path, filter) pairs. |
| + # The path is a relative path to a subdirectory of |
| + # os.path.join(self.cwd, self.package_name) where to look for tests for. |
|
agable
2014/11/12 03:15:10
"in which to look for tests"
pgervais
2014/11/13 00:28:26
Done. Thanks for the English review!
|
| + # Only tests whose name matches the glob are kept. |
| + self.filters = filters |
| + |
| + def itermatchers(self): |
| + """Iterate over all filters, and yield matchers for each of them. |
| + |
| + Returns: |
|
agable
2014/11/12 03:15:09
"Returns" isn't exactly accurate for a generator.
pgervais
2014/11/13 00:28:26
Done.
|
| + path (str): restrict test listing to this subpackage. |
| + matcher (SRE_Pattern): whitelist matcher |
| + """ |
| + for filt in self.filters: |
| + # Implicitely surround globs with * |
| + |
| + one_glob = '%s%s' % (filt[1], '*' if '*' not in filt[1] else '') |
| + matcher = re.compile('^(?:%s)$' % glob.fnmatch.translate(one_glob)) |
| + |
| + ## matcher = re.compile( |
|
agable
2014/11/12 03:15:09
Not sure what these commented-out segments are/wer
iannucci
2014/11/12 20:26:24
I think they were copied from above and then trans
pgervais
2014/11/13 00:28:26
Removed them.
|
| + ## '^%s$' % '|'.join('(?:%s)' % glob.fnmatch.translate(g) |
| + ## for g in globs if g[0] != '-')) |
| + if matcher.pattern == '^$': |
| + matcher = re.compile('^.*$') |
| + |
| + ## neg_matcher = re.compile( |
| + ## '^%s$' % '|'.join('(?:%s)' % glob.fnmatch.translate(g[1:]) |
| + ## for g in globs if g[0] == '-')) |
| + yield filt[0], matcher |
| + |
| + @classmethod |
| + def from_path(cls, path, filters='*'): |
| + path = os.path.abspath(path) |
| + if not os.path.exists(path): |
| + raise ValueError('Path does not exist: %s' % path) |
| + cwd = get_python_root(path) |
| + package_name = os.path.relpath(path, cwd).split(os.path.sep)[0] |
| + # list of (path, filter) pairs. |
|
agable
2014/11/12 03:15:09
This comment is out of place. I think it belongs a
pgervais
2014/11/13 00:28:26
Dropped
|
| + # The path is where to look for tests for. Only tests whose name matches the |
|
agable
2014/11/12 03:15:09
"where to look for tests for" again
pgervais
2014/11/13 00:28:26
Done.
|
| + # glob are kept. |
| + relpath = os.path.relpath(path, os.path.join(cwd, package_name)) |
| + |
| + if isinstance(filters, basestring): |
| + filters = [(relpath, filters)] |
| + else: |
| + filters = [(relpath, filt) for filt in filters] |
| + |
| + return cls(cwd, package_name, filters) |
| + |
| + @classmethod |
| + def from_context_list(cls, contexts): |
| + """Merge several PackageTestingContext pointing to the same package.""" |
| + cwd = set(context.cwd for context in contexts) |
| + assert len(cwd) == 1, \ |
|
agable
2014/11/12 03:15:10
Not clear why here you use assert, while above and
pgervais
2014/11/13 00:28:26
Good catch. Fixed.
|
| + 'from_context_list processes contexts with the same working '\ |
|
agable
2014/11/12 03:15:09
See comment below; this should be something like '
pgervais
2014/11/13 00:28:26
Done.
|
| + 'directory only.' |
| + |
| + package_name = set(context.package_name for context in contexts) |
| + assert len(package_name) == 1, \ |
| + 'from_context_list processes contexts with the same package '\ |
| + 'name only.' |
| + |
| + filters = [] |
| + for context in contexts: |
| + filters.extend(context.filters) |
| + |
| + return cls(cwd.pop(), package_name.pop(), filters) |
| + |
| + |
| +class ProcessingContext(object): |
| + def __init__(self, testing_contexts): |
| + """Information to run a set of tasks in a given working directory. |
| + |
| + Args: |
| + testing_contexts (list): list of PackageTestingContext instances |
| + """ |
| + self.cwd = testing_contexts[0].cwd |
| + |
| + # Merge testing_contexts by package |
| + groups = {} |
| + for context in testing_contexts: |
| + if context.cwd != self.cwd: |
| + raise ValueError('All package must have the same value for "cwd"') |
|
agable
2014/11/12 03:15:10
I don't think that ValueError is the right thing t
pgervais
2014/11/13 00:28:26
I've fixed the error messages in the above two pla
|
| + groups.setdefault(context.package_name, []).append(context) |
| + |
| + self.testing_contexts = [PackageTestingContext.from_context_list(contexts) |
| + for contexts in groups.itervalues()] |
| + |
| + |
| +def get_runtime_contexts(test_globs): |
| + """Compute the list of packages/filters to get tests from.""" |
| + # Step 1: compute list of packages + subtree |
| + testing_contexts = [] |
| + for test_glob in test_globs: |
| + path, test_filter = parse_test_glob(test_glob) |
| + if os.path.exists(os.path.join(path, '__init__.py')): |
| + testing_contexts.append( |
| + PackageTestingContext.from_path(path, test_filter)) |
| + else: |
| + # Look for all packages in path. |
| + subpaths = [] |
| + black_list = get_config(path) |
| + |
| + for filename in filter(lambda x: x not in black_list, os.listdir(path)): |
| + abs_filename = os.path.join(path, filename) |
| + if (os.path.isdir(abs_filename) |
| + and os.path.isfile(os.path.join(abs_filename, '__init__.py'))): |
| + subpaths.append(abs_filename) |
| + |
| + testing_contexts.extend( |
| + [PackageTestingContext.from_path(subpath, test_filter) |
| + for subpath in subpaths]) |
| + |
| + # Step 2: group by working directory - one process per wd. |
| + groups = {} |
| + for context in testing_contexts: |
| + groups.setdefault(context.cwd, []).append(context) |
| + return [ProcessingContext(contexts) for contexts in groups.itervalues()] |
| + |
| + |
| def result_loop(cover_ctx, opts): |
| """Run the specified operation in all paths in parallel. |
| @@ -520,6 +674,8 @@ def result_loop(cover_ctx, opts): |
| The operation to perform (list/test/debug/train) is defined by opts.handler. |
| """ |
| + processing_contexts = get_runtime_contexts(opts.test_glob) |
| + |
| def ensure_echo_on(): |
| """Restore echo on in the terminal. |
| @@ -556,18 +712,10 @@ def result_loop(cover_ctx, opts): |
| procs = [ |
| multiprocessing.Process( |
| - target=result_loop_single_path, |
| - args=(cover_ctx, kill_switch, result_queue, opts, os.path.abspath(p), |
| - False) |
| - ) |
| - for p in opts.directory |
| - ] + [ |
| - multiprocessing.Process( |
| - target=result_loop_single_path, |
| - args=(cover_ctx, kill_switch, result_queue, opts, os.path.abspath(p), |
| - True) |
| + target=result_loop_single_context, |
| + args=(cover_ctx, kill_switch, result_queue, opts, c) |
| ) |
| - for p in opts.package |
| + for c in processing_contexts |
| ] |
| error = False |