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

Unified Diff: expect_tests/pipeline.py

Issue 709853003: New expect_tests UI (Closed) Base URL: https://chromium.googlesource.com/infra/testing/expect_tests@shebang
Patch Set: Filtering on the CLI works. Created 6 years, 1 month 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 side-by-side diff with in-line comments
Download patch
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

Powered by Google App Engine
This is Rietveld 408576698