99import site
1010import subprocess
1111import sys
12+ import time
13+ from concurrent .futures import Future
14+ from concurrent .futures import ProcessPoolExecutor
15+ from concurrent .futures import as_completed
16+ from dataclasses import dataclass
1217from pathlib import Path
1318from typing import TYPE_CHECKING
1419from typing import Any
@@ -31,6 +36,14 @@ def canary_addcommand(parser: "Parser") -> None:
3136stderr : Any = subprocess .PIPE
3237
3338logger = logging .get_logger (__name__ )
39+ test_paths = (
40+ "tests" ,
41+ "src/canary_cmake/tests" ,
42+ "src/canary_gitlab/tests" ,
43+ "src/canary_hpc/tests" ,
44+ "src/canary_pyt/tests" ,
45+ "src/canary_vvtest/tests" ,
46+ )
3447
3548
3649class Action (argparse .Action ):
@@ -54,8 +67,8 @@ def setup_parser(self, parser: "Parser") -> None:
5467 parser .add_argument (
5568 "-b" , nargs = 0 , action = Action , help = "run bandit security checks (default)"
5669 )
57- parser .add_argument ("-t" , nargs = 0 , action = Action , help = "run pytest" )
58- parser .add_argument ("-C" , nargs = 0 , action = Action , help = "run coverage (default) " )
70+ parser .add_argument ("-t" , nargs = 0 , action = Action , help = "run pytest (default) " )
71+ parser .add_argument ("-C" , nargs = 0 , action = Action , help = "run coverage" )
5972 parser .add_argument ("-e" , nargs = 0 , action = Action , help = "run examples test" )
6073 parser .add_argument ("-d" , nargs = 0 , action = Action , help = "make docs" )
6174 parser .add_argument ("-v" , action = "store_true" , help = "verbose" )
@@ -72,7 +85,7 @@ def execute(self, args: argparse.Namespace) -> int:
7285 raise ValueError ("canary check must be run from a editable install of canary" )
7386 self .root = os .path .normpath (str (root ))
7487 if not hasattr (args , "action" ):
75- args .action = set ("fcmbC " )
88+ args .action = set ("fcmbt " )
7689 if shutil .which ("ruff" ) is None and "f" in args .action :
7790 raise ValueError ("ruff must be on PATH to format and check code" )
7891 if shutil .which ("ruff" ) is None and "c" in args .action :
@@ -179,24 +192,20 @@ def run_tests(self, args: argparse.Namespace):
179192 if "e" in args .action :
180193 os .environ ["CANARY_RUN_EXAMPLES_TEST" ] = "1"
181194 with working_dir (self .root ):
182- if "C" not in args .action :
183- pm = logger .progress_monitor (f"Running tests in { self .root } /tests" )
184- pytest ("./tests" )
185- pm .done ()
186- pm = logger .progress_monitor (f"Running tests in { self .root } /canary_pyt/tests" )
187- pytest ("./src/canary_pyt/tests" )
188- pm .done ()
189- pm = logger .progress_monitor (f"Running tests in { self .root } /canary_cmake/tests" )
190- pytest ("./src/canary_cmake/tests" )
191- pm .done ()
192- pm = logger .progress_monitor (f"Running tests in { self .root } /canary_hpc/tests" )
193- pytest ("./src/canary_hpc/tests" )
194- pm .done ()
195- pm = logger .progress_monitor (f"Running tests in { self .root } /canary_vvtest/tests" )
196- pytest ("./src/canary_vvtest/tests" )
197- pm .done ()
195+ if "t" in args .action :
196+ results = run_pytests_parallel (Path (self .root ), test_paths )
197+ failed = [r for r in results if not r .ok ]
198+ if failed :
199+ for r in failed :
200+ if r .stdout :
201+ sys .stdout .write (r .stdout )
202+ if r .stderr :
203+ sys .stderr .write (r .stderr )
204+ raise ValueError (
205+ f"{ len (failed )} pytest runs failed: { ', ' .join (r .path for r in failed )} "
206+ )
198207 else :
199- pm = logger .progress_monitor (f"Running coverage in { self .root } /tests " )
208+ pm = logger .progress_monitor (f"Running coverage in { self .root } " )
200209 coverage ("run" )
201210 pm .done ()
202211 pm = logger .progress_monitor ("Creating coverage report" )
@@ -302,6 +311,56 @@ def pytest(*args: str, **kwargs: Any) -> subprocess.CompletedProcess:
302311 return cp
303312
304313
314+ @dataclass (frozen = True )
315+ class PytestResult :
316+ path : str
317+ returncode : int
318+ stdout : str
319+ stderr : str
320+ elapsed_s : float
321+
322+ @property
323+ def ok (self ) -> bool :
324+ return self .returncode == 0
325+
326+
327+ def run_pytest_one (root : str , relpath : str , pytest_args : tuple [str , ...] = ()) -> PytestResult :
328+ # Runs in a worker process
329+ t0 = time .time ()
330+ command = ["pytest" , relpath , * pytest_args ]
331+ cp = subprocess .run (command , cwd = root , stdout = stdout , stderr = stderr , encoding = "utf-8" )
332+ return PytestResult (
333+ path = relpath ,
334+ returncode = cp .returncode ,
335+ stdout = cp .stdout or "" ,
336+ stderr = cp .stderr or "" ,
337+ elapsed_s = time .time () - t0 ,
338+ )
339+
340+
341+ def run_pytests_parallel (
342+ root : Path ,
343+ test_paths : tuple [str , ...],
344+ * ,
345+ max_workers : int | None = None ,
346+ pytest_args : tuple [str , ...] = (),
347+ ) -> list [PytestResult ]:
348+ results : list [PytestResult ] = []
349+
350+ with ProcessPoolExecutor (max_workers = max_workers or os .cpu_count ()) as ex :
351+ futures : dict [Future , str ] = {}
352+ for p in test_paths :
353+ logger .info (f"Submitting tests in { p } to pytest" )
354+ fut = ex .submit (run_pytest_one , str (root ), str (p ), pytest_args )
355+ futures [fut ] = str (p )
356+ for fut in as_completed (futures ):
357+ res = fut .result ()
358+ results .append (res )
359+ # Print on completion (no interleaving during run)
360+ logger .info (f"pytest finished: { res .path } ({ res .elapsed_s :.1f} s) rc={ res .returncode } " )
361+ return results
362+
363+
305364def coverage (* args : str , ** kwargs : Any ) -> subprocess .CompletedProcess :
306365 kwargs ["stdout" ] = stdout
307366 kwargs ["stderr" ] = stderr
0 commit comments