• 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
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