Skip to content

Commit e40b5be

Browse files
authored
Speedup regr_test.py by running test cases concurrently (#10714)
1 parent e6fb59c commit e40b5be

File tree

1 file changed

+121
-49
lines changed

1 file changed

+121
-49
lines changed

tests/regr_test.py

Lines changed: 121 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@
44
from __future__ import annotations
55

66
import argparse
7+
import concurrent.futures
78
import os
9+
import queue
810
import re
911
import shutil
1012
import subprocess
1113
import sys
1214
import tempfile
15+
import threading
16+
from contextlib import ExitStack, suppress
17+
from dataclasses import dataclass
1318
from enum import IntEnum
1419
from itertools import product
1520
from pathlib import Path
@@ -24,7 +29,6 @@
2429
get_mypy_req,
2530
make_venv,
2631
print_error,
27-
print_success_msg,
2832
testcase_dir_from_package_name,
2933
)
3034

@@ -103,18 +107,20 @@ class Verbosity(IntEnum):
103107
),
104108
)
105109

110+
_PRINT_QUEUE: queue.SimpleQueue[str] = queue.SimpleQueue()
111+
106112

107113
def verbose_log(msg: str) -> None:
108-
print(colored("\n" + msg, "blue"))
114+
_PRINT_QUEUE.put(colored(msg, "blue"))
109115

110116

111-
def setup_testcase_dir(package: PackageInfo, tempdir: Path, new_test_case_dir: Path, verbosity: Verbosity) -> None:
117+
def setup_testcase_dir(package: PackageInfo, tempdir: Path, verbosity: Verbosity) -> None:
112118
if verbosity is verbosity.VERBOSE:
113-
verbose_log(f"Setting up testcase dir in {tempdir}")
119+
verbose_log(f"{package.name}: Setting up testcase dir in {tempdir}")
114120
# --warn-unused-ignores doesn't work for files inside typeshed.
115121
# SO, to work around this, we copy the test_cases directory into a TemporaryDirectory,
116122
# and run the test cases inside of that.
117-
shutil.copytree(package.test_case_directory, new_test_case_dir)
123+
shutil.copytree(package.test_case_directory, tempdir / TEST_CASES)
118124
if package.is_stdlib:
119125
return
120126

@@ -137,16 +143,15 @@ def setup_testcase_dir(package: PackageInfo, tempdir: Path, new_test_case_dir: P
137143
shutil.copytree(Path("stubs", requirement), new_typeshed / "stubs" / requirement)
138144

139145
if requirements.external_pkgs:
140-
if verbosity is Verbosity.VERBOSE:
141-
verbose_log(f"Setting up venv in {tempdir / VENV_DIR}")
142146
pip_exe = make_venv(tempdir / VENV_DIR).pip_exe
143-
pip_command = [pip_exe, "install", get_mypy_req(), *requirements.external_pkgs]
147+
# Use --no-cache-dir to avoid issues with concurrent read/writes to the cache
148+
pip_command = [pip_exe, "install", get_mypy_req(), *requirements.external_pkgs, "--no-cache-dir"]
144149
if verbosity is Verbosity.VERBOSE:
145-
verbose_log(f"{pip_command=}")
150+
verbose_log(f"{package.name}: Setting up venv in {tempdir / VENV_DIR}. {pip_command=}\n")
146151
try:
147152
subprocess.run(pip_command, check=True, capture_output=True, text=True)
148153
except subprocess.CalledProcessError as e:
149-
print(e.stderr)
154+
_PRINT_QUEUE.put(f"{package.name}\n{e.stderr}")
150155
raise
151156

152157

@@ -155,10 +160,6 @@ def run_testcases(
155160
) -> subprocess.CompletedProcess[str]:
156161
env_vars = dict(os.environ)
157162
new_test_case_dir = tempdir / TEST_CASES
158-
testcasedir_already_setup = new_test_case_dir.exists() and new_test_case_dir.is_dir()
159-
160-
if not testcasedir_already_setup:
161-
setup_testcase_dir(package, tempdir=tempdir, new_test_case_dir=new_test_case_dir, verbosity=verbosity)
162163

163164
# "--enable-error-code ignore-without-code" is purposefully omitted.
164165
# See https://github.com/python/typeshed/pull/8083
@@ -202,39 +203,103 @@ def run_testcases(
202203

203204
mypy_command = [python_exe, "-m", "mypy"] + flags
204205
if verbosity is Verbosity.VERBOSE:
205-
verbose_log(f"{mypy_command=}")
206+
description = f"{package.name}/{version}/{platform}"
207+
msg = f"{description}: {mypy_command=}\n"
206208
if "MYPYPATH" in env_vars:
207-
verbose_log(f"{env_vars['MYPYPATH']=}")
209+
msg += f"{description}: {env_vars['MYPYPATH']=}"
208210
else:
209-
verbose_log("MYPYPATH not set")
211+
msg += f"{description}: MYPYPATH not set"
212+
msg += "\n"
213+
verbose_log(msg)
210214
return subprocess.run(mypy_command, capture_output=True, text=True, env=env_vars)
211215

212216

213-
def test_testcase_directory(
214-
package: PackageInfo, version: str, platform: str, *, verbosity: Verbosity, tempdir: Path
215-
) -> ReturnCode:
216-
msg = f"Running mypy --platform {platform} --python-version {version} on the "
217-
msg += "standard library test cases..." if package.is_stdlib else f"test cases for {package.name!r}..."
217+
@dataclass(frozen=True)
218+
class Result:
219+
code: int
220+
command_run: str
221+
stderr: str
222+
stdout: str
223+
test_case_dir: Path
224+
tempdir: Path
225+
226+
def print_description(self, *, verbosity: Verbosity) -> None:
227+
if self.code:
228+
print(f"{self.command_run}:", end=" ")
229+
print_error("FAILURE\n")
230+
replacements = (str(self.tempdir / TEST_CASES), str(self.test_case_dir))
231+
if self.stderr:
232+
print_error(self.stderr, fix_path=replacements)
233+
if self.stdout:
234+
print_error(self.stdout, fix_path=replacements)
235+
236+
237+
def test_testcase_directory(package: PackageInfo, version: str, platform: str, *, verbosity: Verbosity, tempdir: Path) -> Result:
238+
msg = f"mypy --platform {platform} --python-version {version} on the "
239+
msg += "standard library test cases" if package.is_stdlib else f"test cases for {package.name!r}"
218240
if verbosity > Verbosity.QUIET:
219-
print(msg, end=" ", flush=True)
220-
221-
result = run_testcases(package=package, version=version, platform=platform, tempdir=tempdir, verbosity=verbosity)
222-
223-
if result.returncode:
224-
if verbosity is Verbosity.QUIET:
225-
# We'll already have printed this if --verbosity QUIET wasn't passed.
226-
# If --verbosity QUIET was passed, only print this if there were errors.
227-
# If there are errors, the output is inscrutable if this isn't printed.
228-
print(msg, end=" ")
229-
print_error("failure\n")
230-
replacements = (str(tempdir / TEST_CASES), str(package.test_case_directory))
231-
if result.stderr:
232-
print_error(result.stderr, fix_path=replacements)
233-
if result.stdout:
234-
print_error(result.stdout, fix_path=replacements)
235-
elif verbosity > Verbosity.QUIET:
236-
print_success_msg()
237-
return result.returncode
241+
_PRINT_QUEUE.put(f"Running {msg}...")
242+
243+
proc_info = run_testcases(package=package, version=version, platform=platform, tempdir=tempdir, verbosity=verbosity)
244+
return Result(
245+
code=proc_info.returncode,
246+
command_run=msg,
247+
stderr=proc_info.stderr,
248+
stdout=proc_info.stdout,
249+
test_case_dir=package.test_case_directory,
250+
tempdir=tempdir,
251+
)
252+
253+
254+
def print_queued_messages(ev: threading.Event) -> None:
255+
while not ev.is_set():
256+
with suppress(queue.Empty):
257+
print(_PRINT_QUEUE.get(timeout=0.5), flush=True)
258+
while True:
259+
try:
260+
msg = _PRINT_QUEUE.get_nowait()
261+
except queue.Empty:
262+
return
263+
else:
264+
print(msg, flush=True)
265+
266+
267+
def concurrently_run_testcases(
268+
stack: ExitStack,
269+
testcase_directories: list[PackageInfo],
270+
verbosity: Verbosity,
271+
platforms_to_test: list[str],
272+
versions_to_test: list[str],
273+
) -> list[Result]:
274+
packageinfo_to_tempdir = {
275+
package_info: Path(stack.enter_context(tempfile.TemporaryDirectory())) for package_info in testcase_directories
276+
}
277+
278+
event = threading.Event()
279+
printer_thread = threading.Thread(target=print_queued_messages, args=(event,))
280+
printer_thread.start()
281+
282+
with concurrent.futures.ThreadPoolExecutor(max_workers=os.cpu_count()) as executor:
283+
# Each temporary directory may be used by multiple processes concurrently during the next step;
284+
# must make sure that they're all setup correctly before starting the next step,
285+
# in order to avoid race conditions
286+
testcase_futures = [
287+
executor.submit(setup_testcase_dir, package, tempdir, verbosity)
288+
for package, tempdir in packageinfo_to_tempdir.items()
289+
]
290+
concurrent.futures.wait(testcase_futures)
291+
292+
mypy_futures = [
293+
executor.submit(test_testcase_directory, testcase_dir, version, platform, verbosity=verbosity, tempdir=tempdir)
294+
for (testcase_dir, tempdir), platform, version in product(
295+
packageinfo_to_tempdir.items(), platforms_to_test, versions_to_test
296+
)
297+
]
298+
results = [future.result() for future in mypy_futures]
299+
300+
event.set()
301+
printer_thread.join()
302+
return results
238303

239304

240305
def main() -> ReturnCode:
@@ -253,16 +318,23 @@ def main() -> ReturnCode:
253318
versions_to_test = args.versions_to_test or [f"3.{sys.version_info[1]}"]
254319

255320
code = 0
256-
for testcase_dir in testcase_directories:
257-
with tempfile.TemporaryDirectory() as td:
258-
tempdir = Path(td)
259-
for platform, version in product(platforms_to_test, versions_to_test):
260-
this_code = test_testcase_directory(testcase_dir, version, platform, verbosity=verbosity, tempdir=tempdir)
261-
code = max(code, this_code)
321+
results: list[Result] | None = None
322+
323+
with ExitStack() as stack:
324+
results = concurrently_run_testcases(stack, testcase_directories, verbosity, platforms_to_test, versions_to_test)
325+
326+
assert results is not None
327+
print()
328+
329+
for result in results:
330+
result.print_description(verbosity=verbosity)
331+
332+
code = max(result.code for result in results)
333+
262334
if code:
263-
print_error("\nTest completed with errors")
335+
print_error("Test completed with errors")
264336
else:
265-
print(colored("\nTest completed successfully!", "green"))
337+
print(colored("Test completed successfully!", "green"))
266338

267339
return code
268340

0 commit comments

Comments
 (0)