• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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