blob: 28d2e0b3f0f077aa8b85ac7d5a4613f1ced40061 [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.
import argparse
from glob import glob
import multiprocessing
from multiprocessing.pool import ThreadPool
import subprocess
import sys
import threading
import os
import six
CPUS = multiprocessing.cpu_count()
CONCURRENT_SUITES = (CPUS // 4) or CPUS
CONCURRENT_TESTS = CPUS // CONCURRENT_SUITES
ALT_PKG_PATHS = {
'Allura': 'allura/tests/',
}
NOT_MULTIPROC_SAFE = [
'ForgeGit',
'ForgeSVN',
]
# unless you want to mess with changing stdout's own encoding, this works well:
# py2 gets utf-8 encoded (binary) and py3 gets unicode text
print_ensured = six.ensure_binary if six.PY2 else six.ensure_text
def run_one(cmd, **popen_kwargs):
cmd_to_show = '`{}` in {}'.format(cmd, popen_kwargs.get('cwd', '.'))
print('{} running {}\n'.format(threading.current_thread(), cmd_to_show))
sys.stdout.flush()
all_popen_kwargs = dict(shell=True, stderr=subprocess.STDOUT,
stdout=subprocess.PIPE,
bufsize=1, # 1 == line-buffered
text=True,
close_fds='posix' in sys.builtin_module_names)
all_popen_kwargs.update(popen_kwargs)
proc = subprocess.Popen(cmd, **all_popen_kwargs)
while proc.poll() is None:
line = proc.stdout.readline()
sys.stdout.write(print_ensured(line))
if 'No data to combine' in line:
sys.stdout.write('^^ error from "coverage combine" command. Make sure your package has a setup.cfg with coverage settings like other packages\n')
sys.stdout.flush()
# wait for completion and get remainder of output
out_remainder, _ = proc.communicate()
sys.stdout.write(print_ensured(out_remainder))
sys.stdout.flush()
print('finished {}, with returncode: {}'.format(cmd_to_show, proc.returncode))
sys.stdout.flush()
return proc
def run_many(cmds, processes=None):
"""
cmds: list of shell commands, or list of (shell cmds, popen_kwargs)
processes: number of processes, or None for # of CPU cores
"""
thread_pool = ThreadPool(processes=processes)
async_results = []
for cmd_kwds in cmds:
if type(cmd_kwds) == ():
cmd = cmd_kwds
kwds = {}
else:
cmd = cmd_kwds[0]
kwds = cmd_kwds[1]
result = thread_pool.apply_async(run_one, args=(cmd,), kwds=kwds)
async_results.append(result)
thread_pool.close()
thread_pool.join()
procs = [async_result.get() for async_result in async_results]
return [p.returncode for p in procs]
def get_packages():
packages = sorted([p.split('/')[0] for p in glob("*/setup.py")])
# make it first, to catch syntax errors
packages.remove('AlluraTest')
packages.insert(0, 'AlluraTest')
return packages
def check_packages(packages):
for pkg in packages:
try:
__import__(pkg.lower())
except ImportError:
print("Not running tests for {}, since it isn't set up".format(pkg))
else:
yield pkg
def run_tests_in_parallel(options, runner_args):
default_args = [
# '-c /dev/null', # pytest's equivalent of nose's NOSE_IGNORE_CONFIG_FILES='1' is '-c /dev/null/'
'--disable-warnings',
]
def get_pkg_path(pkg):
return ALT_PKG_PATHS.get(pkg, '')
def get_multiproc_args(pkg):
if options.concurrent_tests == 1:
return ''
return '-n {procs_per_suite} --dist loadfile'.format(
procs_per_suite=options.concurrent_tests
) if pkg not in NOT_MULTIPROC_SAFE else ''
def get_concurrent_suites():
if '-n' in sys.argv:
return options.concurrent_suites
return CPUS
cmds = []
env = dict(os.environ)
for package in check_packages(options.packages):
runner = 'pytest'
if options.coverage:
# This is the recommended way to run coverage + pytest https://coverage.readthedocs.io/en/6.5.0/
runner = f'coverage run -m {runner}'
"""
And using config settings in setup.cfg seems to work well with parallel processes
Otherwise need to run with a complex setup like:
PYTHONPATH=/tmp/cov coverage run --rcfile /tmp/cov/.covrc
And /tmp/cov/sitecustomize.py containing:
import os
os.environ['COVERAGE_PROCESS_START'] = '/tmp/cov/.covrc'
import coverage
coverage.process_startup()
"""
multiproc_args = get_multiproc_args(package)
cmd = "{runner} {pkg_path} {args} {multiproc_args}".format(
runner=runner,
pkg_path=get_pkg_path(package),
args=' '.join(default_args + runner_args),
multiproc_args=multiproc_args,
)
if options.coverage:
cmd += ' && coverage combine' # merge separate files present from multiprocessing config being on
cmd += ' && coverage report --include=./* --omit="*/tests/*"'
cmds.append((cmd, dict(cwd=package, env=env)))
# TODO: add a way to include this or not; and add xml output for Jenkins
cmds.append(('npm run lint-es6', {}))
ret_codes = run_many(cmds, processes=get_concurrent_suites())
if options.coverage and not any(ret_codes) and len(options.packages) > 1:
subprocess.call('rm .coverage', shell=True)
subprocess.check_call('cp --backup=numbered */.coverage .', shell=True)
subprocess.check_call('coverage combine --append', shell=True)
report_cmd = "coverage report --omit='*/tests/*'"
subprocess.check_call(report_cmd, shell=True)
print('\nFor HTML coverage report run: {}'.format(report_cmd.replace(' report', ' html')))
return ret_codes
def parse_args():
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''All additional arguments are passed along to pytest (e.g. -v)''')
parser.add_argument('-n', help='Number of test suites to run concurrently in separate '
'processes. Default: # CPUs / 4',
dest='concurrent_suites', type=int, default=CONCURRENT_SUITES)
parser.add_argument('-m', help='Number of tests to run concurrently in separate '
'processes, per suite. Default: # CPUs / # concurrent suites. '
'(equivalent to pytest-xdist\'s -n option)',
dest='concurrent_tests', type=int, default=CONCURRENT_TESTS)
parser.add_argument('--coverage', action='store_true',
help='Collect code coverage details, and report')
parser.add_argument(
'-p', help='List of packages to run tests on. Default: all',
dest='packages', choices=get_packages(), default=get_packages(),
nargs='+')
return parser.parse_known_args()
if __name__ == "__main__":
ret_codes = run_tests_in_parallel(*parse_args())
sys.exit(any(ret_codes))