Skip to content

Commit 5e8b0a5

Browse files
committed
runtest.py runs processes in parallel
For example, on a laptop with 10 CPUs, testing 14 Python versions now takes 40.8 seconds instead of 119.6 seconds: 3x faster! 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 5e8b0a5

6 files changed

Lines changed: 99 additions & 80 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: 65 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,9 @@
1313
import shutil
1414
import sys
1515
import time
16-
from shutil import which
17-
18-
19-
from tests.utils import run_command
16+
from concurrent.futures import ThreadPoolExecutor
2017

18+
from tests.utils import get_output, run_command
2119

2220
TEST_DIR = os.path.join(os.path.dirname(__file__), 'tests')
2321
TEST_COMPAT = os.path.join(TEST_DIR, "test_pythoncapi_compat.py")
@@ -53,26 +51,13 @@
5351
)
5452

5553

56-
def run_tests_exe(executable, verbose, tested):
57-
tested_key = os.path.realpath(executable)
58-
if tested_key in tested:
59-
return
60-
54+
def get_test_command(executable, verbose):
6155
# Don't use realpath() for the executed command to support virtual
6256
# environments
6357
cmd = [executable, TEST_COMPAT]
6458
if verbose:
6559
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)
60+
return cmd
7661

7762

7863
def parse_args():
@@ -85,35 +70,74 @@ def parse_args():
8570
return parser.parse_args()
8671

8772

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

92-
path = os.path.join(TEST_DIR, 'build')
93-
if os.path.exists(path):
94-
shutil.rmtree(path)
112+
if exitcode:
113+
executor.shutdown(wait=True, cancel_futures=True)
114+
sys.exit(exitcode)
95115

116+
print()
117+
print("Tested: {len(jobs)} Python executables")
118+
119+
120+
def test_upgrade_pythoncapi(args):
96121
# 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')
102-
run_command(cmd)
103-
else:
104-
print("Don't test upgrade_pythoncapi.py: it requires Python 3.6")
122+
print(f"Run {TEST_UPGRADE}")
123+
cmd = [sys.executable, TEST_UPGRADE]
124+
if args.verbose:
125+
cmd.append('-v')
126+
run_command(cmd)
105127
print()
106128

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)
112129

113-
print()
114-
print("Tested: %s Python executables" % len(tested))
130+
def main():
131+
start_time = time.perf_counter()
132+
args = parse_args()
133+
134+
test_upgrade_pythoncapi(args)
135+
136+
if not args.current:
137+
run_tests_parallel(args)
115138
else:
116-
run_tests_exe(sys.executable, args.verbose, tested)
139+
cmd = get_test_command(sys.executable, args.verbose)
140+
run_command(cmd)
117141
print()
118142

119143
dt = time.perf_counter() - start_time

‎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: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@
1414
import subprocess
1515
import sys
1616
import sysconfig
17+
import tempfile
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, "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
@@ -219,11 +218,12 @@ def main():
219218
if src_dir:
220219
os.chdir(src_dir)
221220

222-
build_ext()
221+
with tempfile.TemporaryDirectory() as build_dir:
222+
build_ext(build_dir)
223223

224-
tests = setup.C_TESTS + setup.CXX_TESTS
225-
for module_name, std, limited in tests:
226-
run_tests(module_name, std)
224+
tests = setup.C_TESTS + setup.CXX_TESTS
225+
for module_name, std, limited in tests:
226+
run_tests(build_dir, module_name, std)
227227

228228

229229
if __name__ == "__main__":

‎tests/utils.py‎

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

44

5-
def run_command(cmd, **kw):
5+
def _run_command(cmd, **kw):
6+
kw['shell'] = False
67
if hasattr(subprocess, 'run'):
78
proc = subprocess.run(cmd, **kw)
89
else:
9-
kw['shell'] = False
1010
proc = subprocess.Popen(cmd, **kw)
1111
try:
12-
proc.wait()
12+
proc.communicate()
1313
except:
1414
proc.kill()
1515
proc.wait()
1616
raise
17+
return proc
1718

19+
20+
def run_command(cmd, **kw):
21+
proc = _run_command(cmd, **kw)
1822
exitcode = proc.returncode
1923
if exitcode:
2024
sys.exit(exitcode)
2125

2226

23-
def command_stdout(cmd, **kw):
24-
kw['stdout'] = subprocess.PIPE
27+
def get_output(cmd, **kw):
28+
# Legacy for text=True
2529
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)
30+
proc = _run_command(cmd,
31+
stdout=subprocess.PIPE,
32+
stderr=subprocess.STDOUT,
33+
**kw)
34+
return (proc.returncode, proc.stdout)

0 commit comments

Comments
 (0)