blob: 9cabbdf901c8eb4df47b6b0ee46f48c56c0aa160 [file] [log] [blame]
#!/usr/bin/env python
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Parallel test runner for GoogleTest programs.
This script allows one to execute GoogleTest tests in parallel.
GoogleTest programs come with built-in support for running in parallel.
Here tests can automatically be partitioned across a number of test
program invocations ("shards"). This script provides a convenient
wrapper around that functionality and stream-lined output.
"""
from __future__ import print_function
import multiprocessing
import optparse
import os
import shlex
import signal
import subprocess
import sys
DEFAULT_NUM_JOBS = int(multiprocessing.cpu_count() * 1.5)
class Bcolors(object):
"""
A collection of tty output modifiers.
To switch the output of a string, prefix it with the desired
modifier, and terminate it with 'ENDC'.
"""
HEADER = '\033[95m' if sys.stdout.isatty() else ''
OKBLUE = '\033[94m' if sys.stdout.isatty() else ''
OKGREEN = '\033[92m' if sys.stdout.isatty() else ''
WARNING = '\033[93m' if sys.stdout.isatty() else ''
FAIL = '\033[91m'if sys.stdout.isatty() else ''
ENDC = '\033[0m' if sys.stdout.isatty() else ''
BOLD = '\033[1m' if sys.stdout.isatty() else ''
UNDERLINE = '\033[4m' if sys.stdout.isatty() else ''
@staticmethod
def colorize(string, *color_codes):
"""Decorate a string with a number of color codes."""
colors = ''.join(color_codes)
return '{begin}{string}{end}'.format(
begin=colors if sys.stdout.isatty() else '',
string=string,
end=Bcolors.ENDC if sys.stdout.isatty() else '')
def run_test(opts):
"""
Perform an actual run of the test executable.
Expects a list of parameters giving the number of the current
shard, the total number of shards, and the executable to run.
"""
shard, nshards, executable = opts
signal.signal(signal.SIGINT, signal.SIG_IGN)
env = os.environ.copy()
env['GTEST_TOTAL_SHARDS'] = str(nshards)
env['GTEST_SHARD_INDEX'] = str(shard)
try:
output = subprocess.check_output(
executable.split(),
stderr=subprocess.STDOUT,
env=env,
universal_newlines=True)
print(Bcolors.colorize('.', Bcolors.OKGREEN), end='')
sys.stdout.flush()
return True, output
except subprocess.CalledProcessError as error:
print(Bcolors.colorize('.', Bcolors.FAIL), end='')
sys.stdout.flush()
return False, error.output
def parse_arguments():
"""Return the executable to work on, and a list of options."""
parser = optparse.OptionParser(
usage='Usage: %prog [options] <test> [-- <test_options>]')
parser.add_option(
'-j', '--jobs', type='int',
default=DEFAULT_NUM_JOBS,
help='number of parallel jobs to spawn. DEFAULT: {default_}'
.format(default_=DEFAULT_NUM_JOBS))
parser.add_option(
'-s', '--sequential', type='string',
default='',
help='gtest filter for tests to run sequentially')
parser.add_option(
'-v', '--verbosity', type='int',
default=1,
help='output verbosity:'
' 0 only shows summarized information,'
' 1 also shows full logs of failed shards, and anything'
' >1 shows all output. DEFAULT: 1')
parser.epilog = (
'The environment variable MESOS_GTEST_RUNNER_FLAGS '
'can be used to set a default set of flags. Flags passed on the '
'command line always have precedence over these defaults.')
# If the environment variable `MESOS_GTEST_RUNNER_FLAGS` is set we
# use it to set a default set of flags to pass. Flags passed on
# the command line always have precedence over these defaults.
#
# We manually construct `args` here and make use of the fact that
# in `optparser`'s implementation flags passed later on the
# command line overrule identical flags passed earlier.
args = []
if 'MESOS_GTEST_RUNNER_FLAGS' in os.environ:
args.extend(shlex.split(os.environ['MESOS_GTEST_RUNNER_FLAGS']))
args.extend(sys.argv[1:])
(options, executable) = parser.parse_args(args)
if not executable:
parser.print_usage()
sys.exit(1)
if not os.path.isfile(executable[0]):
print(
Bcolors.colorize(
"ERROR: File '{file}' does not exists"
.format(file=executable[0]), Bcolors.FAIL),
file=sys.stderr)
sys.exit(1)
if not os.access(executable[0], os.X_OK):
print(
Bcolors.colorize(
"ERROR: File '{file}' is not executable"
.format(file=executable[0]), Bcolors.FAIL),
file=sys.stderr)
sys.exit(1)
if options.sequential and options.sequential.count(':-'):
print(
Bcolors.colorize(
"ERROR: Cannot use negative filters in "
"'sequential' parameter: '{filter}'"
.format(filter=options.sequential), Bcolors.FAIL),
file=sys.stderr)
sys.exit(1)
if options.sequential and os.environ.get('GTEST_FILTER') and \
os.environ['GTEST_FILTER'].count(':-'):
print(
Bcolors.colorize(
"ERROR: Cannot specify both 'sequential' ""option "
"and environment variable 'GTEST_FILTER' "
"containing negative filters",
Bcolors.FAIL),
file=sys.stderr)
sys.exit(1)
# Since empty strings are falsy, directly compare against `None`
# to preserve an empty string passed via `GTEST_FILTER`.
if os.environ.get('GTEST_FILTER') != None:
options.parallel = '{env_filter}:-{sequential_filter}'\
.format(env_filter=os.environ['GTEST_FILTER'],
sequential_filter=options.sequential)
else:
options.parallel = '*:-{sequential_filter}'\
.format(sequential_filter=options.sequential)
return executable, options
if __name__ == '__main__':
EXECUTABLE, OPTIONS = parse_arguments()
# TODO(ArmandGrillet): Remove this when we'll have switched to Python 3.
dir_path = os.path.dirname(os.path.realpath(__file__))
script_path = os.path.join(dir_path, 'check-python3.py')
subprocess.call('python ' + script_path, shell=True, cwd=dir_path)
def options_gen(executable, filter_, jobs):
"""Generator for options for a certain shard.
Here we set up GoogleTest specific flags, and generate
distinct shard indices.
"""
opts = range(jobs)
# If we run in a terminal, enable colored test output. We
# still allow users to disable this themselves via extra args.
if sys.stdout.isatty():
args = executable[1:]
executable = '{exe} --gtest_color=yes {args}'\
.format(exe=executable[0], args=args if args else '')
if filter_:
executable = '{exe} --gtest_filter={filter}'\
.format(exe=executable, filter=filter_)
for opt in opts:
yield opt, jobs, executable
try:
RESULTS = []
POOL = multiprocessing.Pool(processes=OPTIONS.jobs)
# Run parallel tests.
#
# Multiprocessing's `map` cannot properly handle `KeyboardInterrupt` in
# some python versions. Use `map_async` with an explicit timeout
# instead. See http://stackoverflow.com/a/1408476.
RESULTS.extend(
POOL.map_async(
run_test,
options_gen(
EXECUTABLE, OPTIONS.parallel, OPTIONS.jobs)).get(
timeout=sys.maxint))
# Now run sequential tests.
if OPTIONS.sequential:
RESULTS.extend(
POOL.map_async(
run_test,
options_gen(
EXECUTABLE, OPTIONS.sequential, 1)).get(
timeout=sys.maxint))
# Count the number of failed shards and print results from
# failed shards.
#
# NOTE: The `RESULTS` array stores the result for each
# `run_test` invocation returning a tuple (success, output).
NFAILED = len([success for success, __ in RESULTS if not success])
# TODO(bbannier): Introduce a verbosity which prints results
# as they arrive; this likely requires some output parsing to
# ensure results from different tests do not interleave.
for result in RESULTS:
if not result[0]:
if OPTIONS.verbosity > 0:
print(result[1], file=sys.stderr)
else:
if OPTIONS.verbosity > 1:
print(result[1], file=sys.stdout)
if NFAILED > 0:
print(Bcolors.colorize(
'\n[FAIL]: {nfailed} shard(s) have failed tests'.format(
nfailed=NFAILED),
Bcolors.FAIL, Bcolors.BOLD),
file=sys.stderr)
else:
print(Bcolors.colorize('\n[PASS]', Bcolors.OKGREEN, Bcolors.BOLD))
sys.exit(NFAILED)
except KeyboardInterrupt:
# Force a newline after intermediate test reports.
print()
print('Caught KeyboardInterrupt, terminating workers')
POOL.terminate()
POOL.join()
sys.exit(1)
except OSError as error:
print(Bcolors.colorize(
'\nERROR: {err}'.format(err=error),
Bcolors.FAIL, Bcolors.BOLD))
POOL.terminate()
POOL.join()
sys.exit(1)