Skip to content

Commit 8de7fdb

Browse files
authored
runtest.py runs processes in parallel (#196)
For example, on a laptop with 10 CPUs, testing 14 Python versions now takes 40.8 seconds instead of 119.6 seconds: 3x faster! Add --jobs option to runtests.py. tests/test_pythoncapi_compat.py now builds C and C++ extensions in a temporary build directory. Fix some ruff check warnings.
1 parent e2a2251 commit 8de7fdb

6 files changed

Lines changed: 128 additions & 86 deletions

File tree

‎.gitignore‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
build
21
*.swp
3-
tests/build/
42
*.py[cod]
53
__pycache__

‎docs/changelog.rst‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ Changelog
22
=========
33

44
* 2026-10-10: Add ``PyObject_GenericHash()`` and
5-
``PyUnicodeWriter_WriteUCS4()`` functions.
5+
``PyUnicodeWriter_WriteUCS4()`` functions. runtests.py now runs tests in
6+
parallel.
67
* 2026-10-08: Add support for the limited C API 3.2.
78
* 2026-10-08: Add functions:
89

‎runtests.py‎

Lines changed: 77 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,11 @@
1212
import os.path
1313
import shutil
1414
import sys
15+
import tempfile
1516
import time
16-
from shutil import which
17-
18-
19-
from tests.utils import run_command
17+
from concurrent.futures import ThreadPoolExecutor
2018

19+
from tests.utils import get_output, run_command
2120

2221
TEST_DIR = os.path.join(os.path.dirname(__file__), 'tests')
2322
TEST_COMPAT = os.path.join(TEST_DIR, "test_pythoncapi_compat.py")
@@ -53,26 +52,13 @@
5352
)
5453

5554

56-
def run_tests_exe(executable, verbose, tested):
57-
tested_key = os.path.realpath(executable)
58-
if tested_key in tested:
59-
return
60-
55+
def get_test_command(executable, verbose, build_dir):
6156
# Don't use realpath() for the executed command to support virtual
6257
# environments
63-
cmd = [executable, TEST_COMPAT]
58+
cmd = [executable, "-u", TEST_COMPAT, build_dir]
6459
if verbose:
6560
cmd.append('-v')
66-
run_command(cmd)
67-
tested.add(tested_key)
68-
69-
70-
def run_tests(python, verbose, tested):
71-
executable = which(python)
72-
if not executable:
73-
print("Ignore missing Python executable: %s" % python)
74-
return
75-
run_tests_exe(executable, verbose, tested)
61+
return cmd
7662

7763

7864
def parse_args():
@@ -82,39 +68,88 @@ def parse_args():
8268
parser.add_argument('-c', '--current', action="store_true",
8369
help="Only test the current Python executable "
8470
"(don't test multiple Python versions)")
71+
parser.add_argument('-j', '--jobs', type=int,
72+
help="Number of jobs run in parallel")
8573
return parser.parse_args()
8674

8775

88-
def main():
89-
start_time = time.perf_counter()
90-
args = parse_args()
76+
def run_tests_parallel(args):
77+
jobs = []
78+
79+
tested = set()
80+
tested_key = os.path.realpath(sys.executable)
81+
tested.add(tested_key)
82+
jobs.append(sys.executable)
83+
84+
for python in PYTHONS:
85+
executable = shutil.which(python)
86+
if not executable:
87+
print(f"Ignore missing Python executable: {python}")
88+
continue
89+
tested_key = os.path.realpath(executable)
90+
if tested_key in tested:
91+
continue
92+
tested.add(tested_key)
93+
jobs.append(executable)
94+
95+
def worker(executable):
96+
with tempfile.TemporaryDirectory() as build_dir:
97+
cmd = get_test_command(executable, args.verbose, build_dir)
98+
return get_output(cmd)
99+
100+
max_workers = args.jobs
101+
if not max_workers:
102+
if hasattr(os, 'process_cpu_count'):
103+
max_workers = os.process_cpu_count()
104+
else:
105+
max_workers = os.cpu_count()
106+
107+
print()
108+
print(f"Run {len(jobs)} jobs with {max_workers} workers (threads)")
109+
print()
110+
111+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
112+
exitcode = None
113+
for exitcode, stdout in executor.map(worker, jobs):
114+
print(stdout, end='', flush=True)
115+
if exitcode:
116+
break
91117

92-
path = os.path.join(TEST_DIR, 'build')
93-
if os.path.exists(path):
94-
shutil.rmtree(path)
118+
if exitcode:
119+
executor.shutdown(wait=True, cancel_futures=True)
120+
sys.exit(exitcode)
95121

122+
print()
123+
print(f"Tested: {len(jobs)} Python executables")
124+
125+
126+
def test_upgrade_pythoncapi(args):
96127
# upgrade_pythoncapi.py requires Python 3.6 or newer
97-
if sys.version_info >= (3, 6):
98-
print("Run %s" % TEST_UPGRADE)
99-
cmd = [sys.executable, TEST_UPGRADE]
100-
if args.verbose:
101-
cmd.append('-v')
128+
print(f"Run {TEST_UPGRADE}")
129+
cmd = [sys.executable, "-u", TEST_UPGRADE]
130+
if args.verbose:
131+
cmd.append('-v')
132+
run_command(cmd)
133+
print()
134+
135+
136+
def test_current_version(args):
137+
with tempfile.TemporaryDirectory() as build_dir:
138+
cmd = get_test_command(sys.executable, args.verbose, build_dir)
102139
run_command(cmd)
103-
else:
104-
print("Don't test upgrade_pythoncapi.py: it requires Python 3.6")
105140
print()
106141

107-
tested = set()
108-
if not args.current:
109-
for python in PYTHONS:
110-
run_tests(python, args.verbose, tested)
111-
run_tests_exe(sys.executable, args.verbose, tested)
112142

113-
print()
114-
print("Tested: %s Python executables" % len(tested))
143+
def main():
144+
start_time = time.perf_counter()
145+
args = parse_args()
146+
147+
test_upgrade_pythoncapi(args)
148+
149+
if not args.current:
150+
run_tests_parallel(args)
115151
else:
116-
run_tests_exe(sys.executable, args.verbose, tested)
117-
print()
152+
test_current_version(args)
118153

119154
dt = time.perf_counter() - start_time
120155
print(f"Total time: {dt:.1f} seconds")

‎tests/setup.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
else:
2929
TEST_OLD_LIMITED_C_API = False
3030

31-
OLD_LIMITED_CAPI = f'-DPy_LIMITED_API=0x3020000'
31+
OLD_LIMITED_CAPI = '-DPy_LIMITED_API=0x3020000'
3232

3333
SRC_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), '..'))
3434
LIMITED_SUFFIX = "_limited"
@@ -136,9 +136,9 @@ def cxx_extension_name(std):
136136

137137
def main():
138138
try:
139-
from setuptools import setup, Extension
139+
from setuptools import Extension, setup
140140
except ImportError:
141-
from distutils.core import setup, Extension
141+
from distutils.core import Extension, setup
142142

143143
cflags = list(CFLAGS)
144144
cxxflags = list(CXXFLAGS)

‎tests/test_pythoncapi_compat.py‎

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
python3 run_tests.py
88
python3 run_tests.py -v # verbose mode
99
"""
10+
import argparse
1011
import faulthandler
1112
import gc
1213
import os.path
@@ -17,7 +18,7 @@
1718

1819
# test.utils
1920
import setup
20-
from utils import run_command, command_stdout
21+
from utils import run_command, get_output
2122

2223

2324
# Windows uses MSVC compiler
@@ -62,26 +63,24 @@ def display_title(title):
6263
sys.stdout.flush()
6364

6465

65-
def build_ext():
66+
def build_ext(build_dir):
6667
display_title("Build test extensions")
67-
if os.path.exists("build"):
68-
shutil.rmtree("build")
69-
cmd = [sys.executable, "setup.py", "build"]
68+
cmd = [sys.executable, "-u", "setup.py", "build", "--build-base", build_dir]
7069
if VERBOSE:
7170
run_command(cmd)
7271
print()
7372
else:
74-
exitcode, stdout = command_stdout(cmd, stderr=subprocess.STDOUT)
73+
exitcode, stdout = get_output(cmd)
7574
if exitcode:
7675
print(stdout.rstrip())
7776
sys.exit(exitcode)
7877

7978

80-
def import_tests(module_name):
79+
def import_tests(build_dir, module_name):
8180
pythonpath = None
82-
for name in os.listdir("build"):
81+
for name in os.listdir(build_dir):
8382
if name.startswith('lib.'):
84-
pythonpath = os.path.join("build", name)
83+
pythonpath = os.path.join(build_dir, name)
8584

8685
if not pythonpath:
8786
raise Exception("Failed to find the build directory")
@@ -152,7 +151,7 @@ def python_version():
152151
return "%s %s (%s build)" % (python_impl, pyver, build)
153152

154153

155-
def run_tests(module_name, std):
154+
def run_tests(build_dir, module_name, std):
156155
lang = std.upper() if std else None
157156
if VERBOSE:
158157
print("")
@@ -162,7 +161,7 @@ def run_tests(module_name, std):
162161
titlte = f"{title} ({lang})"
163162
display_title(title)
164163

165-
testmod = import_tests(module_name)
164+
testmod = import_tests(build_dir, module_name)
166165

167166
if VERBOSE:
168167
empty_line = False
@@ -203,27 +202,36 @@ def test_func():
203202
msg += " (no reference leak detected)"
204203
print(msg)
205204

205+
# Unload the extension module
206+
testmod = None
207+
del sys.modules[module_name]
206208

207-
def main():
208-
global VERBOSE
209-
VERBOSE = ("-v" in sys.argv[1:] or "--verbose" in sys.argv[1:])
210209

211-
if (3, 13) <= sys.version_info <= (3, 13, 0, 'alpha', 4):
212-
print("SKIP Python 3.13 alpha 1..4: not supported!")
213-
return
210+
def parse_args():
211+
parser = argparse.ArgumentParser()
212+
parser.add_argument('-v', '--verbose',
213+
action='store_true') # on/off flag
214+
parser.add_argument('build_dir')
215+
return parser.parse_args()
216+
214217

215-
if faulthandler is not None:
216-
faulthandler.enable()
218+
def main():
219+
faulthandler.enable()
220+
221+
global VERBOSE
222+
args = parse_args()
223+
VERBOSE = args.verbose
224+
build_dir = args.build_dir
217225

218226
src_dir = os.path.dirname(__file__)
219227
if src_dir:
220228
os.chdir(src_dir)
221229

222-
build_ext()
230+
build_ext(build_dir)
223231

224232
tests = setup.C_TESTS + setup.CXX_TESTS
225233
for module_name, std, limited in tests:
226-
run_tests(module_name, std)
234+
run_tests(build_dir, module_name, std)
227235

228236

229237
if __name__ == "__main__":

‎tests/utils.py‎

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,37 @@
22
import sys
33

44

5-
def run_command(cmd, **kw):
5+
def _run_command(cmd, **kw):
6+
sys.stdout.flush()
7+
sys.stderr.flush()
8+
9+
kw['shell'] = False
610
if hasattr(subprocess, 'run'):
711
proc = subprocess.run(cmd, **kw)
812
else:
9-
kw['shell'] = False
1013
proc = subprocess.Popen(cmd, **kw)
1114
try:
15+
proc.communicate()
1216
proc.wait()
1317
except:
1418
proc.kill()
1519
proc.wait()
1620
raise
21+
return proc
1722

23+
24+
def run_command(cmd, **kw):
25+
proc = _run_command(cmd, **kw)
1826
exitcode = proc.returncode
1927
if exitcode:
2028
sys.exit(exitcode)
2129

2230

23-
def command_stdout(cmd, **kw):
24-
kw['stdout'] = subprocess.PIPE
31+
def get_output(cmd, **kw):
32+
# Legacy for text=True
2533
kw['universal_newlines'] = True
26-
if hasattr(subprocess, 'run'):
27-
proc = subprocess.run(cmd, **kw)
28-
return (proc.returncode, proc.stdout)
29-
else:
30-
kw['shell'] = False
31-
proc = subprocess.Popen(cmd, **kw)
32-
try:
33-
stdout = proc.communicate()[0]
34-
except:
35-
proc.kill()
36-
proc.wait()
37-
raise
38-
return (proc.returncode, stdout)
34+
proc = _run_command(cmd,
35+
stdout=subprocess.PIPE,
36+
stderr=subprocess.STDOUT,
37+
**kw)
38+
return (proc.returncode, proc.stdout)

0 commit comments

Comments
 (0)