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