#!/usr/bin/env python3 """ Command line tool to bisect failing CPython tests. Find the test_os test method which alters the environment: ./python -m test.bisect_cmd --fail-env-changed test_os Find a reference leak in "test_os", write the list of failing tests into the "bisect" file: ./python -m test.bisect_cmd -o bisect -R 3:3 test_os Load an existing list of tests from a file using -i option: ./python -m test --list-cases -m FileTests test_os > tests ./python -m test.bisect_cmd -i tests test_os """ import argparse import datetime import os.path import math import random import subprocess import sys import tempfile import time def write_tests(filename, tests): with open(filename, "w") as fp: for name in tests: print(name, file=fp) fp.flush() def write_output(filename, tests): if not filename: return print("Writing %s tests into %s" % (len(tests), filename)) write_tests(filename, tests) return filename def format_shell_args(args): return ' '.join(args) def python_cmd(): cmd = [sys.executable] cmd.extend(subprocess._args_from_interpreter_flags()) cmd.extend(subprocess._optim_args_from_interpreter_flags()) cmd.extend(('-X', 'faulthandler')) return cmd def list_cases(args): cmd = python_cmd() cmd.extend(['-m', 'test', '--list-cases']) cmd.extend(args.test_args) proc = subprocess.run(cmd, stdout=subprocess.PIPE, universal_newlines=True) exitcode = proc.returncode if exitcode: cmd = format_shell_args(cmd) print("Failed to list tests: %s failed with exit code %s" % (cmd, exitcode)) sys.exit(exitcode) tests = proc.stdout.splitlines() return tests def run_tests(args, tests, huntrleaks=None): tmp = tempfile.mktemp() try: write_tests(tmp, tests) cmd = python_cmd() cmd.extend(['-u', '-m', 'test', '--matchfile', tmp]) cmd.extend(args.test_args) print("+ %s" % format_shell_args(cmd)) sys.stdout.flush() sys.stderr.flush() proc = subprocess.run(cmd) return proc.returncode finally: if os.path.exists(tmp): os.unlink(tmp) def parse_args(): parser = argparse.ArgumentParser() parser.add_argument('-i', '--input', help='Test names produced by --list-tests written ' 'into a file. If not set, run --list-tests') parser.add_argument('-o', '--output', help='Result of the bisection') parser.add_argument('-n', '--max-tests', type=int, default=1, help='Maximum number of tests to stop the bisection ' '(default: 1)') parser.add_argument('-N', '--max-iter', type=int, default=100, help='Maximum number of bisection iterations ' '(default: 100)') # FIXME: document that following arguments are test arguments args, test_args = parser.parse_known_args() args.test_args = test_args return args def main(): args = parse_args() for opt in ('-w', '--rerun', '--verbose2'): if opt in args.test_args: print(f"WARNING: {opt} option should not be used to bisect!") print() if args.input: with open(args.input) as fp: tests = [line.strip() for line in fp] else: tests = list_cases(args) print("Start bisection with %s tests" % len(tests)) print("Test arguments: %s" % format_shell_args(args.test_args)) print("Bisection will stop when getting %s or less tests " "(-n/--max-tests option), or after %s iterations " "(-N/--max-iter option)" % (args.max_tests, args.max_iter)) output = write_output(args.output, tests) print() start_time = time.monotonic() iteration = 1 try: while len(tests) > args.max_tests and iteration <= args.max_iter: ntest = len(tests) ntest = max(ntest // 2, 1) subtests = random.sample(tests, ntest) print(f"[+] Iteration {iteration}/{args.max_iter}: " f"run {len(subtests)} tests/{len(tests)}") print() exitcode = run_tests(args, subtests) print("ran %s tests/%s" % (ntest, len(tests))) print("exit", exitcode) if exitcode: print("Tests failed: continuing with this subtest") tests = subtests output = write_output(args.output, tests) else: print("Tests succeeded: skipping this subtest, trying a new subset") print() iteration += 1 except KeyboardInterrupt: print() print("Bisection interrupted!") print() print("Tests (%s):" % len(tests)) for test in tests: print("* %s" % test) print() if output: print("Output written into %s" % output) dt = math.ceil(time.monotonic() - start_time) if len(tests) <= args.max_tests: print("Bisection completed in %s iterations and %s" % (iteration, datetime.timedelta(seconds=dt))) else: print("Bisection failed after %s iterations and %s" % (iteration, datetime.timedelta(seconds=dt))) sys.exit(1) if __name__ == "__main__": main()