ZeroTierOne/ext/libpqxx-7.7.3/tools/test_all.py
2022-06-24 10:12:36 -07:00

631 lines
18 KiB
Python
Executable File

#! /usr/bin/env python3
"""Brute-force test script: test libpqxx against many compilers etc.
This script makes no changes in the source tree; all builds happen in
temporary directories.
To make this possible, you may need to run "make distclean" in the
source tree. The configure script will refuse to configure otherwise.
"""
# Without this, pocketlint does not yet understand the print function.
from __future__ import print_function
from abc import (
ABCMeta,
abstractmethod,
)
from argparse import ArgumentParser
from contextlib import contextmanager
from datetime import datetime
from functools import partial
import json
from multiprocessing import (
JoinableQueue,
Process,
Queue,
)
from multiprocessing.pool import (
Pool,
)
from os import (
cpu_count,
getcwd,
)
import os.path
from queue import Empty
from shutil import rmtree
from subprocess import (
CalledProcessError,
check_call,
check_output,
DEVNULL,
)
from sys import (
stderr,
stdout,
)
from tempfile import mkdtemp
from textwrap import dedent
CPUS = cpu_count()
GCC_VERSIONS = list(range(8, 14))
GCC = ['g++-%d' % ver for ver in GCC_VERSIONS]
CLANG_VERSIONS = list(range(7, 15))
CLANG = ['clang++-6.0'] + ['clang++-%d' % ver for ver in CLANG_VERSIONS]
CXX = GCC + CLANG
STDLIB = (
'',
'-stdlib=libc++',
)
OPT = ('-O0', '-O3')
LINK = {
'static': ['--enable-static', '--disable-shared'],
'dynamic': ['--disable-static', '--enable-shared'],
}
DEBUG = {
'plain': [],
'audit': ['--enable-audit'],
'maintainer': ['--enable-maintainer-mode'],
'full': ['--enable-audit', '--enable-maintainer-mode'],
}
# CMake "generators." Maps a value for cmake's -G option to a command line to
# run.
#
# I prefer Ninja if available, because it's fast. But hey, the default will
# work.
#
# Maps the name of the generator (as used with cmake's -G option) to the
# actual command line needed to do the build.
CMAKE_GENERATORS = {
'Ninja': ['ninja'],
'Unix Makefiles': ['make', '-j%d' % CPUS],
}
class Fail(Exception):
"""A known, well-handled exception. Doesn't need a traceback."""
class Skip(Exception):
""""We're not doing this build. It's not an error though."""
def run(cmd, output, cwd=None):
"""Run a command, write output to file-like object."""
command_line = ' '.join(cmd)
output.write("%s\n\n" % command_line)
check_call(cmd, stdout=output, stderr=output, cwd=cwd)
def report(output, message):
"""Report a message to output, and standard output."""
print(message, flush=True)
output.write('\n\n')
output.write(message)
output.write('\n')
def file_contains(path, text):
"""Does the file at path contain text?"""
with open(path) as stream:
for line in stream:
if text in line:
return True
return False
@contextmanager
def tmp_dir():
"""Create a temporary directory, and clean it up again."""
tmp = mkdtemp()
try:
yield tmp
finally:
rmtree(tmp)
def write_check_code(work_dir):
"""Write a simple C++ program so we can tesst whether we can compile it.
Returns the file's full path.
"""
path = os.path.join(work_dir, "check.cxx")
with open(path, 'w') as source:
source.write(dedent("""\
#include <iostream>
int main()
{
std::cout << "Hello world." << std::endl;
}
"""))
return path
def check_compiler(work_dir, cxx, stdlib, check, verbose=False):
"""Is the given compiler combo available?"""
err_file = os.path.join(work_dir, 'stderr.log')
if verbose:
err_output = open(err_file, 'w')
else:
err_output = DEVNULL
try:
command = [cxx, check]
if stdlib != '':
command.append(stdlib)
check_call(command, cwd=work_dir, stderr=err_output)
except (OSError, CalledProcessError):
if verbose:
with open(err_file) as errors:
stdout.write(errors.read())
print("Can't build with '%s %s'. Skipping." % (cxx, stdlib))
return False
else:
return True
# TODO: Use Pool.
def check_compilers(compilers, stdlibs, verbose=False):
"""Check which compiler configurations are viable."""
with tmp_dir() as work_dir:
check = write_check_code(work_dir)
return [
(cxx, stdlib)
for stdlib in stdlibs
for cxx in compilers
if check_compiler(
work_dir, cxx, stdlib, check=check, verbose=verbose)
]
def find_cmake_command():
"""Figure out a CMake generator we can use, or None."""
try:
caps = check_output(['cmake', '-E', 'capabilities'])
except FileNotFoundError:
return None
names = {generator['name'] for generator in json.loads(caps)['generators']}
for gen in CMAKE_GENERATORS.keys():
if gen in names:
return gen
return None
class Config:
"""Configuration for a build.
These classes must be suitable for pickling, so we can send its objects to
worker processes.
"""
__metaclass__ = ABCMeta
@abstractmethod
def name(self):
"""Return an identifier for this build configuration."""
def make_log_name(self):
"""Compose log file name for this build."""
return "build-%s.out" % self.name()
class Build:
"""A pending or ondoing build, in its own directory.
Each step returns True for Success, or False for failure.
These classes must be suitable for pickling, so we can send its objects to
worker processes.
"""
def __init__(self, logs_dir, config=None):
self.config = config
self.log = os.path.join(logs_dir, config.make_log_name())
# Start a fresh log file.
with open(self.log, 'w') as log:
log.write("Starting %s.\n" % datetime.utcnow())
self.work_dir = mkdtemp()
def clean_up(self):
"""Delete the build tree."""
rmtree(self.work_dir)
@abstractmethod
def configure(self, log):
"""Prepare for a build."""
@abstractmethod
def build(self, log):
"""Build the code, including the tests. Don't run tests though."""
def test(self, log):
"""Run tests."""
run(
[os.path.join(os.path.curdir, 'test', 'runner')], log,
cwd=self.work_dir)
def logging(self, function):
"""Call function, pass open write handle for `self.log`."""
# TODO: Should probably be a decorator.
with open(self.log, 'a') as log:
try:
function(log)
except Exception as error:
log.write("%s\n" % error)
raise
def do_configure(self):
"""Call `configure`, writing output to `self.log`."""
self.logging(self.configure)
def do_build(self):
"""Call `build`, writing output to `self.log`."""
self.logging(self.build)
def do_test(self):
"""Call `test`, writing output to `self.log`."""
self.logging(self.test)
class AutotoolsConfig(Config):
"""A combination of build options for the "configure" script."""
def __init__(self, cxx, opt, stdlib, link, link_opts, debug, debug_opts):
self.cxx = cxx
self.opt = opt
self.stdlib = stdlib
self.link = link
self.link_opts = link_opts
self.debug = debug
self.debug_opts = debug_opts
def name(self):
return '_'.join([
self.cxx, self.opt, self.stdlib, self.link, self.debug])
class AutotoolsBuild(Build):
"""Build using the "configure" script."""
__metaclass__ = ABCMeta
def configure(self, log):
configure = [
os.path.join(getcwd(), "configure"),
"CXX=%s" % self.config.cxx,
]
if self.config.stdlib == '':
configure += [
"CXXFLAGS=%s" % self.config.opt,
]
else:
configure += [
"CXXFLAGS=%s %s" % (self.config.opt, self.config.stdlib),
"LDFLAGS=%s" % self.config.stdlib,
]
configure += [
"--disable-documentation",
] + self.config.link_opts + self.config.debug_opts
run(configure, log, cwd=self.work_dir)
def build(self, log):
run(['make', '-j%d' % CPUS], log, cwd=self.work_dir)
# Passing "TESTS=" like this will suppress the actual running of
# the tests. We run them in the "test" stage.
run(['make', '-j%d' % CPUS, 'check', 'TESTS='], log, cwd=self.work_dir)
class CMakeConfig(Config):
"""Configuration for a CMake build."""
def __init__(self, generator):
self.generator = generator
self.builder = CMAKE_GENERATORS[generator]
def name(self):
return "cmake"
class CMakeBuild(Build):
"""Build using CMake.
Ignores the config for now.
"""
__metaclass__ = ABCMeta
def configure(self, log):
source_dir = getcwd()
generator = self.config.generator
run(
['cmake', '-G', generator, source_dir], output=log,
cwd=self.work_dir)
def build(self, log):
run(self.config.builder, log, cwd=self.work_dir)
def parse_args():
"""Parse command-line arguments."""
parser = ArgumentParser(description=__doc__)
parser.add_argument('--verbose', '-v', action='store_true')
parser.add_argument(
'--compilers', '-c', default=','.join(CXX),
help="Compilers, separated by commas. Default is %(default)s.")
parser.add_argument(
'--optimize', '-O', default=','.join(OPT),
help=(
"Alternative optimisation options, separated by commas. "
"Default is %(default)s."))
parser.add_argument(
'--stdlibs', '-L', default=','.join(STDLIB),
help=(
"Comma-separated options for choosing standard library. "
"Defaults to %(default)s."))
parser.add_argument(
'--logs', '-l', default='.', metavar='DIRECTORY',
help="Write build logs to DIRECTORY.")
parser.add_argument(
'--jobs', '-j', default=CPUS, metavar='CPUS',
help=(
"When running 'make', run up to CPUS concurrent processes. "
"Defaults to %(default)s."))
parser.add_argument(
'--minimal', '-m', action='store_true',
help="Make it as short a run as possible. For testing this script.")
return parser.parse_args()
def soft_get(queue, block=True):
"""Get an item off `queue`, or `None` if the queue is empty."""
try:
return queue.get(block)
except Empty:
return None
def read_queue(queue, block=True):
"""Read entries off `queue`, terminating when it gets a `None`.
Also terminates when the queue is empty.
"""
entry = soft_get(queue, block)
while entry is not None:
yield entry
entry = soft_get(queue, block)
def service_builds(in_queue, fail_queue, out_queue):
"""Worker process for "build" stage: process one job at a time.
Sends successful builds to `out_queue`, and failed builds to `fail_queue`.
Terminates when it receives a `None`, at which point it will send a `None`
into `out_queue` in turn.
"""
for build in read_queue(in_queue):
try:
build.do_build()
except Exception as error:
fail_queue.put((build, "%s" % error))
else:
out_queue.put(build)
in_queue.task_done()
# Mark the end of the queue.
out_queue.put(None)
def service_tests(in_queue, fail_queue, out_queue):
"""Worker process for "test" stage: test one build at a time.
Sends successful builds to `out_queue`, and failed builds to `fail_queue`.
Terminates when it receives a final `None`. Does not send out a final
`None` of its own.
"""
for build in read_queue(in_queue):
try:
build.do_test()
except Exception as error:
fail_queue.put((build, "%s" % error))
else:
out_queue.put(build)
in_queue.task_done()
def report_failures(queue, message):
"""Report failures from a failure queue. Return total number."""
failures = 0
for build, error in read_queue(queue, block=False):
print("%s: %s - %s" % (message, build.config.name(), error))
failures += 1
return failures
def count_entries(queue):
"""Get and discard all entries from `queue`, return the total count."""
total = 0
for _ in read_queue(queue, block=False):
total += 1
return total
def gather_builds(args):
"""Produce the list of builds we want to perform."""
if args.verbose:
print("\nChecking available compilers.")
compiler_candidates = args.compilers.split(',')
compilers = check_compilers(
compiler_candidates, args.stdlibs.split(','),
verbose=args.verbose)
if list(compilers) == []:
raise Fail(
"Did not find any viable compilers. Tried: %s."
% ', '.join(compiler_candidates))
opt_levels = args.optimize.split(',')
link_types = LINK.items()
debug_mixes = DEBUG.items()
if args.minimal:
compilers = compilers[:1]
opt_levels = opt_levels[:1]
link_types = list(link_types)[:1]
debug_mixes = list(debug_mixes)[:1]
builds = [
AutotoolsBuild(
args.logs,
AutotoolsConfig(
opt=opt, link=link, link_opts=link_opts, debug=debug,
debug_opts=debug_opts, cxx=cxx, stdlib=stdlib))
for opt in sorted(opt_levels)
for link, link_opts in sorted(link_types)
for debug, debug_opts in sorted(debug_mixes)
for cxx, stdlib in compilers
]
cmake = find_cmake_command()
if cmake is not None:
builds.append(CMakeBuild(args.logs, CMakeConfig(cmake)))
return builds
def enqueue(queue, build, *args):
"""Put `build` on `queue`.
Ignores additional arguments, so that it can be used as a clalback for
`Pool`.
We do this instead of a lambda in order to get the closure right. We want
the build for the current iteration, not the last one that was executed
before the lambda runs.
"""
queue.put(build)
def enqueue_error(queue, build, error):
"""Put the pair of `build` and `error` on `queue`."""
queue.put((build, error))
def main(args):
"""Do it all."""
if not os.path.isdir(args.logs):
raise Fail("Logs location '%s' is not a directory." % args.logs)
builds = gather_builds(args)
if args.verbose:
print("Lined up %d builds." % len(builds))
# The "configure" step is single-threaded. We can run many at the same
# time, even when we're also running a "build" step at the same time.
# This means we may run a lot more processes than we have CPUs, but there's
# no law against that. There's also I/O time to be covered.
configure_pool = Pool()
# Builds which have failed the "configure" stage, with their errors. This
# queue must never stall, so that we can let results pile up here while the
# work continues.
configure_fails = Queue(len(builds))
# Waiting list for the "build" stage. It contains Build objects,
# terminated by a final None to signify that there are no more builds to be
# done.
build_queue = JoinableQueue(10)
# Builds that have failed the "build" stage.
build_fails = Queue(len(builds))
# Waiting list for the "test" stage. It contains Build objects, terminated
# by a final None.
test_queue = JoinableQueue(10)
# The "build" step tries to utilise all CPUs, and it may use a fair bit of
# memory. Run only one of these at a time, in a single worker process.
build_worker = Process(
target=service_builds, args=(build_queue, build_fails, test_queue))
build_worker.start()
# Builds that have failed the "test" stage.
test_fails = Queue(len(builds))
# Completed builds. This must never stall.
done_queue = JoinableQueue(len(builds))
# The "test" step can not run concurrently (yet). So, run tests serially
# in a single worker process. It takes its jobs directly from the "build"
# worker.
test_worker = Process(
target=service_tests, args=(test_queue, test_fails, done_queue))
test_worker.start()
# Feed all builds into the "configure" pool. Each build which passes this
# stage goes into the "build" queue.
for build in builds:
configure_pool.apply_async(
build.do_configure, callback=partial(enqueue, build_queue, build),
error_callback=partial(enqueue_error, configure_fails, build))
if args.verbose:
print("All jobs are underway.")
configure_pool.close()
configure_pool.join()
# TODO: Async reporting for faster feedback.
configure_fail_count = report_failures(configure_fails, "CONFIGURE FAIL")
if args.verbose:
print("Configure stage done.")
# Mark the end of the build queue for the build worker.
build_queue.put(None)
build_worker.join()
# TODO: Async reporting for faster feedback.
build_fail_count = report_failures(build_fails, "BUILD FAIL")
if args.verbose:
print("Build step done.")
# Mark the end of the test queue for the test worker.
test_queue.put(None)
test_worker.join()
# TODO: Async reporting for faster feedback.
# TODO: Collate failures into meaningful output, e.g. "shared library fails."
test_fail_count = report_failures(test_fails, "TEST FAIL")
if args.verbose:
print("Test step done.")
# All done. Clean up.
for build in builds:
build.clean_up()
ok_count = count_entries(done_queue)
if ok_count == len(builds):
print("All tests OK.")
else:
print(
"Failures during configure: %d - build: %d - test: %d. OK: %d."
% (
configure_fail_count,
build_fail_count,
test_fail_count,
ok_count,
))
if __name__ == '__main__':
try:
exit(main(parse_args()))
except Fail as failure:
stderr.write("%s\n" % failure)
exit(2)