Skip to content

Commit a3c5f91

Browse files
committed
pythongh-115122: Add --bisect option to regrtest (python#115123)
* test.bisect_cmd now exit with code 0 on success, and code 1 on failure. Before, it was the opposite. * test.bisect_cmd now runs the test worker process with -X faulthandler. * regrtest RunTests: Add create_python_cmd() and bisect_cmd() methods. (cherry picked from commit 1e5719a)
1 parent 1c72265 commit a3c5f91

File tree

8 files changed

+178
-28
lines changed

8 files changed

+178
-28
lines changed

Lib/test/bisect_cmd.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def python_cmd():
5151
cmd = [sys.executable]
5252
cmd.extend(subprocess._args_from_interpreter_flags())
5353
cmd.extend(subprocess._optim_args_from_interpreter_flags())
54+
cmd.extend(('-X', 'faulthandler'))
5455
return cmd
5556

5657

@@ -77,9 +78,13 @@ def run_tests(args, tests, huntrleaks=None):
7778
write_tests(tmp, tests)
7879

7980
cmd = python_cmd()
80-
cmd.extend(['-m', 'test', '--matchfile', tmp])
81+
cmd.extend(['-u', '-m', 'test', '--matchfile', tmp])
8182
cmd.extend(args.test_args)
8283
print("+ %s" % format_shell_args(cmd))
84+
85+
sys.stdout.flush()
86+
sys.stderr.flush()
87+
8388
proc = subprocess.run(cmd)
8489
return proc.returncode
8590
finally:
@@ -137,8 +142,8 @@ def main():
137142
ntest = max(ntest // 2, 1)
138143
subtests = random.sample(tests, ntest)
139144

140-
print("[+] Iteration %s: run %s tests/%s"
141-
% (iteration, len(subtests), len(tests)))
145+
print(f"[+] Iteration {iteration}/{args.max_iter}: "
146+
f"run {len(subtests)} tests/{len(tests)}")
142147
print()
143148

144149
exitcode = run_tests(args, subtests)
@@ -170,10 +175,10 @@ def main():
170175
if len(tests) <= args.max_tests:
171176
print("Bisection completed in %s iterations and %s"
172177
% (iteration, datetime.timedelta(seconds=dt)))
173-
sys.exit(1)
174178
else:
175179
print("Bisection failed after %s iterations and %s"
176180
% (iteration, datetime.timedelta(seconds=dt)))
181+
sys.exit(1)
177182

178183

179184
if __name__ == "__main__":

Lib/test/libregrtest/cmdline.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,8 @@ def _create_parser():
350350
help='override the working directory for the test run')
351351
group.add_argument('--cleanup', action='store_true',
352352
help='remove old test_python_* directories')
353+
group.add_argument('--bisect', action='store_true',
354+
help='if some tests fail, run test.bisect_cmd on them')
353355
group.add_argument('--dont-add-python-opts', dest='_add_python_opts',
354356
action='store_false',
355357
help="internal option, don't use it")

Lib/test/libregrtest/main.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
import sysconfig
77
import time
88

9-
from test import support
10-
from test.support import os_helper, MS_WINDOWS
9+
from test.support import os_helper, MS_WINDOWS, flush_std_streams
1110

1211
from .cmdline import _parse_args, Namespace
1312
from .findtests import findtests, split_test_packages, list_cases
@@ -74,6 +73,7 @@ def __init__(self, ns: Namespace, _add_python_opts: bool = False):
7473
self.want_cleanup: bool = ns.cleanup
7574
self.want_rerun: bool = ns.rerun
7675
self.want_run_leaks: bool = ns.runleaks
76+
self.want_bisect: bool = ns.bisect
7777

7878
self.ci_mode: bool = (ns.fast_ci or ns.slow_ci)
7979
self.want_add_python_opts: bool = (_add_python_opts
@@ -277,6 +277,55 @@ def rerun_failed_tests(self, runtests: RunTests):
277277

278278
self.display_result(rerun_runtests)
279279

280+
def _run_bisect(self, runtests: RunTests, test: str, progress: str) -> bool:
281+
print()
282+
title = f"Bisect {test}"
283+
if progress:
284+
title = f"{title} ({progress})"
285+
print(title)
286+
print("#" * len(title))
287+
print()
288+
289+
cmd = runtests.create_python_cmd()
290+
cmd.extend([
291+
"-u", "-m", "test.bisect_cmd",
292+
# Limit to 25 iterations (instead of 100) to not abuse CI resources
293+
"--max-iter", "25",
294+
"-v",
295+
# runtests.match_tests is not used (yet) for bisect_cmd -i arg
296+
])
297+
cmd.extend(runtests.bisect_cmd_args())
298+
cmd.append(test)
299+
print("+", shlex.join(cmd), flush=True)
300+
301+
flush_std_streams()
302+
303+
import subprocess
304+
proc = subprocess.run(cmd, timeout=runtests.timeout)
305+
exitcode = proc.returncode
306+
307+
title = f"{title}: exit code {exitcode}"
308+
print(title)
309+
print("#" * len(title))
310+
print(flush=True)
311+
312+
if exitcode:
313+
print(f"Bisect failed with exit code {exitcode}")
314+
return False
315+
316+
return True
317+
318+
def run_bisect(self, runtests: RunTests) -> None:
319+
tests, _ = self.results.prepare_rerun(clear=False)
320+
321+
for index, name in enumerate(tests, 1):
322+
if len(tests) > 1:
323+
progress = f"{index}/{len(tests)}"
324+
else:
325+
progress = ""
326+
if not self._run_bisect(runtests, name, progress):
327+
return
328+
280329
def display_result(self, runtests):
281330
# If running the test suite for PGO then no one cares about results.
282331
if runtests.pgo:
@@ -458,7 +507,7 @@ def _run_tests(self, selected: TestTuple, tests: TestList | None) -> int:
458507

459508
setup_process()
460509

461-
if self.hunt_refleak and not self.num_workers:
510+
if (runtests.hunt_refleak is not None) and (not self.num_workers):
462511
# gh-109739: WindowsLoadTracker thread interfers with refleak check
463512
use_load_tracker = False
464513
else:
@@ -478,6 +527,9 @@ def _run_tests(self, selected: TestTuple, tests: TestList | None) -> int:
478527

479528
if self.want_rerun and self.results.need_rerun():
480529
self.rerun_failed_tests(runtests)
530+
531+
if self.want_bisect and self.results.need_rerun():
532+
self.run_bisect(runtests)
481533
finally:
482534
if use_load_tracker:
483535
self.logger.stop_load_tracker()

Lib/test/libregrtest/results.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ def accumulate_result(self, result: TestResult, runtests: RunTests):
129129
def need_rerun(self):
130130
return bool(self.rerun_results)
131131

132-
def prepare_rerun(self) -> tuple[TestTuple, FilterDict]:
132+
def prepare_rerun(self, *, clear: bool = True) -> tuple[TestTuple, FilterDict]:
133133
tests: TestList = []
134134
match_tests_dict = {}
135135
for result in self.rerun_results:
@@ -140,11 +140,12 @@ def prepare_rerun(self) -> tuple[TestTuple, FilterDict]:
140140
if match_tests:
141141
match_tests_dict[result.test_name] = match_tests
142142

143-
# Clear previously failed tests
144-
self.rerun_bad.extend(self.bad)
145-
self.bad.clear()
146-
self.env_changed.clear()
147-
self.rerun_results.clear()
143+
if clear:
144+
# Clear previously failed tests
145+
self.rerun_bad.extend(self.bad)
146+
self.bad.clear()
147+
self.env_changed.clear()
148+
self.rerun_results.clear()
148149

149150
return (tuple(tests), match_tests_dict)
150151

Lib/test/libregrtest/runtests.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import dataclasses
33
import json
44
import os
5+
import shlex
56
import subprocess
7+
import sys
68
from typing import Any
79

810
from test import support
@@ -67,6 +69,11 @@ class HuntRefleak:
6769
runs: int
6870
filename: StrPath
6971

72+
def bisect_cmd_args(self) -> list[str]:
73+
# Ignore filename since it can contain colon (":"),
74+
# and usually it's not used. Use the default filename.
75+
return ["-R", f"{self.warmups}:{self.runs}:"]
76+
7077

7178
@dataclasses.dataclass(slots=True, frozen=True)
7279
class RunTests:
@@ -136,6 +143,49 @@ def json_file_use_stdout(self) -> bool:
136143
or support.is_wasi
137144
)
138145

146+
def create_python_cmd(self) -> list[str]:
147+
python_opts = support.args_from_interpreter_flags()
148+
if self.python_cmd is not None:
149+
executable = self.python_cmd
150+
# Remove -E option, since --python=COMMAND can set PYTHON
151+
# environment variables, such as PYTHONPATH, in the worker
152+
# process.
153+
python_opts = [opt for opt in python_opts if opt != "-E"]
154+
else:
155+
executable = (sys.executable,)
156+
cmd = [*executable, *python_opts]
157+
if '-u' not in python_opts:
158+
cmd.append('-u') # Unbuffered stdout and stderr
159+
if self.coverage:
160+
cmd.append("-Xpresite=test.cov")
161+
return cmd
162+
163+
def bisect_cmd_args(self) -> list[str]:
164+
args = []
165+
if self.fail_fast:
166+
args.append("--failfast")
167+
if self.fail_env_changed:
168+
args.append("--fail-env-changed")
169+
if self.timeout:
170+
args.append(f"--timeout={self.timeout}")
171+
if self.hunt_refleak is not None:
172+
args.extend(self.hunt_refleak.bisect_cmd_args())
173+
if self.test_dir:
174+
args.extend(("--testdir", self.test_dir))
175+
if self.memory_limit:
176+
args.extend(("--memlimit", self.memory_limit))
177+
if self.gc_threshold:
178+
args.append(f"--threshold={self.gc_threshold}")
179+
if self.use_resources:
180+
args.extend(("-u", ','.join(self.use_resources)))
181+
if self.python_cmd:
182+
cmd = shlex.join(self.python_cmd)
183+
args.extend(("--python", cmd))
184+
if self.randomize:
185+
args.append(f"--randomize")
186+
args.append(f"--randseed={self.random_seed}")
187+
return args
188+
139189

140190
@dataclasses.dataclass(slots=True, frozen=True)
141191
class WorkerRunTests(RunTests):

Lib/test/libregrtest/worker.py

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import os
44
from typing import Any, NoReturn
55

6-
from test import support
76
from test.support import os_helper
87

98
from .setup import setup_process, setup_test_dir
@@ -19,21 +18,10 @@
1918

2019
def create_worker_process(runtests: WorkerRunTests, output_fd: int,
2120
tmp_dir: StrPath | None = None) -> subprocess.Popen:
22-
python_cmd = runtests.python_cmd
2321
worker_json = runtests.as_json()
2422

25-
python_opts = support.args_from_interpreter_flags()
26-
if python_cmd is not None:
27-
executable = python_cmd
28-
# Remove -E option, since --python=COMMAND can set PYTHON environment
29-
# variables, such as PYTHONPATH, in the worker process.
30-
python_opts = [opt for opt in python_opts if opt != "-E"]
31-
else:
32-
executable = (sys.executable,)
33-
cmd = [*executable, *python_opts,
34-
'-u', # Unbuffered stdout and stderr
35-
'-m', 'test.libregrtest.worker',
36-
worker_json]
23+
cmd = runtests.create_python_cmd()
24+
cmd.extend(['-m', 'test.libregrtest.worker', worker_json])
3725

3826
env = dict(os.environ)
3927
if tmp_dir is not None:

Lib/test/test_regrtest.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ def test_unknown_option(self):
389389
self.checkError(['--unknown-option'],
390390
'unrecognized arguments: --unknown-option')
391391

392-
def check_ci_mode(self, args, use_resources, rerun=True):
392+
def create_regrtest(self, args):
393393
ns = cmdline._parse_args(args)
394394

395395
# Check Regrtest attributes which are more reliable than Namespace
@@ -401,6 +401,10 @@ def check_ci_mode(self, args, use_resources, rerun=True):
401401

402402
regrtest = main.Regrtest(ns)
403403

404+
return regrtest
405+
406+
def check_ci_mode(self, args, use_resources, rerun=True):
407+
regrtest = self.create_regrtest(args)
404408
self.assertEqual(regrtest.num_workers, -1)
405409
self.assertEqual(regrtest.want_rerun, rerun)
406410
self.assertTrue(regrtest.randomize)
@@ -446,6 +450,11 @@ def test_dont_add_python_opts(self):
446450
ns = cmdline._parse_args(args)
447451
self.assertFalse(ns._add_python_opts)
448452

453+
def test_bisect(self):
454+
args = ['--bisect']
455+
regrtest = self.create_regrtest(args)
456+
self.assertTrue(regrtest.want_bisect)
457+
449458

450459
@dataclasses.dataclass(slots=True)
451460
class Rerun:
@@ -1178,6 +1187,47 @@ def test_huntrleaks(self):
11781187
def test_huntrleaks_mp(self):
11791188
self.check_huntrleaks(run_workers=True)
11801189

1190+
@unittest.skipUnless(support.Py_DEBUG, 'need a debug build')
1191+
def test_huntrleaks_bisect(self):
1192+
# test --huntrleaks --bisect
1193+
code = textwrap.dedent("""
1194+
import unittest
1195+
1196+
GLOBAL_LIST = []
1197+
1198+
class RefLeakTest(unittest.TestCase):
1199+
def test1(self):
1200+
pass
1201+
1202+
def test2(self):
1203+
pass
1204+
1205+
def test3(self):
1206+
GLOBAL_LIST.append(object())
1207+
1208+
def test4(self):
1209+
pass
1210+
""")
1211+
1212+
test = self.create_test('huntrleaks', code=code)
1213+
1214+
filename = 'reflog.txt'
1215+
self.addCleanup(os_helper.unlink, filename)
1216+
cmd = ['--huntrleaks', '3:3:', '--bisect', test]
1217+
output = self.run_tests(*cmd,
1218+
exitcode=EXITCODE_BAD_TEST,
1219+
stderr=subprocess.STDOUT)
1220+
1221+
self.assertIn(f"Bisect {test}", output)
1222+
self.assertIn(f"Bisect {test}: exit code 0", output)
1223+
1224+
# test3 is the one which leaks
1225+
self.assertIn("Bisection completed in", output)
1226+
self.assertIn(
1227+
"Tests (1):\n"
1228+
f"* {test}.RefLeakTest.test3\n",
1229+
output)
1230+
11811231
@unittest.skipUnless(support.Py_DEBUG, 'need a debug build')
11821232
def test_huntrleaks_fd_leak(self):
11831233
# test --huntrleaks for file descriptor leak
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add ``--bisect`` option to regrtest test runner: run failed tests with
2+
``test.bisect_cmd`` to identify failing tests. Patch by Victor Stinner.

0 commit comments

Comments
 (0)