1#!/usr/local/bin/python3.11
2"""
3Command line tool to bisect failing CPython tests.
4
5Find the test_os test method which alters the environment:
6
7    ./python -m test.bisect_cmd --fail-env-changed test_os
8
9Find a reference leak in "test_os", write the list of failing tests into the
10"bisect" file:
11
12    ./python -m test.bisect_cmd -o bisect -R 3:3 test_os
13
14Load an existing list of tests from a file using -i option:
15
16    ./python -m test --list-cases -m FileTests test_os > tests
17    ./python -m test.bisect_cmd -i tests test_os
18"""
19
20import argparse
21import datetime
22import os.path
23import math
24import random
25import subprocess
26import sys
27import tempfile
28import time
29
30
31def write_tests(filename, tests):
32    with open(filename, "w") as fp:
33        for name in tests:
34            print(name, file=fp)
35        fp.flush()
36
37
38def write_output(filename, tests):
39    if not filename:
40        return
41    print("Writing %s tests into %s" % (len(tests), filename))
42    write_tests(filename, tests)
43    return filename
44
45
46def format_shell_args(args):
47    return ' '.join(args)
48
49
50def python_cmd():
51    cmd = [sys.executable]
52    cmd.extend(subprocess._args_from_interpreter_flags())
53    cmd.extend(subprocess._optim_args_from_interpreter_flags())
54    return cmd
55
56
57def list_cases(args):
58    cmd = python_cmd()
59    cmd.extend(['-m', 'test', '--list-cases'])
60    cmd.extend(args.test_args)
61    proc = subprocess.run(cmd,
62                          stdout=subprocess.PIPE,
63                          universal_newlines=True)
64    exitcode = proc.returncode
65    if exitcode:
66        cmd = format_shell_args(cmd)
67        print("Failed to list tests: %s failed with exit code %s"
68              % (cmd, exitcode))
69        sys.exit(exitcode)
70    tests = proc.stdout.splitlines()
71    return tests
72
73
74def run_tests(args, tests, huntrleaks=None):
75    tmp = tempfile.mktemp()
76    try:
77        write_tests(tmp, tests)
78
79        cmd = python_cmd()
80        cmd.extend(['-m', 'test', '--matchfile', tmp])
81        cmd.extend(args.test_args)
82        print("+ %s" % format_shell_args(cmd))
83        proc = subprocess.run(cmd)
84        return proc.returncode
85    finally:
86        if os.path.exists(tmp):
87            os.unlink(tmp)
88
89
90def parse_args():
91    parser = argparse.ArgumentParser()
92    parser.add_argument('-i', '--input',
93                        help='Test names produced by --list-tests written '
94                             'into a file. If not set, run --list-tests')
95    parser.add_argument('-o', '--output',
96                        help='Result of the bisection')
97    parser.add_argument('-n', '--max-tests', type=int, default=1,
98                        help='Maximum number of tests to stop the bisection '
99                             '(default: 1)')
100    parser.add_argument('-N', '--max-iter', type=int, default=100,
101                        help='Maximum number of bisection iterations '
102                             '(default: 100)')
103    # FIXME: document that following arguments are test arguments
104
105    args, test_args = parser.parse_known_args()
106    args.test_args = test_args
107    return args
108
109
110def main():
111    args = parse_args()
112    if '-w' in args.test_args or '--verbose2' in args.test_args:
113        print("WARNING: -w/--verbose2 option should not be used to bisect!")
114        print()
115
116    if args.input:
117        with open(args.input) as fp:
118            tests = [line.strip() for line in fp]
119    else:
120        tests = list_cases(args)
121
122    print("Start bisection with %s tests" % len(tests))
123    print("Test arguments: %s" % format_shell_args(args.test_args))
124    print("Bisection will stop when getting %s or less tests "
125          "(-n/--max-tests option), or after %s iterations "
126          "(-N/--max-iter option)"
127          % (args.max_tests, args.max_iter))
128    output = write_output(args.output, tests)
129    print()
130
131    start_time = time.monotonic()
132    iteration = 1
133    try:
134        while len(tests) > args.max_tests and iteration <= args.max_iter:
135            ntest = len(tests)
136            ntest = max(ntest // 2, 1)
137            subtests = random.sample(tests, ntest)
138
139            print("[+] Iteration %s: run %s tests/%s"
140                  % (iteration, len(subtests), len(tests)))
141            print()
142
143            exitcode = run_tests(args, subtests)
144
145            print("ran %s tests/%s" % (ntest, len(tests)))
146            print("exit", exitcode)
147            if exitcode:
148                print("Tests failed: continuing with this subtest")
149                tests = subtests
150                output = write_output(args.output, tests)
151            else:
152                print("Tests succeeded: skipping this subtest, trying a new subset")
153            print()
154            iteration += 1
155    except KeyboardInterrupt:
156        print()
157        print("Bisection interrupted!")
158        print()
159
160    print("Tests (%s):" % len(tests))
161    for test in tests:
162        print("* %s" % test)
163    print()
164
165    if output:
166        print("Output written into %s" % output)
167
168    dt = math.ceil(time.monotonic() - start_time)
169    if len(tests) <= args.max_tests:
170        print("Bisection completed in %s iterations and %s"
171              % (iteration, datetime.timedelta(seconds=dt)))
172        sys.exit(1)
173    else:
174        print("Bisection failed after %s iterations and %s"
175              % (iteration, datetime.timedelta(seconds=dt)))
176
177
178if __name__ == "__main__":
179    main()
180