From fe0fe87727897a996b702eedc95249311ac7b7cd Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Sat, 10 Oct 2026 02:37:24 +0200 Subject: [PATCH 1/5] 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. --- .gitignore | 2 - docs/changelog.rst | 3 +- runtests.py | 106 ++++++++++++++++++++------------ tests/setup.py | 6 +- tests/test_pythoncapi_compat.py | 30 ++++----- tests/utils.py | 32 +++++----- 6 files changed, 99 insertions(+), 80 deletions(-) diff --git a/.gitignore b/.gitignore index f724ed1..0c05057 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -build *.swp -tests/build/ *.py[cod] __pycache__ diff --git a/docs/changelog.rst b/docs/changelog.rst index 7e3f002..02cdd72 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,7 +2,8 @@ Changelog ========= * 2026-10-10: Add ``PyObject_GenericHash()`` and - ``PyUnicodeWriter_WriteUCS4()`` functions. + ``PyUnicodeWriter_WriteUCS4()`` functions. runtests.py now runs tests in + parallel. * 2026-10-08: Add support for the limited C API 3.2. * 2026-10-08: Add functions: diff --git a/runtests.py b/runtests.py index 0493285..2b1d393 100755 --- a/runtests.py +++ b/runtests.py @@ -13,11 +13,9 @@ import shutil import sys import time -from shutil import which - - -from tests.utils import run_command +from concurrent.futures import ThreadPoolExecutor +from tests.utils import get_output, run_command TEST_DIR = os.path.join(os.path.dirname(__file__), 'tests') TEST_COMPAT = os.path.join(TEST_DIR, "test_pythoncapi_compat.py") @@ -53,26 +51,13 @@ ) -def run_tests_exe(executable, verbose, tested): - tested_key = os.path.realpath(executable) - if tested_key in tested: - return - +def get_test_command(executable, verbose): # Don't use realpath() for the executed command to support virtual # environments cmd = [executable, TEST_COMPAT] if verbose: cmd.append('-v') - run_command(cmd) - tested.add(tested_key) - - -def run_tests(python, verbose, tested): - executable = which(python) - if not executable: - print("Ignore missing Python executable: %s" % python) - return - run_tests_exe(executable, verbose, tested) + return cmd def parse_args(): @@ -85,35 +70,74 @@ def parse_args(): return parser.parse_args() -def main(): - start_time = time.perf_counter() - args = parse_args() +def run_tests_parallel(args): + jobs = [] + + tested = set() + tested_key = os.path.realpath(sys.executable) + tested.add(tested_key) + jobs.append(sys.executable) + + for python in PYTHONS: + executable = shutil.which(python) + if not executable: + print(f"Ignore missing Python executable: {python}") + continue + tested_key = os.path.realpath(executable) + if tested_key in tested: + continue + tested.add(tested_key) + jobs.append(executable) + + def worker(executable): + cmd = get_test_command(executable, args.verbose) + return get_output(cmd) + + if hasattr(os, 'process_cpu_count'): + max_workers = os.process_cpu_count() + else: + max_workers = os.cpu_count() + + print() + print(f"Run {len(jobs)} jobs with {max_workers} workers (threads)") + print() + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + exitcode = None + for exitcode, stdout in executor.map(worker, jobs): + print(stdout, end='') + if exitcode: + break - path = os.path.join(TEST_DIR, 'build') - if os.path.exists(path): - shutil.rmtree(path) + if exitcode: + executor.shutdown(wait=True, cancel_futures=True) + sys.exit(exitcode) + print() + print(f"Tested: {len(jobs)} Python executables") + + +def test_upgrade_pythoncapi(args): # upgrade_pythoncapi.py requires Python 3.6 or newer - if sys.version_info >= (3, 6): - print("Run %s" % TEST_UPGRADE) - cmd = [sys.executable, TEST_UPGRADE] - if args.verbose: - cmd.append('-v') - run_command(cmd) - else: - print("Don't test upgrade_pythoncapi.py: it requires Python 3.6") + print(f"Run {TEST_UPGRADE}") + cmd = [sys.executable, TEST_UPGRADE] + if args.verbose: + cmd.append('-v') + run_command(cmd) print() - tested = set() - if not args.current: - for python in PYTHONS: - run_tests(python, args.verbose, tested) - run_tests_exe(sys.executable, args.verbose, tested) - print() - print("Tested: %s Python executables" % len(tested)) +def main(): + start_time = time.perf_counter() + args = parse_args() + + test_upgrade_pythoncapi(args) + + if not args.current: + run_tests_parallel(args) else: - run_tests_exe(sys.executable, args.verbose, tested) + cmd = get_test_command(sys.executable, args.verbose) + run_command(cmd) print() dt = time.perf_counter() - start_time diff --git a/tests/setup.py b/tests/setup.py index 57f44f4..ba79156 100755 --- a/tests/setup.py +++ b/tests/setup.py @@ -28,7 +28,7 @@ else: TEST_OLD_LIMITED_C_API = False -OLD_LIMITED_CAPI = f'-DPy_LIMITED_API=0x3020000' +OLD_LIMITED_CAPI = '-DPy_LIMITED_API=0x3020000' SRC_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), '..')) LIMITED_SUFFIX = "_limited" @@ -136,9 +136,9 @@ def cxx_extension_name(std): def main(): try: - from setuptools import setup, Extension + from setuptools import Extension, setup except ImportError: - from distutils.core import setup, Extension + from distutils.core import Extension, setup cflags = list(CFLAGS) cxxflags = list(CXXFLAGS) diff --git a/tests/test_pythoncapi_compat.py b/tests/test_pythoncapi_compat.py index aca6d67..4fcfbd3 100644 --- a/tests/test_pythoncapi_compat.py +++ b/tests/test_pythoncapi_compat.py @@ -14,10 +14,11 @@ import subprocess import sys import sysconfig +import tempfile # test.utils import setup -from utils import run_command, command_stdout +from utils import run_command, get_output # Windows uses MSVC compiler @@ -62,26 +63,24 @@ def display_title(title): sys.stdout.flush() -def build_ext(): +def build_ext(build_dir): display_title("Build test extensions") - if os.path.exists("build"): - shutil.rmtree("build") - cmd = [sys.executable, "setup.py", "build"] + cmd = [sys.executable, "setup.py", "build", "--build-base", build_dir] if VERBOSE: run_command(cmd) print() else: - exitcode, stdout = command_stdout(cmd, stderr=subprocess.STDOUT) + exitcode, stdout = get_output(cmd) if exitcode: print(stdout.rstrip()) sys.exit(exitcode) -def import_tests(module_name): +def import_tests(build_dir, module_name): pythonpath = None - for name in os.listdir("build"): + for name in os.listdir(build_dir): if name.startswith('lib.'): - pythonpath = os.path.join("build", name) + pythonpath = os.path.join(build_dir, name) if not pythonpath: raise Exception("Failed to find the build directory") @@ -152,7 +151,7 @@ def python_version(): return "%s %s (%s build)" % (python_impl, pyver, build) -def run_tests(module_name, std): +def run_tests(build_dir, module_name, std): lang = std.upper() if std else None if VERBOSE: print("") @@ -162,7 +161,7 @@ def run_tests(module_name, std): titlte = f"{title} ({lang})" display_title(title) - testmod = import_tests(module_name) + testmod = import_tests(build_dir, module_name) if VERBOSE: empty_line = False @@ -219,11 +218,12 @@ def main(): if src_dir: os.chdir(src_dir) - build_ext() + with tempfile.TemporaryDirectory() as build_dir: + build_ext(build_dir) - tests = setup.C_TESTS + setup.CXX_TESTS - for module_name, std, limited in tests: - run_tests(module_name, std) + tests = setup.C_TESTS + setup.CXX_TESTS + for module_name, std, limited in tests: + run_tests(build_dir, module_name, std) if __name__ == "__main__": diff --git a/tests/utils.py b/tests/utils.py index 0fd0547..a6f9065 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2,37 +2,33 @@ import sys -def run_command(cmd, **kw): +def _run_command(cmd, **kw): + kw['shell'] = False if hasattr(subprocess, 'run'): proc = subprocess.run(cmd, **kw) else: - kw['shell'] = False proc = subprocess.Popen(cmd, **kw) try: - proc.wait() + proc.communicate() except: proc.kill() proc.wait() raise + return proc + +def run_command(cmd, **kw): + proc = _run_command(cmd, **kw) exitcode = proc.returncode if exitcode: sys.exit(exitcode) -def command_stdout(cmd, **kw): - kw['stdout'] = subprocess.PIPE +def get_output(cmd, **kw): + # Legacy for text=True kw['universal_newlines'] = True - if hasattr(subprocess, 'run'): - proc = subprocess.run(cmd, **kw) - return (proc.returncode, proc.stdout) - else: - kw['shell'] = False - proc = subprocess.Popen(cmd, **kw) - try: - stdout = proc.communicate()[0] - except: - proc.kill() - proc.wait() - raise - return (proc.returncode, stdout) + proc = _run_command(cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + **kw) + return (proc.returncode, proc.stdout) From 9842f6ad0fa983d728f3ec9f1a83a70f1260ac0b Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Sat, 10 Oct 2026 03:06:22 +0200 Subject: [PATCH 2/5] Use tempfile in the parent process --- runtests.py | 21 +++++++++++++------- tests/test_pythoncapi_compat.py | 34 ++++++++++++++++++++------------- tests/utils.py | 1 + 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/runtests.py b/runtests.py index 2b1d393..72b2b8d 100755 --- a/runtests.py +++ b/runtests.py @@ -12,6 +12,7 @@ import os.path import shutil import sys +import tempfile import time from concurrent.futures import ThreadPoolExecutor @@ -51,10 +52,10 @@ ) -def get_test_command(executable, verbose): +def get_test_command(executable, verbose, build_dir): # Don't use realpath() for the executed command to support virtual # environments - cmd = [executable, TEST_COMPAT] + cmd = [executable, TEST_COMPAT, build_dir] if verbose: cmd.append('-v') return cmd @@ -90,8 +91,9 @@ def run_tests_parallel(args): jobs.append(executable) def worker(executable): - cmd = get_test_command(executable, args.verbose) - return get_output(cmd) + with tempfile.TemporaryDirectory() as build_dir: + cmd = get_test_command(executable, args.verbose, build_dir) + return get_output(cmd) if hasattr(os, 'process_cpu_count'): max_workers = os.process_cpu_count() @@ -127,6 +129,13 @@ def test_upgrade_pythoncapi(args): print() +def test_current_version(args): + with tempfile.TemporaryDirectory() as build_dir: + cmd = get_test_command(sys.executable, args.verbose, build_dir) + run_command(cmd) + print() + + def main(): start_time = time.perf_counter() args = parse_args() @@ -136,9 +145,7 @@ def main(): if not args.current: run_tests_parallel(args) else: - cmd = get_test_command(sys.executable, args.verbose) - run_command(cmd) - print() + test_current_version(args) dt = time.perf_counter() - start_time print(f"Total time: {dt:.1f} seconds") diff --git a/tests/test_pythoncapi_compat.py b/tests/test_pythoncapi_compat.py index 4fcfbd3..555d4dc 100644 --- a/tests/test_pythoncapi_compat.py +++ b/tests/test_pythoncapi_compat.py @@ -7,6 +7,7 @@ python3 run_tests.py python3 run_tests.py -v # verbose mode """ +import argparse import faulthandler import gc import os.path @@ -14,7 +15,6 @@ import subprocess import sys import sysconfig -import tempfile # test.utils import setup @@ -202,28 +202,36 @@ def test_func(): msg += " (no reference leak detected)" print(msg) + # Unload the extension module + testmod = None + del sys.modules[module_name] + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument('-v', '--verbose', + action='store_true') # on/off flag + parser.add_argument('build_dir') + return parser.parse_args() + def main(): global VERBOSE - VERBOSE = ("-v" in sys.argv[1:] or "--verbose" in sys.argv[1:]) - - if (3, 13) <= sys.version_info <= (3, 13, 0, 'alpha', 4): - print("SKIP Python 3.13 alpha 1..4: not supported!") - return + args = parse_args() + VERBOSE = args.verbose + build_dir = args.build_dir - if faulthandler is not None: - faulthandler.enable() + faulthandler.enable() src_dir = os.path.dirname(__file__) if src_dir: os.chdir(src_dir) - with tempfile.TemporaryDirectory() as build_dir: - build_ext(build_dir) + build_ext(build_dir) - tests = setup.C_TESTS + setup.CXX_TESTS - for module_name, std, limited in tests: - run_tests(build_dir, module_name, std) + tests = setup.C_TESTS + setup.CXX_TESTS + for module_name, std, limited in tests: + run_tests(build_dir, module_name, std) if __name__ == "__main__": diff --git a/tests/utils.py b/tests/utils.py index a6f9065..3166c52 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -10,6 +10,7 @@ def _run_command(cmd, **kw): proc = subprocess.Popen(cmd, **kw) try: proc.communicate() + proc.wait() except: proc.kill() proc.wait() From a30726473618453c776b6d42bfb1de7669050810 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Sat, 10 Oct 2026 03:11:49 +0200 Subject: [PATCH 3/5] Flush stdout/stderr --- tests/utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/utils.py b/tests/utils.py index 3166c52..39b5452 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -3,6 +3,9 @@ def _run_command(cmd, **kw): + sys.stdout.flush() + sys.stderr.flush() + kw['shell'] = False if hasattr(subprocess, 'run'): proc = subprocess.run(cmd, **kw) From ef4897777103a5505f472d0be4a1839f6e3fb974 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Sat, 10 Oct 2026 03:15:53 +0200 Subject: [PATCH 4/5] Pass -u option --- runtests.py | 4 ++-- tests/test_pythoncapi_compat.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/runtests.py b/runtests.py index 72b2b8d..ee2c35d 100755 --- a/runtests.py +++ b/runtests.py @@ -55,7 +55,7 @@ def get_test_command(executable, verbose, build_dir): # Don't use realpath() for the executed command to support virtual # environments - cmd = [executable, TEST_COMPAT, build_dir] + cmd = [executable, "-u", TEST_COMPAT, build_dir] if verbose: cmd.append('-v') return cmd @@ -122,7 +122,7 @@ def worker(executable): def test_upgrade_pythoncapi(args): # upgrade_pythoncapi.py requires Python 3.6 or newer print(f"Run {TEST_UPGRADE}") - cmd = [sys.executable, TEST_UPGRADE] + cmd = [sys.executable, "-u", TEST_UPGRADE] if args.verbose: cmd.append('-v') run_command(cmd) diff --git a/tests/test_pythoncapi_compat.py b/tests/test_pythoncapi_compat.py index 555d4dc..8ef3f24 100644 --- a/tests/test_pythoncapi_compat.py +++ b/tests/test_pythoncapi_compat.py @@ -65,7 +65,7 @@ def display_title(title): def build_ext(build_dir): display_title("Build test extensions") - cmd = [sys.executable, "setup.py", "build", "--build-base", build_dir] + cmd = [sys.executable, "-u", "setup.py", "build", "--build-base", build_dir] if VERBOSE: run_command(cmd) print() @@ -216,13 +216,13 @@ def parse_args(): def main(): + faulthandler.enable() + global VERBOSE args = parse_args() VERBOSE = args.verbose build_dir = args.build_dir - faulthandler.enable() - src_dir = os.path.dirname(__file__) if src_dir: os.chdir(src_dir) From b35575d31df070770a86aeeb1cfe2bd5dce4a10f Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Sat, 10 Oct 2026 03:20:47 +0200 Subject: [PATCH 5/5] Add --jobs option --- runtests.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/runtests.py b/runtests.py index ee2c35d..04ea8a3 100755 --- a/runtests.py +++ b/runtests.py @@ -68,6 +68,8 @@ def parse_args(): parser.add_argument('-c', '--current', action="store_true", help="Only test the current Python executable " "(don't test multiple Python versions)") + parser.add_argument('-j', '--jobs', type=int, + help="Number of jobs run in parallel") return parser.parse_args() @@ -95,10 +97,12 @@ def worker(executable): cmd = get_test_command(executable, args.verbose, build_dir) return get_output(cmd) - if hasattr(os, 'process_cpu_count'): - max_workers = os.process_cpu_count() - else: - max_workers = os.cpu_count() + max_workers = args.jobs + if not max_workers: + if hasattr(os, 'process_cpu_count'): + max_workers = os.process_cpu_count() + else: + max_workers = os.cpu_count() print() print(f"Run {len(jobs)} jobs with {max_workers} workers (threads)") @@ -107,7 +111,7 @@ def worker(executable): with ThreadPoolExecutor(max_workers=max_workers) as executor: exitcode = None for exitcode, stdout in executor.map(worker, jobs): - print(stdout, end='') + print(stdout, end='', flush=True) if exitcode: break