1*760c253cSXin Li#!/usr/bin/env python3 2*760c253cSXin Li# Copyright 2019 The ChromiumOS Authors 3*760c253cSXin Li# Use of this source code is governed by a BSD-style license that can be 4*760c253cSXin Li# found in the LICENSE file. 5*760c253cSXin Li 6*760c253cSXin Li"""Runs tests for the given input files. 7*760c253cSXin Li 8*760c253cSXin LiTries its best to autodetect all tests based on path name without being *too* 9*760c253cSXin Liaggressive. 10*760c253cSXin Li 11*760c253cSXin LiIn short, there's a small set of directories in which, if you make any change, 12*760c253cSXin Liall of the tests in those directories get run. Additionally, if you change a 13*760c253cSXin Lipython file named foo, it'll run foo_test.py or foo_unittest.py if either of 14*760c253cSXin Lithose exist. 15*760c253cSXin Li 16*760c253cSXin LiAll tests are run in parallel. 17*760c253cSXin Li""" 18*760c253cSXin Li 19*760c253cSXin Li# NOTE: An alternative mentioned on the initial CL for this 20*760c253cSXin Li# https://chromium-review.googlesource.com/c/chromiumos/third_party/toolchain-utils/+/1516414 21*760c253cSXin Li# is pytest. It looks like that brings some complexity (and makes use outside 22*760c253cSXin Li# of the chroot a bit more obnoxious?), but might be worth exploring if this 23*760c253cSXin Li# starts to grow quite complex on its own. 24*760c253cSXin Li 25*760c253cSXin Li 26*760c253cSXin Liimport argparse 27*760c253cSXin Liimport collections 28*760c253cSXin Liimport multiprocessing.pool 29*760c253cSXin Liimport os 30*760c253cSXin Liimport shlex 31*760c253cSXin Liimport signal 32*760c253cSXin Liimport subprocess 33*760c253cSXin Liimport sys 34*760c253cSXin Lifrom typing import Optional, Tuple 35*760c253cSXin Li 36*760c253cSXin Li 37*760c253cSXin LiTestSpec = collections.namedtuple("TestSpec", ["directory", "command"]) 38*760c253cSXin Li 39*760c253cSXin Li# List of python scripts that are not test with relative path to 40*760c253cSXin Li# toolchain-utils. 41*760c253cSXin Linon_test_py_files = { 42*760c253cSXin Li "debug_info_test/debug_info_test.py", 43*760c253cSXin Li} 44*760c253cSXin Li 45*760c253cSXin Li 46*760c253cSXin Lidef _make_relative_to_toolchain_utils(toolchain_utils, path): 47*760c253cSXin Li """Cleans & makes a path relative to toolchain_utils. 48*760c253cSXin Li 49*760c253cSXin Li Raises if that path isn't under toolchain_utils. 50*760c253cSXin Li """ 51*760c253cSXin Li # abspath has the nice property that it removes any markers like './'. 52*760c253cSXin Li as_abs = os.path.abspath(path) 53*760c253cSXin Li result = os.path.relpath(as_abs, start=toolchain_utils) 54*760c253cSXin Li 55*760c253cSXin Li if result.startswith("../"): 56*760c253cSXin Li raise ValueError("Non toolchain-utils directory found: %s" % result) 57*760c253cSXin Li return result 58*760c253cSXin Li 59*760c253cSXin Li 60*760c253cSXin Lidef _filter_python_tests(test_files, toolchain_utils): 61*760c253cSXin Li """Returns all files that are real python tests.""" 62*760c253cSXin Li python_tests = [] 63*760c253cSXin Li for test_file in test_files: 64*760c253cSXin Li rel_path = _make_relative_to_toolchain_utils(toolchain_utils, test_file) 65*760c253cSXin Li if rel_path not in non_test_py_files: 66*760c253cSXin Li python_tests.append(_python_test_to_spec(test_file)) 67*760c253cSXin Li else: 68*760c253cSXin Li print("## %s ... NON_TEST_PY_FILE" % rel_path) 69*760c253cSXin Li return python_tests 70*760c253cSXin Li 71*760c253cSXin Li 72*760c253cSXin Lidef _gather_python_tests_in(rel_subdir, toolchain_utils): 73*760c253cSXin Li """Returns all files that appear to be Python tests in a given directory.""" 74*760c253cSXin Li subdir = os.path.join(toolchain_utils, rel_subdir) 75*760c253cSXin Li test_files = ( 76*760c253cSXin Li os.path.join(subdir, file_name) 77*760c253cSXin Li for file_name in os.listdir(subdir) 78*760c253cSXin Li if file_name.endswith("_test.py") or file_name.endswith("_unittest.py") 79*760c253cSXin Li ) 80*760c253cSXin Li return _filter_python_tests(test_files, toolchain_utils) 81*760c253cSXin Li 82*760c253cSXin Li 83*760c253cSXin Lidef _run_test(test_spec: TestSpec, timeout: int) -> Tuple[Optional[int], str]: 84*760c253cSXin Li """Runs a test. 85*760c253cSXin Li 86*760c253cSXin Li Returns a tuple indicating the process' exit code, and the combined 87*760c253cSXin Li stdout+stderr of the process. If the exit code is None, the process timed 88*760c253cSXin Li out. 89*760c253cSXin Li """ 90*760c253cSXin Li # Each subprocess gets its own process group, since many of these tests 91*760c253cSXin Li # spawn subprocesses for a variety of reasons. If these tests time out, we 92*760c253cSXin Li # want to be able to clean up all of the children swiftly. 93*760c253cSXin Li # pylint: disable=subprocess-popen-preexec-fn 94*760c253cSXin Li with subprocess.Popen( 95*760c253cSXin Li test_spec.command, 96*760c253cSXin Li cwd=test_spec.directory, 97*760c253cSXin Li stdin=subprocess.DEVNULL, 98*760c253cSXin Li stdout=subprocess.PIPE, 99*760c253cSXin Li stderr=subprocess.STDOUT, 100*760c253cSXin Li encoding="utf-8", 101*760c253cSXin Li # TODO(b/296616854): This is unsafe, and we should use 102*760c253cSXin Li # process_group=0 when we have upgraded to Python 3.11. 103*760c253cSXin Li preexec_fn=lambda: os.setpgid(0, 0), 104*760c253cSXin Li ) as p: 105*760c253cSXin Li child_pgid = p.pid 106*760c253cSXin Li try: 107*760c253cSXin Li out, _ = p.communicate(timeout=timeout) 108*760c253cSXin Li return p.returncode, out 109*760c253cSXin Li except BaseException as e: 110*760c253cSXin Li # Try to shut the processes down gracefully. 111*760c253cSXin Li os.killpg(child_pgid, signal.SIGINT) 112*760c253cSXin Li try: 113*760c253cSXin Li # 2 seconds is arbitrary, but given that these are unittests, 114*760c253cSXin Li # should be plenty of time for them to shut down. 115*760c253cSXin Li p.wait(timeout=2) 116*760c253cSXin Li except subprocess.TimeoutExpired: 117*760c253cSXin Li os.killpg(child_pgid, signal.SIGKILL) 118*760c253cSXin Li except: 119*760c253cSXin Li os.killpg(child_pgid, signal.SIGKILL) 120*760c253cSXin Li raise 121*760c253cSXin Li 122*760c253cSXin Li if isinstance(e, subprocess.TimeoutExpired): 123*760c253cSXin Li # We just killed the entire process group. This should complete 124*760c253cSXin Li # ~immediately. If it doesn't, something is very wrong. 125*760c253cSXin Li out, _ = p.communicate(timeout=5) 126*760c253cSXin Li return (None, out) 127*760c253cSXin Li raise 128*760c253cSXin Li 129*760c253cSXin Li 130*760c253cSXin Lidef _python_test_to_spec(test_file): 131*760c253cSXin Li """Given a .py file, convert it to a TestSpec.""" 132*760c253cSXin Li # Run tests in the directory they exist in, since some of them are sensitive 133*760c253cSXin Li # to that. 134*760c253cSXin Li test_directory = os.path.dirname(os.path.abspath(test_file)) 135*760c253cSXin Li file_name = os.path.basename(test_file) 136*760c253cSXin Li 137*760c253cSXin Li if os.access(test_file, os.X_OK): 138*760c253cSXin Li command = ["./" + file_name] 139*760c253cSXin Li else: 140*760c253cSXin Li # Assume the user wanted py3. 141*760c253cSXin Li command = ["python3", file_name] 142*760c253cSXin Li 143*760c253cSXin Li return TestSpec(directory=test_directory, command=command) 144*760c253cSXin Li 145*760c253cSXin Li 146*760c253cSXin Lidef _autodetect_python_tests_for(test_file, toolchain_utils): 147*760c253cSXin Li """Given a test file, detect if there may be related tests.""" 148*760c253cSXin Li if not test_file.endswith(".py"): 149*760c253cSXin Li return [] 150*760c253cSXin Li 151*760c253cSXin Li test_prefixes = ("test_", "unittest_") 152*760c253cSXin Li test_suffixes = ("_test.py", "_unittest.py") 153*760c253cSXin Li 154*760c253cSXin Li test_file_name = os.path.basename(test_file) 155*760c253cSXin Li test_file_is_a_test = any( 156*760c253cSXin Li test_file_name.startswith(x) for x in test_prefixes 157*760c253cSXin Li ) or any(test_file_name.endswith(x) for x in test_suffixes) 158*760c253cSXin Li 159*760c253cSXin Li if test_file_is_a_test: 160*760c253cSXin Li test_files = [test_file] 161*760c253cSXin Li else: 162*760c253cSXin Li test_file_no_suffix = test_file[:-3] 163*760c253cSXin Li candidates = [test_file_no_suffix + x for x in test_suffixes] 164*760c253cSXin Li 165*760c253cSXin Li dir_name = os.path.dirname(test_file) 166*760c253cSXin Li candidates += ( 167*760c253cSXin Li os.path.join(dir_name, x + test_file_name) for x in test_prefixes 168*760c253cSXin Li ) 169*760c253cSXin Li test_files = (x for x in candidates if os.path.exists(x)) 170*760c253cSXin Li return _filter_python_tests(test_files, toolchain_utils) 171*760c253cSXin Li 172*760c253cSXin Li 173*760c253cSXin Lidef _run_test_scripts(pool, all_tests, timeout, show_successful_output=False): 174*760c253cSXin Li """Runs a list of TestSpecs. Returns whether all of them succeeded.""" 175*760c253cSXin Li results = [ 176*760c253cSXin Li pool.apply_async(_run_test, (test, timeout)) for test in all_tests 177*760c253cSXin Li ] 178*760c253cSXin Li 179*760c253cSXin Li failures = [] 180*760c253cSXin Li for i, (test, future) in enumerate(zip(all_tests, results)): 181*760c253cSXin Li # Add a bit more spacing between outputs. 182*760c253cSXin Li if show_successful_output and i: 183*760c253cSXin Li print("\n") 184*760c253cSXin Li 185*760c253cSXin Li pretty_test = shlex.join(test.command) 186*760c253cSXin Li pretty_directory = os.path.relpath(test.directory) 187*760c253cSXin Li if pretty_directory == ".": 188*760c253cSXin Li test_message = pretty_test 189*760c253cSXin Li else: 190*760c253cSXin Li test_message = "%s in %s/" % (pretty_test, pretty_directory) 191*760c253cSXin Li 192*760c253cSXin Li print("## %s ... " % test_message, end="") 193*760c253cSXin Li # Be sure that the users sees which test is running. 194*760c253cSXin Li sys.stdout.flush() 195*760c253cSXin Li 196*760c253cSXin Li exit_code, stdout = future.get() 197*760c253cSXin Li if exit_code == 0: 198*760c253cSXin Li print("PASS") 199*760c253cSXin Li is_failure = False 200*760c253cSXin Li else: 201*760c253cSXin Li print("TIMEOUT" if exit_code is None else "FAIL") 202*760c253cSXin Li failures.append(test_message) 203*760c253cSXin Li is_failure = True 204*760c253cSXin Li 205*760c253cSXin Li if show_successful_output or is_failure: 206*760c253cSXin Li if stdout: 207*760c253cSXin Li print("-- Stdout:\n", stdout) 208*760c253cSXin Li else: 209*760c253cSXin Li print("-- No stdout was produced.") 210*760c253cSXin Li 211*760c253cSXin Li if failures: 212*760c253cSXin Li word = "tests" if len(failures) > 1 else "test" 213*760c253cSXin Li print(f"{len(failures)} {word} failed:") 214*760c253cSXin Li for failure in failures: 215*760c253cSXin Li print(f"\t{failure}") 216*760c253cSXin Li 217*760c253cSXin Li return not failures 218*760c253cSXin Li 219*760c253cSXin Li 220*760c253cSXin Lidef _compress_list(l): 221*760c253cSXin Li """Removes consecutive duplicate elements from |l|. 222*760c253cSXin Li 223*760c253cSXin Li >>> _compress_list([]) 224*760c253cSXin Li [] 225*760c253cSXin Li >>> _compress_list([1, 1]) 226*760c253cSXin Li [1] 227*760c253cSXin Li >>> _compress_list([1, 2, 1]) 228*760c253cSXin Li [1, 2, 1] 229*760c253cSXin Li """ 230*760c253cSXin Li result = [] 231*760c253cSXin Li for e in l: 232*760c253cSXin Li if result and result[-1] == e: 233*760c253cSXin Li continue 234*760c253cSXin Li result.append(e) 235*760c253cSXin Li return result 236*760c253cSXin Li 237*760c253cSXin Li 238*760c253cSXin Lidef _fix_python_path(toolchain_utils): 239*760c253cSXin Li pypath = os.environ.get("PYTHONPATH", "") 240*760c253cSXin Li if pypath: 241*760c253cSXin Li pypath = ":" + pypath 242*760c253cSXin Li os.environ["PYTHONPATH"] = toolchain_utils + pypath 243*760c253cSXin Li 244*760c253cSXin Li 245*760c253cSXin Lidef _find_forced_subdir_python_tests(test_paths, toolchain_utils): 246*760c253cSXin Li assert all(os.path.isabs(path) for path in test_paths) 247*760c253cSXin Li 248*760c253cSXin Li # Directories under toolchain_utils for which any change will cause all 249*760c253cSXin Li # tests in that directory to be rerun. Includes changes in subdirectories. 250*760c253cSXin Li all_dirs = { 251*760c253cSXin Li "crosperf", 252*760c253cSXin Li "cros_utils", 253*760c253cSXin Li } 254*760c253cSXin Li 255*760c253cSXin Li relative_paths = [ 256*760c253cSXin Li _make_relative_to_toolchain_utils(toolchain_utils, path) 257*760c253cSXin Li for path in test_paths 258*760c253cSXin Li ] 259*760c253cSXin Li 260*760c253cSXin Li gather_test_dirs = set() 261*760c253cSXin Li 262*760c253cSXin Li for path in relative_paths: 263*760c253cSXin Li top_level_dir = path.split("/")[0] 264*760c253cSXin Li if top_level_dir in all_dirs: 265*760c253cSXin Li gather_test_dirs.add(top_level_dir) 266*760c253cSXin Li 267*760c253cSXin Li results = [] 268*760c253cSXin Li for d in sorted(gather_test_dirs): 269*760c253cSXin Li results += _gather_python_tests_in(d, toolchain_utils) 270*760c253cSXin Li return results 271*760c253cSXin Li 272*760c253cSXin Li 273*760c253cSXin Lidef _find_go_tests(test_paths): 274*760c253cSXin Li """Returns TestSpecs for the go folders of the given files""" 275*760c253cSXin Li assert all(os.path.isabs(path) for path in test_paths) 276*760c253cSXin Li 277*760c253cSXin Li dirs_with_gofiles = set( 278*760c253cSXin Li os.path.dirname(p) for p in test_paths if p.endswith(".go") 279*760c253cSXin Li ) 280*760c253cSXin Li command = ["go", "test", "-vet=all"] 281*760c253cSXin Li # Note: We sort the directories to be deterministic. 282*760c253cSXin Li return [ 283*760c253cSXin Li TestSpec(directory=d, command=command) 284*760c253cSXin Li for d in sorted(dirs_with_gofiles) 285*760c253cSXin Li ] 286*760c253cSXin Li 287*760c253cSXin Li 288*760c253cSXin Lidef main(argv): 289*760c253cSXin Li default_toolchain_utils = os.path.abspath(os.path.dirname(__file__)) 290*760c253cSXin Li 291*760c253cSXin Li parser = argparse.ArgumentParser(description=__doc__) 292*760c253cSXin Li parser.add_argument( 293*760c253cSXin Li "--show_all_output", 294*760c253cSXin Li action="store_true", 295*760c253cSXin Li help="show stdout of successful tests", 296*760c253cSXin Li ) 297*760c253cSXin Li parser.add_argument( 298*760c253cSXin Li "--toolchain_utils", 299*760c253cSXin Li default=default_toolchain_utils, 300*760c253cSXin Li help="directory of toolchain-utils. Often auto-detected", 301*760c253cSXin Li ) 302*760c253cSXin Li parser.add_argument( 303*760c253cSXin Li "file", nargs="*", help="a file that we should run tests for" 304*760c253cSXin Li ) 305*760c253cSXin Li parser.add_argument( 306*760c253cSXin Li "--timeout", 307*760c253cSXin Li default=120, 308*760c253cSXin Li type=int, 309*760c253cSXin Li help="Time to allow a test to execute before timing it out, in " 310*760c253cSXin Li "seconds.", 311*760c253cSXin Li ) 312*760c253cSXin Li args = parser.parse_args(argv) 313*760c253cSXin Li 314*760c253cSXin Li modified_files = [os.path.abspath(f) for f in args.file] 315*760c253cSXin Li show_all_output = args.show_all_output 316*760c253cSXin Li toolchain_utils = args.toolchain_utils 317*760c253cSXin Li 318*760c253cSXin Li if not modified_files: 319*760c253cSXin Li print("No files given. Exit.") 320*760c253cSXin Li return 0 321*760c253cSXin Li 322*760c253cSXin Li _fix_python_path(toolchain_utils) 323*760c253cSXin Li 324*760c253cSXin Li tests_to_run = _find_forced_subdir_python_tests( 325*760c253cSXin Li modified_files, toolchain_utils 326*760c253cSXin Li ) 327*760c253cSXin Li for f in modified_files: 328*760c253cSXin Li tests_to_run += _autodetect_python_tests_for(f, toolchain_utils) 329*760c253cSXin Li tests_to_run += _find_go_tests(modified_files) 330*760c253cSXin Li 331*760c253cSXin Li # TestSpecs have lists, so we can't use a set. We'd likely want to keep them 332*760c253cSXin Li # sorted for determinism anyway. 333*760c253cSXin Li tests_to_run.sort() 334*760c253cSXin Li tests_to_run = _compress_list(tests_to_run) 335*760c253cSXin Li 336*760c253cSXin Li with multiprocessing.pool.ThreadPool() as pool: 337*760c253cSXin Li success = _run_test_scripts( 338*760c253cSXin Li pool, tests_to_run, args.timeout, show_all_output 339*760c253cSXin Li ) 340*760c253cSXin Li return 0 if success else 1 341*760c253cSXin Li 342*760c253cSXin Li 343*760c253cSXin Liif __name__ == "__main__": 344*760c253cSXin Li sys.exit(main(sys.argv[1:])) 345