1#!/usr/bin/env python3 2# -*- coding: utf-8 -*- 3# 4# Copyright 2019 The Chromium OS Authors. All rights reserved. 5# Use of this source code is governed by a BSD-style license that can be 6# found in the LICENSE file. 7 8"""Runs presubmit checks against a bundle of files.""" 9 10import argparse 11import datetime 12import multiprocessing 13import multiprocessing.pool 14import os 15import re 16import shlex 17import shutil 18import subprocess 19import sys 20import threading 21import traceback 22import typing as t 23 24 25def run_command_unchecked(command: t.List[str], 26 cwd: str, 27 env: t.Dict[str, str] = None) -> t.Tuple[int, str]: 28 """Runs a command in the given dir, returning its exit code and stdio.""" 29 p = subprocess.Popen( 30 command, 31 cwd=cwd, 32 stdin=subprocess.DEVNULL, 33 stdout=subprocess.PIPE, 34 stderr=subprocess.STDOUT, 35 env=env, 36 ) 37 38 stdout, _ = p.communicate() 39 exit_code = p.wait() 40 return exit_code, stdout.decode('utf-8', 'replace') 41 42 43def has_executable_on_path(exe: str) -> bool: 44 """Returns whether we have `exe` somewhere on our $PATH""" 45 return shutil.which(exe) is not None 46 47 48def escape_command(command: t.Iterable[str]) -> str: 49 """Returns a human-readable and copy-pastable shell command. 50 51 Only intended for use in output to users. shell=True is strongly discouraged. 52 """ 53 return ' '.join(shlex.quote(x) for x in command) 54 55 56def remove_deleted_files(files: t.Iterable[str]) -> t.List[str]: 57 return [f for f in files if os.path.exists(f)] 58 59 60def is_file_executable(file_path: str) -> bool: 61 return os.access(file_path, os.X_OK) 62 63 64# As noted in our docs, some of our Python code depends on modules that sit in 65# toolchain-utils/. Add that to PYTHONPATH to ensure that things like `cros 66# lint` are kept happy. 67def env_with_pythonpath(toolchain_utils_root: str) -> t.Dict[str, str]: 68 env = dict(os.environ) 69 if 'PYTHONPATH' in env: 70 env['PYTHONPATH'] += ':' + toolchain_utils_root 71 else: 72 env['PYTHONPATH'] = toolchain_utils_root 73 return env 74 75 76# Each checker represents an independent check that's done on our sources. 77# 78# They should: 79# - never write to stdout/stderr or read from stdin directly 80# - return either a CheckResult, or a list of [(subcheck_name, CheckResult)] 81# - ideally use thread_pool to check things concurrently 82# - though it's important to note that these *also* live on the threadpool 83# we've provided. It's the caller's responsibility to guarantee that at 84# least ${number_of_concurrently_running_checkers}+1 threads are present 85# in the pool. In order words, blocking on results from the provided 86# threadpool is OK. 87CheckResult = t.NamedTuple( 88 'CheckResult', 89 ( 90 ('ok', bool), 91 ('output', str), 92 ('autofix_commands', t.List[t.List[str]]), 93 ), 94) 95 96 97def get_check_result_or_catch( 98 task: multiprocessing.pool.ApplyResult) -> CheckResult: 99 """Returns the result of task(); if that raises, returns a CheckResult. 100 101 The task is expected to return a CheckResult on get(). 102 """ 103 try: 104 return task.get() 105 except Exception: 106 return CheckResult( 107 ok=False, 108 output='Check exited with an unexpected exception:\n%s' % 109 traceback.format_exc(), 110 autofix_commands=[], 111 ) 112 113 114def check_yapf(toolchain_utils_root: str, 115 python_files: t.Iterable[str]) -> CheckResult: 116 """Subchecker of check_py_format. Checks python file formats with yapf""" 117 command = ['yapf', '-d'] + python_files 118 exit_code, stdout_and_stderr = run_command_unchecked( 119 command, cwd=toolchain_utils_root) 120 121 # yapf fails when files are poorly formatted. 122 if exit_code == 0: 123 return CheckResult( 124 ok=True, 125 output='', 126 autofix_commands=[], 127 ) 128 129 bad_files = [] 130 bad_file_re = re.compile(r'^--- (.*)\s+\(original\)\s*$') 131 for line in stdout_and_stderr.splitlines(): 132 m = bad_file_re.match(line) 133 if not m: 134 continue 135 136 file_name, = m.groups() 137 bad_files.append(file_name.strip()) 138 139 # ... and doesn't really differentiate "your files have broken formatting" 140 # errors from general ones. So if we see nothing diffed, assume that a 141 # general error happened. 142 if not bad_files: 143 return CheckResult( 144 ok=False, 145 output='`%s` failed; stdout/stderr:\n%s' % (escape_command(command), 146 stdout_and_stderr), 147 autofix_commands=[], 148 ) 149 150 autofix = ['yapf', '-i'] + bad_files 151 return CheckResult( 152 ok=False, 153 output='The following file(s) have formatting errors: %s' % bad_files, 154 autofix_commands=[autofix], 155 ) 156 157 158def check_python_file_headers(python_files: t.Iterable[str]) -> CheckResult: 159 """Subchecker of check_py_format. Checks python #!s""" 160 add_hashbang = [] 161 remove_hashbang = [] 162 163 for python_file in python_files: 164 needs_hashbang = is_file_executable(python_file) 165 with open(python_file, encoding='utf-8') as f: 166 has_hashbang = f.read(2) == '#!' 167 if needs_hashbang == has_hashbang: 168 continue 169 170 if needs_hashbang: 171 add_hashbang.append(python_file) 172 else: 173 remove_hashbang.append(python_file) 174 175 autofix = [] 176 output = [] 177 if add_hashbang: 178 output.append( 179 'The following files have no #!, but need one: %s' % add_hashbang) 180 autofix.append(['sed', '-i', '1i#!/usr/bin/env python3'] + add_hashbang) 181 182 if remove_hashbang: 183 output.append( 184 "The following files have a #!, but shouldn't: %s" % remove_hashbang) 185 autofix.append(['sed', '-i', '1d'] + remove_hashbang) 186 187 if not output: 188 return CheckResult( 189 ok=True, 190 output='', 191 autofix_commands=[], 192 ) 193 return CheckResult( 194 ok=False, 195 output='\n'.join(output), 196 autofix_commands=autofix, 197 ) 198 199 200def check_py_format(toolchain_utils_root: str, 201 thread_pool: multiprocessing.pool.ThreadPool, 202 files: t.Iterable[str]) -> CheckResult: 203 """Runs yapf on files to check for style bugs. Also checks for #!s.""" 204 yapf = 'yapf' 205 if not has_executable_on_path(yapf): 206 return CheckResult( 207 ok=False, 208 output="yapf isn't available on your $PATH. Please either " 209 'enter a chroot, or place depot_tools on your $PATH.', 210 autofix_commands=[], 211 ) 212 213 python_files = [f for f in remove_deleted_files(files) if f.endswith('.py')] 214 if not python_files: 215 return CheckResult( 216 ok=True, 217 output='no python files to check', 218 autofix_commands=[], 219 ) 220 221 tasks = [ 222 ('check_yapf', 223 thread_pool.apply_async(check_yapf, 224 (toolchain_utils_root, python_files))), 225 ('check_file_headers', 226 thread_pool.apply_async(check_python_file_headers, (python_files,))), 227 ] 228 return [(name, get_check_result_or_catch(task)) for name, task in tasks] 229 230 231def find_chromeos_root_directory() -> t.Optional[str]: 232 return os.getenv('CHROMEOS_ROOT_DIRECTORY') 233 234 235def check_cros_lint( 236 toolchain_utils_root: str, thread_pool: multiprocessing.pool.ThreadPool, 237 files: t.Iterable[str]) -> t.Union[t.List[CheckResult], CheckResult]: 238 """Runs `cros lint`""" 239 240 fixed_env = env_with_pythonpath(toolchain_utils_root) 241 242 # We have to support users who don't have a chroot. So we either run `cros 243 # lint` (if it's been made available to us), or we try a mix of 244 # pylint+golint. 245 def try_run_cros_lint(cros_binary: str) -> t.Optional[CheckResult]: 246 exit_code, output = run_command_unchecked( 247 [cros_binary, 'lint', '--py3', '--'] + files, 248 toolchain_utils_root, 249 env=fixed_env) 250 251 # This is returned specifically if cros couldn't find the Chrome OS tree 252 # root. 253 if exit_code == 127: 254 return None 255 256 return CheckResult( 257 ok=exit_code == 0, 258 output=output, 259 autofix_commands=[], 260 ) 261 262 cros_lint = try_run_cros_lint('cros') 263 if cros_lint is not None: 264 return cros_lint 265 266 cros_root = find_chromeos_root_directory() 267 if cros_root: 268 cros_lint = try_run_cros_lint(os.path.join(cros_root, 'chromite/bin/cros')) 269 if cros_lint is not None: 270 return cros_lint 271 272 tasks = [] 273 274 def check_result_from_command(command: t.List[str]) -> CheckResult: 275 exit_code, output = run_command_unchecked( 276 command, toolchain_utils_root, env=fixed_env) 277 return CheckResult( 278 ok=exit_code == 0, 279 output=output, 280 autofix_commands=[], 281 ) 282 283 python_files = [f for f in remove_deleted_files(files) if f.endswith('.py')] 284 if python_files: 285 286 def run_pylint() -> CheckResult: 287 # pylint is required. Fail hard if it DNE. 288 return check_result_from_command(['pylint'] + python_files) 289 290 tasks.append(('pylint', thread_pool.apply_async(run_pylint))) 291 292 go_files = [f for f in remove_deleted_files(files) if f.endswith('.go')] 293 if go_files: 294 295 def run_golint() -> CheckResult: 296 if has_executable_on_path('golint'): 297 return check_result_from_command(['golint', '-set_exit_status'] + 298 go_files) 299 300 complaint = '\n'.join(( 301 'WARNING: go linting disabled. golint is not on your $PATH.', 302 'Please either enter a chroot, or install go locally. Continuing.', 303 )) 304 return CheckResult( 305 ok=True, 306 output=complaint, 307 autofix_commands=[], 308 ) 309 310 tasks.append(('golint', thread_pool.apply_async(run_golint))) 311 312 complaint = '\n'.join(( 313 'WARNING: No Chrome OS checkout detected, and no viable CrOS tree', 314 'found; falling back to linting only python and go. If you have a', 315 'Chrome OS checkout, please either develop from inside of the source', 316 'tree, or set $CHROMEOS_ROOT_DIRECTORY to the root of it.', 317 )) 318 319 results = [(name, get_check_result_or_catch(task)) for name, task in tasks] 320 if not results: 321 return CheckResult( 322 ok=True, 323 output=complaint, 324 autofix_commands=[], 325 ) 326 327 # We need to complain _somewhere_. 328 name, angry_result = results[0] 329 angry_complaint = (complaint + '\n\n' + angry_result.output).strip() 330 results[0] = (name, angry_result._replace(output=angry_complaint)) 331 return results 332 333 334def check_go_format(toolchain_utils_root, _thread_pool, files): 335 """Runs gofmt on files to check for style bugs.""" 336 gofmt = 'gofmt' 337 if not has_executable_on_path(gofmt): 338 return CheckResult( 339 ok=False, 340 output="gofmt isn't available on your $PATH. Please either " 341 'enter a chroot, or place your go bin/ directory on your $PATH.', 342 autofix_commands=[], 343 ) 344 345 go_files = [f for f in remove_deleted_files(files) if f.endswith('.go')] 346 if not go_files: 347 return CheckResult( 348 ok=True, 349 output='no go files to check', 350 autofix_commands=[], 351 ) 352 353 command = [gofmt, '-l'] + go_files 354 exit_code, output = run_command_unchecked(command, cwd=toolchain_utils_root) 355 356 if exit_code: 357 return CheckResult( 358 ok=False, 359 output='%s failed; stdout/stderr:\n%s' % (escape_command(command), 360 output), 361 autofix_commands=[], 362 ) 363 364 output = output.strip() 365 if not output: 366 return CheckResult( 367 ok=True, 368 output='', 369 autofix_commands=[], 370 ) 371 372 broken_files = [x.strip() for x in output.splitlines()] 373 autofix = [gofmt, '-w'] + broken_files 374 return CheckResult( 375 ok=False, 376 output='The following Go files have incorrect ' 377 'formatting: %s' % broken_files, 378 autofix_commands=[autofix], 379 ) 380 381 382def check_tests(toolchain_utils_root: str, 383 _thread_pool: multiprocessing.pool.ThreadPool, 384 files: t.List[str]) -> CheckResult: 385 """Runs tests.""" 386 exit_code, stdout_and_stderr = run_command_unchecked( 387 [os.path.join(toolchain_utils_root, 'run_tests_for.py'), '--'] + files, 388 toolchain_utils_root) 389 return CheckResult( 390 ok=exit_code == 0, 391 output=stdout_and_stderr, 392 autofix_commands=[], 393 ) 394 395 396def detect_toolchain_utils_root() -> str: 397 return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 398 399 400def process_check_result( 401 check_name: str, check_results: t.Union[t.List[CheckResult], CheckResult], 402 start_time: datetime.datetime) -> t.Tuple[bool, t.List[t.List[str]]]: 403 """Prints human-readable output for the given check_results.""" 404 indent = ' ' 405 406 def indent_block(text: str) -> str: 407 return indent + text.replace('\n', '\n' + indent) 408 409 if isinstance(check_results, CheckResult): 410 ok, output, autofix_commands = check_results 411 if not ok and autofix_commands: 412 recommendation = ('Recommended command(s) to fix this: %s' % 413 [escape_command(x) for x in autofix_commands]) 414 if output: 415 output += '\n' + recommendation 416 else: 417 output = recommendation 418 else: 419 output_pieces = [] 420 autofix_commands = [] 421 for subname, (ok, output, autofix) in check_results: 422 status = 'succeeded' if ok else 'failed' 423 message = ['*** %s.%s %s' % (check_name, subname, status)] 424 if output: 425 message.append(indent_block(output)) 426 if not ok and autofix: 427 message.append( 428 indent_block('Recommended command(s) to fix this: %s' % 429 [escape_command(x) for x in autofix])) 430 431 output_pieces.append('\n'.join(message)) 432 autofix_commands += autofix 433 434 ok = all(x.ok for _, x in check_results) 435 output = '\n\n'.join(output_pieces) 436 437 time_taken = datetime.datetime.now() - start_time 438 if ok: 439 print('*** %s succeeded after %s' % (check_name, time_taken)) 440 else: 441 print('*** %s failed after %s' % (check_name, time_taken)) 442 443 if output: 444 print(indent_block(output)) 445 446 print() 447 return ok, autofix_commands 448 449 450def try_autofix(all_autofix_commands: t.List[t.List[str]], 451 toolchain_utils_root: str) -> None: 452 """Tries to run all given autofix commands, if appropriate.""" 453 if not all_autofix_commands: 454 return 455 456 exit_code, output = run_command_unchecked(['git', 'status', '--porcelain'], 457 cwd=toolchain_utils_root) 458 if exit_code != 0: 459 print("Autofix aborted: couldn't get toolchain-utils git status.") 460 return 461 462 if output.strip(): 463 # A clean repo makes checking/undoing autofix commands trivial. A dirty 464 # one... less so. :) 465 print('Git repo seems dirty; skipping autofix.') 466 return 467 468 anything_succeeded = False 469 for command in all_autofix_commands: 470 exit_code, output = run_command_unchecked(command, cwd=toolchain_utils_root) 471 472 if exit_code: 473 print('*** Autofix command `%s` exited with code %d; stdout/stderr:' % 474 (escape_command(command), exit_code)) 475 print(output) 476 else: 477 print('*** Autofix `%s` succeeded' % escape_command(command)) 478 anything_succeeded = True 479 480 if anything_succeeded: 481 print('NOTE: Autofixes have been applied. Please check your tree, since ' 482 'some lints may now be fixed') 483 484 485def find_repo_root(base_dir: str) -> t.Optional[str]: 486 current = base_dir 487 while current != '/': 488 if os.path.isdir(os.path.join(current, '.repo')): 489 return current 490 current = os.path.dirname(current) 491 return None 492 493 494def is_in_chroot() -> bool: 495 return os.path.exists('/etc/cros_chroot_version') 496 497 498def maybe_reexec_inside_chroot(autofix: bool, files: t.List[str]) -> None: 499 if is_in_chroot(): 500 return 501 502 enter_chroot = True 503 chdir_to = None 504 toolchain_utils = detect_toolchain_utils_root() 505 if find_repo_root(toolchain_utils) is None: 506 chromeos_root_dir = find_chromeos_root_directory() 507 if chromeos_root_dir is None: 508 print('Standalone toolchain-utils checkout detected; cannot enter ' 509 'chroot.') 510 enter_chroot = False 511 else: 512 chdir_to = chromeos_root_dir 513 514 if not has_executable_on_path('cros_sdk'): 515 print('No `cros_sdk` detected on $PATH; cannot enter chroot.') 516 enter_chroot = False 517 518 if not enter_chroot: 519 print('Giving up on entering the chroot; be warned that some presubmits ' 520 'may be broken.') 521 return 522 523 # We'll be changing ${PWD}, so make everything relative to toolchain-utils, 524 # which resides at a well-known place inside of the chroot. 525 chroot_toolchain_utils = '/mnt/host/source/src/third_party/toolchain-utils' 526 527 def rebase_path(path: str) -> str: 528 return os.path.join(chroot_toolchain_utils, 529 os.path.relpath(path, toolchain_utils)) 530 531 args = [ 532 'cros_sdk', 533 '--enter', 534 '--', 535 rebase_path(__file__), 536 ] 537 538 if not autofix: 539 args.append('--no_autofix') 540 args.extend(rebase_path(x) for x in files) 541 542 if chdir_to is None: 543 print('Attempting to enter the chroot...') 544 else: 545 print(f'Attempting to enter the chroot for tree at {chdir_to}...') 546 os.chdir(chdir_to) 547 os.execvp(args[0], args) 548 549 550# FIXME(crbug.com/980719): we probably want a better way of handling this. For 551# now, as a workaround, ensure we have all dependencies installed as a part of 552# presubmits. pip and scipy are fast enough to install (they take <1min 553# combined on my machine), so hoooopefully users won't get too impatient. 554def ensure_scipy_installed() -> None: 555 if not has_executable_on_path('pip'): 556 print('Autoinstalling `pip`...') 557 subprocess.check_call(['sudo', 'emerge', 'dev-python/pip']) 558 559 exit_code = subprocess.call( 560 ['python3', '-c', 'import scipy'], 561 stdout=subprocess.DEVNULL, 562 stderr=subprocess.DEVNULL, 563 ) 564 if exit_code != 0: 565 print('Autoinstalling `scipy`...') 566 subprocess.check_call(['pip', 'install', '--user', 'scipy']) 567 568 569def main(argv: t.List[str]) -> int: 570 parser = argparse.ArgumentParser(description=__doc__) 571 parser.add_argument( 572 '--no_autofix', 573 dest='autofix', 574 action='store_false', 575 help="Don't run any autofix commands.") 576 parser.add_argument( 577 '--no_enter_chroot', 578 dest='enter_chroot', 579 action='store_false', 580 help="Prevent auto-entering the chroot if we're not already in it.") 581 parser.add_argument('files', nargs='*') 582 opts = parser.parse_args(argv) 583 584 files = opts.files 585 if not files: 586 return 0 587 588 if opts.enter_chroot: 589 maybe_reexec_inside_chroot(opts.autofix, opts.files) 590 591 # If you ask for --no_enter_chroot, you're on your own for installing these 592 # things. 593 if is_in_chroot(): 594 ensure_scipy_installed() 595 596 files = [os.path.abspath(f) for f in files] 597 598 # Note that we extract .__name__s from these, so please name them in a 599 # user-friendly way. 600 checks = [ 601 check_cros_lint, 602 check_py_format, 603 check_go_format, 604 check_tests, 605 ] 606 607 toolchain_utils_root = detect_toolchain_utils_root() 608 609 # NOTE: As mentioned above, checks can block on threads they spawn in this 610 # pool, so we need at least len(checks)+1 threads to avoid deadlock. Use *2 611 # so all checks can make progress at a decent rate. 612 num_threads = max(multiprocessing.cpu_count(), len(checks) * 2) 613 start_time = datetime.datetime.now() 614 615 # For our single print statement... 616 spawn_print_lock = threading.RLock() 617 618 def run_check(check_fn): 619 name = check_fn.__name__ 620 with spawn_print_lock: 621 print('*** Spawning %s' % name) 622 return name, check_fn(toolchain_utils_root, pool, files) 623 624 # ThreadPool is a ContextManager in py3. 625 # pylint: disable=not-context-manager 626 with multiprocessing.pool.ThreadPool(num_threads) as pool: 627 all_checks_ok = True 628 all_autofix_commands = [] 629 for check_name, result in pool.imap_unordered(run_check, checks): 630 ok, autofix_commands = process_check_result(check_name, result, 631 start_time) 632 all_checks_ok = ok and all_checks_ok 633 all_autofix_commands += autofix_commands 634 635 # Run these after everything settles, so: 636 # - we don't collide with checkers that are running concurrently 637 # - we clearly print out everything that went wrong ahead of time, in case 638 # any of these fail 639 if opts.autofix: 640 try_autofix(all_autofix_commands, toolchain_utils_root) 641 642 if not all_checks_ok: 643 return 1 644 return 0 645 646 647if __name__ == '__main__': 648 sys.exit(main(sys.argv[1:])) 649