presubmit_support.py 52.5 KB
Newer Older
1
#!/usr/bin/env python
2
# Copyright (c) 2012 The Chromium Authors. All rights reserved.
3 4 5 6 7 8
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""Enables directory-specific presubmit checks to run at upload and/or commit.
"""

9
__version__ = '1.8.0'
10 11 12 13 14

# TODO(joi) Add caching where appropriate/needed. The API is designed to allow
# caching (between all different invocations of presubmit scripts for a given
# change). We should add it as our presubmit scripts start feeling slow.

15
import cpplint
16 17
import cPickle  # Exposed through the API.
import cStringIO  # Exposed through the API.
18
import contextlib
19
import fnmatch  # Exposed through the API.
20
import glob
21
import inspect
22
import itertools
23
import json  # Exposed through the API.
24
import logging
25
import marshal  # Exposed through the API.
26
import multiprocessing
27 28 29
import optparse
import os  # Somewhat exposed through the API.
import pickle  # Exposed through the API.
30
import random
31 32 33
import re  # Exposed through the API.
import sys  # Parts exposed through API.
import tempfile  # Exposed through the API.
34
import time
35
import traceback  # Exposed through the API.
36
import types
37
import unittest  # Exposed through the API.
38
import urllib2  # Exposed through the API.
39
import urlparse
40
from warnings import warn
41 42

# Local imports.
43
import auth
44
import fix_encoding
45
import gclient_utils
46
import gerrit_util
47
import owners
48
import presubmit_canned_checks
49
import rietveld
50
import scm
51
import subprocess2 as subprocess  # Exposed through the API.
52 53


54 55 56 57
# Ask for feedback only once in program lifetime.
_ASKED_FOR_FEEDBACK = False


58
class PresubmitFailure(Exception):
59 60 61
  pass


62 63 64 65 66 67 68 69
class CommandData(object):
  def __init__(self, name, cmd, kwargs, message):
    self.name = name
    self.cmd = cmd
    self.kwargs = kwargs
    self.message = message
    self.info = None

70

71 72 73 74 75 76 77 78 79
def normpath(path):
  '''Version of os.path.normpath that also changes backward slashes to
  forward slashes when not running on Windows.
  '''
  # This is safe to always do because the Windows version of os.path.normpath
  # will replace forward slashes with backward slashes.
  path = path.replace(os.sep, '/')
  return os.path.normpath(path)

80 81 82 83

def _RightHandSideLinesImpl(affected_files):
  """Implements RightHandSideLines for InputApi and GclChange."""
  for af in affected_files:
84
    lines = af.ChangedContents()
85
    for line in lines:
86
      yield (af, line[0], line[1])
87 88


89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120
class PresubmitOutput(object):
  def __init__(self, input_stream=None, output_stream=None):
    self.input_stream = input_stream
    self.output_stream = output_stream
    self.reviewers = []
    self.written_output = []
    self.error_count = 0

  def prompt_yes_no(self, prompt_string):
    self.write(prompt_string)
    if self.input_stream:
      response = self.input_stream.readline().strip().lower()
      if response not in ('y', 'yes'):
        self.fail()
    else:
      self.fail()

  def fail(self):
    self.error_count += 1

  def should_continue(self):
    return not self.error_count

  def write(self, s):
    self.written_output.append(s)
    if self.output_stream:
      self.output_stream.write(s)

  def getvalue(self):
    return ''.join(self.written_output)


121 122 123 124 125 126
# Top level object so multiprocessing can pickle
# Public access through OutputApi object.
class _PresubmitResult(object):
  """Base class for result objects."""
  fatal = False
  should_prompt = False
127

128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148
  def __init__(self, message, items=None, long_text=''):
    """
    message: A short one-line message to indicate errors.
    items: A list of short strings to indicate where errors occurred.
    long_text: multi-line text output, e.g. from another tool
    """
    self._message = message
    self._items = items or []
    if items:
      self._items = items
    self._long_text = long_text.rstrip()

  def handle(self, output):
    output.write(self._message)
    output.write('\n')
    for index, item in enumerate(self._items):
      output.write('  ')
      # Write separately in case it's unicode.
      output.write(str(item))
      if index < len(self._items) - 1:
        output.write(' \\')
149
      output.write('\n')
150 151 152 153 154 155 156 157 158 159 160 161 162 163
    if self._long_text:
      output.write('\n***************\n')
      # Write separately in case it's unicode.
      output.write(self._long_text)
      output.write('\n***************\n')
    if self.fatal:
      output.fail()


# Top level object so multiprocessing can pickle
# Public access through OutputApi object.
class _PresubmitError(_PresubmitResult):
  """A hard presubmit error."""
  fatal = True
164 165


166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187
# Top level object so multiprocessing can pickle
# Public access through OutputApi object.
class _PresubmitPromptWarning(_PresubmitResult):
  """An warning that prompts the user if they want to continue."""
  should_prompt = True


# Top level object so multiprocessing can pickle
# Public access through OutputApi object.
class _PresubmitNotifyResult(_PresubmitResult):
  """Just print something to the screen -- but it's not even a warning."""
  pass


# Top level object so multiprocessing can pickle
# Public access through OutputApi object.
class _MailTextResult(_PresubmitResult):
  """A warning that should be included in the review request email."""
  def __init__(self, *args, **kwargs):
    super(_MailTextResult, self).__init__()
    raise NotImplementedError()

188 189 190 191 192 193 194 195 196 197 198 199
class GerritAccessor(object):
  """Limited Gerrit functionality for canned presubmit checks to work.

  To avoid excessive Gerrit calls, caches the results.
  """

  def __init__(self, host):
    self.host = host
    self.cache = {}

  def _FetchChangeDetail(self, issue):
    # Separate function to be easily mocked in tests.
200 201 202 203 204 205 206 207 208 209
    try:
      return gerrit_util.GetChangeDetail(
          self.host, str(issue),
          ['ALL_REVISIONS', 'DETAILED_LABELS'],
          ignore_404=False)
    except gerrit_util.GerritError as e:
      if e.http_status == 404:
        raise Exception('Either Gerrit issue %s doesn\'t exist, or '
                        'no credentials to fetch issue details' % issue)
      raise
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253

  def GetChangeInfo(self, issue):
    """Returns labels and all revisions (patchsets) for this issue.

    The result is a dictionary according to Gerrit REST Api.
    https://gerrit-review.googlesource.com/Documentation/rest-api.html

    However, API isn't very clear what's inside, so see tests for example.
    """
    assert issue
    cache_key = int(issue)
    if cache_key not in self.cache:
      self.cache[cache_key] = self._FetchChangeDetail(issue)
    return self.cache[cache_key]

  def GetChangeDescription(self, issue, patchset=None):
    """If patchset is none, fetches current patchset."""
    info = self.GetChangeInfo(issue)
    # info is a reference to cache. We'll modify it here adding description to
    # it to the right patchset, if it is not yet there.

    # Find revision info for the patchset we want.
    if patchset is not None:
      for rev, rev_info in info['revisions'].iteritems():
        if str(rev_info['_number']) == str(patchset):
          break
      else:
        raise Exception('patchset %s doesn\'t exist in issue %s' % (
            patchset, issue))
    else:
      rev = info['current_revision']
      rev_info = info['revisions'][rev]

    # Updates revision info, which is part of cached issue info.
    if 'real_description' not in rev_info:
      rev_info['real_description'] = (
          gerrit_util.GetChangeDescriptionFromGitiles(
              rev_info['fetch']['http']['url'], rev))
    return rev_info['real_description']

  def GetChangeOwner(self, issue):
    return self.GetChangeInfo(issue)['owner']['email']

  def GetChangeReviewers(self, issue, approving_only=True):
254 255 256 257
    cr = self.GetChangeInfo(issue)['labels']['Code-Review']
    max_value = max(int(k) for k in cr['values'].keys())
    return [r['email'] for r in cr['all']
            if not approving_only or r.get('value', 0) == max_value]
258

259 260 261 262 263 264 265 266 267 268 269 270 271

class OutputApi(object):
  """An instance of OutputApi gets passed to presubmit scripts so that they
  can output various types of results.
  """
  PresubmitResult = _PresubmitResult
  PresubmitError = _PresubmitError
  PresubmitPromptWarning = _PresubmitPromptWarning
  PresubmitNotifyResult = _PresubmitNotifyResult
  MailTextResult = _MailTextResult

  def __init__(self, is_committing):
    self.is_committing = is_committing
272

273 274 275 276 277 278
  def PresubmitPromptOrNotify(self, *args, **kwargs):
    """Warn the user when uploading, but only notify if committing."""
    if self.is_committing:
      return self.PresubmitNotifyResult(*args, **kwargs)
    return self.PresubmitPromptWarning(*args, **kwargs)

279 280 281 282 283

class InputApi(object):
  """An instance of this object is passed to presubmit scripts so they can
  know stuff about the change they're looking at.
  """
284 285
  # Method could be a function
  # pylint: disable=R0201
286

287 288
  # File extensions that are considered source files from a style guide
  # perspective. Don't modify this list from a presubmit script!
289 290 291 292
  #
  # Files without an extension aren't included in the list. If you want to
  # filter them as source files, add r"(^|.*?[\\\/])[^.]+$" to the white list.
  # Note that ALL CAPS files are black listed in DEFAULT_BLACK_LIST below.
293 294
  DEFAULT_WHITE_LIST = (
      # C++ and friends
295 296
      r".+\.c$", r".+\.cc$", r".+\.cpp$", r".+\.h$", r".+\.m$", r".+\.mm$",
      r".+\.inl$", r".+\.asm$", r".+\.hxx$", r".+\.hpp$", r".+\.s$", r".+\.S$",
297
      # Scripts
298
      r".+\.js$", r".+\.py$", r".+\.sh$", r".+\.rb$", r".+\.pl$", r".+\.pm$",
299
      # Other
300
      r".+\.java$", r".+\.mk$", r".+\.am$", r".+\.css$"
301 302 303 304 305
  )

  # Path regexp that should be excluded from being considered containing source
  # files. Don't modify this list from a presubmit script!
  DEFAULT_BLACK_LIST = (
306
      r"testing_support[\\\/]google_appengine[\\\/].*",
307
      r".*\bexperimental[\\\/].*",
308 309
      # Exclude third_party/.* but NOT third_party/WebKit (crbug.com/539768).
      r".*\bthird_party[\\\/](?!WebKit[\\\/]).*",
310 311 312 313
      # Output directories (just in case)
      r".*\bDebug[\\\/].*",
      r".*\bRelease[\\\/].*",
      r".*\bxcodebuild[\\\/].*",
314
      r".*\bout[\\\/].*",
315
      # All caps files like README and LICENCE.
316
      r".*\b[A-Z0-9_]{2,}$",
317
      # SCM (can happen in dual SCM configuration). (Slightly over aggressive)
318 319
      r"(|.*[\\\/])\.git[\\\/].*",
      r"(|.*[\\\/])\.svn[\\\/].*",
320 321 322
      # There is no point in processing a patch file.
      r".+\.diff$",
      r".+\.patch$",
323 324
  )

325
  def __init__(self, change, presubmit_path, is_committing,
326
      rietveld_obj, verbose, gerrit_obj=None, dry_run=None):
327 328 329
    """Builds an InputApi object.

    Args:
330
      change: A presubmit.Change object.
331
      presubmit_path: The path to the presubmit script being processed.
332
      is_committing: True if the change is about to be committed.
333
      rietveld_obj: rietveld.Rietveld client object
334 335
      gerrit_obj: provides basic Gerrit codereview functionality.
      dry_run: if true, some Checks will be skipped.
336
    """
337 338
    # Version number of the presubmit_support script.
    self.version = [int(x) for x in __version__.split('.')]
339
    self.change = change
340
    self.is_committing = is_committing
341
    self.rietveld = rietveld_obj
342
    self.gerrit = gerrit_obj
343
    self.dry_run = dry_run
344 345 346
    # TBD
    self.host_url = 'http://codereview.chromium.org'
    if self.rietveld:
347
      self.host_url = self.rietveld.url
348 349 350 351 352

    # We expose various modules and functions as attributes of the input_api
    # so that presubmit scripts don't have to import them.
    self.basename = os.path.basename
    self.cPickle = cPickle
353
    self.cpplint = cpplint
354
    self.cStringIO = cStringIO
355
    self.fnmatch = fnmatch
356
    self.glob = glob.glob
357
    self.json = json
358
    self.logging = logging.getLogger('PRESUBMIT')
359 360
    self.os_listdir = os.listdir
    self.os_walk = os.walk
361
    self.os_path = os.path
362
    self.os_stat = os.stat
363 364 365 366 367
    self.pickle = pickle
    self.marshal = marshal
    self.re = re
    self.subprocess = subprocess
    self.tempfile = tempfile
368
    self.time = time
369
    self.traceback = traceback
370
    self.unittest = unittest
371 372
    self.urllib2 = urllib2

373 374 375 376
    # To easily fork python.
    self.python_executable = sys.executable
    self.environ = os.environ

377 378 379
    # InputApi.platform is the platform you're currently running on.
    self.platform = sys.platform

380 381
    self.cpu_count = multiprocessing.cpu_count()

382 383 384 385 386 387
    # this is done here because in RunTests, the current working directory has
    # changed, which causes Pool() to explode fantastically when run on windows
    # (because it tries to load the __main__ module, which imports lots of
    # things relative to the current working directory).
    self._run_tests_pool = multiprocessing.Pool(self.cpu_count)

388
    # The local path of the currently-being-processed presubmit script.
389
    self._current_presubmit_path = os.path.dirname(presubmit_path)
390 391 392 393

    # We carry the canned checks so presubmit scripts can easily use them.
    self.canned_checks = presubmit_canned_checks

394 395 396
    # TODO(dpranke): figure out a list of all approved owners for a repo
    # in order to be able to handle wildcard OWNERS files?
    self.owners_db = owners.Database(change.RepositoryRoot(),
397
        fopen=file, os_path=self.os_path)
398
    self.verbose = verbose
399
    self.Command = CommandData
400

401
    # Replace <hash_map> and <hash_set> as headers that need to be included
402
    # with "base/containers/hash_tables.h" instead.
403 404 405
    # Access to a protected member _XX of a client class
    # pylint: disable=W0212
    self.cpplint._re_pattern_templates = [
406
      (a, b, 'base/containers/hash_tables.h')
407 408 409 410
        if header in ('<hash_map>', '<hash_set>') else (a, b, header)
      for (a, b, header) in cpplint._re_pattern_templates
    ]

411 412 413 414 415 416 417 418
  def PresubmitLocalPath(self):
    """Returns the local path of the presubmit script currently being run.

    This is useful if you don't want to hard-code absolute paths in the
    presubmit script.  For example, It can be used to find another file
    relative to the PRESUBMIT.py script, so the whole tree can be branched and
    the presubmit script still works, without editing its content.
    """
419
    return self._current_presubmit_path
420

421
  def AffectedFiles(self, include_deletes=True, file_filter=None):
422 423 424 425
    """Same as input_api.change.AffectedFiles() except only lists files
    (and optionally directories) in the same directory as the current presubmit
    script, or subdirectories thereof.
    """
426
    dir_with_slash = normpath("%s/" % self.PresubmitLocalPath())
427 428
    if len(dir_with_slash) == 1:
      dir_with_slash = ''
429

430 431
    return filter(
        lambda x: normpath(x.AbsoluteLocalPath()).startswith(dir_with_slash),
432
        self.change.AffectedFiles(include_deletes, file_filter))
433

434
  def LocalPaths(self):
435
    """Returns local paths of input_api.AffectedFiles()."""
436
    paths = [af.LocalPath() for af in self.AffectedFiles()]
437 438
    logging.debug("LocalPaths: %s", paths)
    return paths
439

440
  def AbsoluteLocalPaths(self):
441
    """Returns absolute local paths of input_api.AffectedFiles()."""
442
    return [af.AbsoluteLocalPath() for af in self.AffectedFiles()]
443

444 445
  def AffectedTestableFiles(self, include_deletes=None):
    """Same as input_api.change.AffectedTestableFiles() except only lists files
446 447 448
    in the same directory as the current presubmit script, or subdirectories
    thereof.
    """
449
    if include_deletes is not None:
450
      warn("AffectedTestableFiles(include_deletes=%s)"
451 452 453
               " is deprecated and ignored" % str(include_deletes),
           category=DeprecationWarning,
           stacklevel=2)
454 455 456 457 458 459
    return filter(lambda x: x.IsTestableFile(),
                  self.AffectedFiles(include_deletes=False))

  def AffectedTextFiles(self, include_deletes=None):
    """An alias to AffectedTestableFiles for backwards compatibility."""
    return self.AffectedTestableFiles(include_deletes=include_deletes)
460

461 462 463 464 465 466 467 468 469 470 471
  def FilterSourceFile(self, affected_file, white_list=None, black_list=None):
    """Filters out files that aren't considered "source file".

    If white_list or black_list is None, InputApi.DEFAULT_WHITE_LIST
    and InputApi.DEFAULT_BLACK_LIST is used respectively.

    The lists will be compiled as regular expression and
    AffectedFile.LocalPath() needs to pass both list.

    Note: Copy-paste this function to suit your needs or use a lambda function.
    """
472
    def Find(affected_file, items):
473
      local_path = affected_file.LocalPath()
474
      for item in items:
475
        if self.re.match(item, local_path):
476 477 478 479 480 481
          return True
      return False
    return (Find(affected_file, white_list or self.DEFAULT_WHITE_LIST) and
            not Find(affected_file, black_list or self.DEFAULT_BLACK_LIST))

  def AffectedSourceFiles(self, source_file):
482
    """Filter the list of AffectedTestableFiles by the function source_file.
483 484 485 486 487

    If source_file is None, InputApi.FilterSourceFile() is used.
    """
    if not source_file:
      source_file = self.FilterSourceFile
488
    return filter(source_file, self.AffectedTestableFiles())
489 490

  def RightHandSideLines(self, source_file_filter=None):
491 492 493 494 495 496 497 498 499 500 501 502 503
    """An iterator over all text lines in "new" version of changed files.

    Only lists lines from new or modified text files in the change that are
    contained by the directory of the currently executing presubmit script.

    This is useful for doing line-by-line regex checks, like checking for
    trailing whitespace.

    Yields:
      a 3 tuple:
        the AffectedFile instance of the current file;
        integer line number (1-based); and
        the contents of the line as a string.
504

505
    Note: The carriage return (LF or CR) is stripped off.
506
    """
507
    files = self.AffectedSourceFiles(source_file_filter)
508
    return _RightHandSideLinesImpl(files)
509

510
  def ReadFile(self, file_item, mode='r'):
511
    """Reads an arbitrary file.
512

513 514
    Deny reading anything outside the repository.
    """
515 516 517
    if isinstance(file_item, AffectedFile):
      file_item = file_item.AbsoluteLocalPath()
    if not file_item.startswith(self.change.RepositoryRoot()):
518
      raise IOError('Access outside the repository root is denied.')
519
    return gclient_utils.FileRead(file_item, mode)
520

521 522 523 524 525
  @property
  def tbr(self):
    """Returns if a change is TBR'ed."""
    return 'TBR' in self.change.tags

526
  def RunTests(self, tests_mix, parallel=True):
527 528 529 530 531 532 533 534
    tests = []
    msgs = []
    for t in tests_mix:
      if isinstance(t, OutputApi.PresubmitResult):
        msgs.append(t)
      else:
        assert issubclass(t.message, _PresubmitResult)
        tests.append(t)
535 536
        if self.verbose:
          t.info = _PresubmitNotifyResult
537
    if len(tests) > 1 and parallel:
538
      # async recipe works around multiprocessing bug handling Ctrl-C
539
      msgs.extend(self._run_tests_pool.map_async(CallCommand, tests).get(99999))
540 541 542 543
    else:
      msgs.extend(map(CallCommand, tests))
    return [m for m in msgs if m]

544 545 546 547 548
  def ShutdownPool(self):
    self._run_tests_pool.close()
    self._run_tests_pool.join()
    self._run_tests_pool = None

549

550 551
class _DiffCache(object):
  """Caches diffs retrieved from a particular SCM."""
552 553 554
  def __init__(self, upstream=None):
    """Stores the upstream revision against which all diffs will be computed."""
    self._upstream = upstream
555 556 557 558 559 560 561 562

  def GetDiff(self, path, local_root):
    """Get the diff for a particular path."""
    raise NotImplementedError()


class _GitDiffCache(_DiffCache):
  """DiffCache implementation for git; gets all file diffs at once."""
563 564
  def __init__(self, upstream):
    super(_GitDiffCache, self).__init__(upstream=upstream)
565 566 567 568 569 570 571 572 573 574
    self._diffs_by_file = None

  def GetDiff(self, path, local_root):
    if not self._diffs_by_file:
      # Compute a single diff for all files and parse the output; should
      # with git this is much faster than computing one diff for each file.
      diffs = {}

      # Don't specify any filenames below, because there are command line length
      # limits on some platforms and GenerateDiff would fail.
575 576
      unified_diff = scm.GIT.GenerateDiff(local_root, files=[], full_move=True,
                                          branch=self._upstream)
577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602

      # This regex matches the path twice, separated by a space. Note that
      # filename itself may contain spaces.
      file_marker = re.compile('^diff --git (?P<filename>.*) (?P=filename)$')
      current_diff = []
      keep_line_endings = True
      for x in unified_diff.splitlines(keep_line_endings):
        match = file_marker.match(x)
        if match:
          # Marks the start of a new per-file section.
          diffs[match.group('filename')] = current_diff = [x]
        elif x.startswith('diff --git'):
          raise PresubmitFailure('Unexpected diff line: %s' % x)
        else:
          current_diff.append(x)

      self._diffs_by_file = dict(
        (normpath(path), ''.join(diff)) for path, diff in diffs.items())

    if path not in self._diffs_by_file:
      raise PresubmitFailure(
          'Unified diff did not contain entry for file %s' % path)

    return self._diffs_by_file[path]


603 604
class AffectedFile(object):
  """Representation of a file in a change."""
605 606 607

  DIFF_CACHE = _DiffCache

608 609
  # Method could be a function
  # pylint: disable=R0201
610
  def __init__(self, path, action, repository_root, diff_cache):
611 612
    self._path = path
    self._action = action
613
    self._local_root = repository_root
614
    self._is_directory = None
615 616
    self._cached_changed_contents = None
    self._cached_new_contents = None
617
    self._diff_cache = diff_cache
618
    logging.debug('%s(%s)', self.__class__.__name__, self._path)
619 620 621 622

  def LocalPath(self):
    """Returns the path of this file on the local disk relative to client root.
    """
623
    return normpath(self._path)
624 625 626 627

  def AbsoluteLocalPath(self):
    """Returns the absolute path of this file on the local disk.
    """
628
    return os.path.abspath(os.path.join(self._local_root, self.LocalPath()))
629 630 631

  def Action(self):
    """Returns the action on this opened file, e.g. A, M, D, etc."""
632 633
    # TODO(maruel): Somewhat crappy, Could be "A" or "A  +" for svn but
    # different for other SCM.
634
    return self._action
635

636
  def IsTestableFile(self):
637
    """Returns True if the file is a text file and not a binary file.
638

639
    Deleted files are not text file."""
640 641
    raise NotImplementedError()  # Implement when needed

642 643 644 645
  def IsTextFile(self):
    """An alias to IsTestableFile for backwards compatibility."""
    return self.IsTestableFile()

646 647 648 649 650 651 652
  def NewContents(self):
    """Returns an iterator over the lines in the new version of file.

    The new version is the file in the user's workspace, i.e. the "right hand
    side".

    Contents will be empty if the file is a directory or does not exist.
653
    Note: The carriage returns (LF or CR) are stripped off.
654
    """
655 656
    if self._cached_new_contents is None:
      self._cached_new_contents = []
657 658 659 660 661
      try:
        self._cached_new_contents = gclient_utils.FileRead(
            self.AbsoluteLocalPath(), 'rU').splitlines()
      except IOError:
        pass  # File not found?  That's fine; maybe it was deleted.
662
    return self._cached_new_contents[:]
663

664 665 666 667 668 669 670 671
  def ChangedContents(self):
    """Returns a list of tuples (line number, line text) of all new lines.

     This relies on the scm diff output describing each changed code section
     with a line of the form

     ^@@ <old line num>,<old size> <new line num>,<new size> @@$
    """
672 673 674
    if self._cached_changed_contents is not None:
      return self._cached_changed_contents[:]
    self._cached_changed_contents = []
675 676 677 678 679 680 681 682
    line_num = 0

    for line in self.GenerateScmDiff().splitlines():
      m = re.match(r'^@@ [0-9\,\+\-]+ \+([0-9]+)\,[0-9]+ @@', line)
      if m:
        line_num = int(m.groups(1)[0])
        continue
      if line.startswith('+') and not line.startswith('++'):
683
        self._cached_changed_contents.append((line_num, line[1:]))
684 685
      if not line.startswith('-'):
        line_num += 1
686
    return self._cached_changed_contents[:]
687

688 689 690
  def __str__(self):
    return self.LocalPath()

691
  def GenerateScmDiff(self):
692
    return self._diff_cache.GetDiff(self.LocalPath(), self._local_root)
693

694

695 696
class GitAffectedFile(AffectedFile):
  """Representation of a file in a change out of a git checkout."""
697 698
  # Method 'NNN' is abstract in class 'NNN' but is not overridden
  # pylint: disable=W0223
699

700 701
  DIFF_CACHE = _GitDiffCache

702 703 704
  def __init__(self, *args, **kwargs):
    AffectedFile.__init__(self, *args, **kwargs)
    self._server_path = None
705
    self._is_testable_file = None
706

707 708
  def IsTestableFile(self):
    if self._is_testable_file is None:
709
      if self.Action() == 'D':
710 711
        # A deleted file is not testable.
        self._is_testable_file = False
712
      else:
713 714
        self._is_testable_file = os.path.isfile(self.AbsoluteLocalPath())
    return self._is_testable_file
715

716

717
class Change(object):
718 719 720 721
  """Describe a change.

  Used directly by the presubmit scripts to query the current change being
  tested.
722

723
  Instance members:
724
    tags: Dictionary of KEY=VALUE pairs found in the change description.
725 726 727
    self.KEY: equivalent to tags['KEY']
  """

728 729
  _AFFECTED_FILES = AffectedFile

730
  # Matches key/value (or "tag") lines in changelist descriptions.
731
  TAG_LINE_RE = re.compile(
732
      '^[ \t]*(?P<key>[A-Z][A-Z_0-9]*)[ \t]*=[ \t]*(?P<value>.*?)[ \t]*$')
733
  scm = ''
734

735
  def __init__(
736 737
      self, name, description, local_root, files, issue, patchset, author,
      upstream=None):
738 739 740
    if files is None:
      files = []
    self._name = name
741 742
    # Convert root into an absolute path.
    self._local_root = os.path.abspath(local_root)
743
    self._upstream = upstream
744 745
    self.issue = issue
    self.patchset = patchset
746
    self.author_email = author
747

748
    self._full_description = ''
749
    self.tags = {}
750 751
    self._description_without_tags = ''
    self.SetDescriptionText(description)
752

753 754 755
    assert all(
        (isinstance(f, (list, tuple)) and len(f) == 2) for f in files), files

756
    diff_cache = self._AFFECTED_FILES.DIFF_CACHE(self._upstream)
757
    self._affected_files = [
758 759
        self._AFFECTED_FILES(path, action.strip(), self._local_root, diff_cache)
        for action, path in files
760
    ]
761

762
  def Name(self):
763
    """Returns the change name."""
764
    return self._name
765 766 767 768 769 770 771 772

  def DescriptionText(self):
    """Returns the user-entered changelist description, minus tags.

    Any line in the user-provided description starting with e.g. "FOO="
    (whitespace permitted before and around) is considered a tag line.  Such
    lines are stripped out of the description this function returns.
    """
773
    return self._description_without_tags
774 775 776

  def FullDescriptionText(self):
    """Returns the complete changelist description including tags."""
777
    return self._full_description
778

779 780
  def SetDescriptionText(self, description):
    """Sets the full description text (including tags) to |description|.
781

782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799
    Also updates the list of tags."""
    self._full_description = description

    # From the description text, build up a dictionary of key/value pairs
    # plus the description minus all key/value or "tag" lines.
    description_without_tags = []
    self.tags = {}
    for line in self._full_description.splitlines():
      m = self.TAG_LINE_RE.match(line)
      if m:
        self.tags[m.group('key')] = m.group('value')
      else:
        description_without_tags.append(line)

    # Change back to text and remove whitespace at end.
    self._description_without_tags = (
        '\n'.join(description_without_tags).rstrip())

800
  def RepositoryRoot(self):
801 802 803
    """Returns the repository (checkout) root directory for this change,
    as an absolute path.
    """
804
    return self._local_root
805 806

  def __getattr__(self, attr):
807 808 809
    """Return tags directly as attributes on the object."""
    if not re.match(r"^[A-Z_]*$", attr):
      raise AttributeError(self, attr)
810
    return self.tags.get(attr)
811

812 813 814 815
  def AllFiles(self, root=None):
    """List all files under source control in the repo."""
    raise NotImplementedError()

816
  def AffectedFiles(self, include_deletes=True, file_filter=None):
817 818 819 820
    """Returns a list of AffectedFile instances for all files in the change.

    Args:
      include_deletes: If false, deleted files will be filtered out.
821
      file_filter: An additional filter to apply.
822 823 824 825

    Returns:
      [AffectedFile(path, action), AffectedFile(path, action)]
    """
826
    affected = filter(file_filter, self._affected_files)
827

828 829 830 831 832
    if include_deletes:
      return affected
    else:
      return filter(lambda x: x.Action() != 'D', affected)

833
  def AffectedTestableFiles(self, include_deletes=None):
834 835
    """Return a list of the existing text files in a change."""
    if include_deletes is not None:
836
      warn("AffectedTeestableFiles(include_deletes=%s)"
837 838 839
               " is deprecated and ignored" % str(include_deletes),
           category=DeprecationWarning,
           stacklevel=2)
840 841
    return filter(lambda x: x.IsTestableFile(),
                  self.AffectedFiles(include_deletes=False))
842

843 844 845
  def AffectedTextFiles(self, include_deletes=None):
    """An alias to AffectedTestableFiles for backwards compatibility."""
    return self.AffectedTestableFiles(include_deletes=include_deletes)
846

847
  def LocalPaths(self):
848
    """Convenience function."""
849
    return [af.LocalPath() for af in self.AffectedFiles()]
850

851
  def AbsoluteLocalPaths(self):
852
    """Convenience function."""
853
    return [af.AbsoluteLocalPath() for af in self.AffectedFiles()]
854 855 856 857 858 859 860 861 862 863 864 865 866 867 868

  def RightHandSideLines(self):
    """An iterator over all text lines in "new" version of changed files.

    Lists lines from new or modified text files in the change.

    This is useful for doing line-by-line regex checks, like checking for
    trailing whitespace.

    Yields:
      a 3 tuple:
        the AffectedFile instance of the current file;
        integer line number (1-based); and
        the contents of the line as a string.
    """
869 870
    return _RightHandSideLinesImpl(
        x for x in self.AffectedFiles(include_deletes=False)
871
        if x.IsTestableFile())
872

873

874 875
class GitChange(Change):
  _AFFECTED_FILES = GitAffectedFile
876
  scm = 'git'
877

878 879 880 881 882 883
  def AllFiles(self, root=None):
    """List all files under source control in the repo."""
    root = root or self.RepositoryRoot()
    return subprocess.check_output(
        ['git', 'ls-files', '--', '.'], cwd=root).splitlines()

884

885
def ListRelevantPresubmitFiles(files, root):
886 887
  """Finds all presubmit files that apply to a given set of source files.

888 889 890
  If inherit-review-settings-ok is present right under root, looks for
  PRESUBMIT.py in directories enclosing root.

891 892
  Args:
    files: An iterable container containing file paths.
893
    root: Path where to stop searching.
894 895

  Return:
896
    List of absolute paths of the existing PRESUBMIT.py scripts.
897
  """
898 899 900 901 902 903 904 905 906 907 908 909 910 911
  files = [normpath(os.path.join(root, f)) for f in files]

  # List all the individual directories containing files.
  directories = set([os.path.dirname(f) for f in files])

  # Ignore root if inherit-review-settings-ok is present.
  if os.path.isfile(os.path.join(root, 'inherit-review-settings-ok')):
    root = None

  # Collect all unique directories that may contain PRESUBMIT.py.
  candidates = set()
  for directory in directories:
    while True:
      if directory in candidates:
912
        break
913 914
      candidates.add(directory)
      if directory == root:
915
        break
916 917 918 919 920 921 922 923 924
      parent_dir = os.path.dirname(directory)
      if parent_dir == directory:
        # We hit the system root directory.
        break
      directory = parent_dir

  # Look for PRESUBMIT.py in all candidate directories.
  results = []
  for directory in sorted(list(candidates)):
925 926 927 928 929 930 931 932
    try:
      for f in os.listdir(directory):
        p = os.path.join(directory, f)
        if os.path.isfile(p) and re.match(
            r'PRESUBMIT.*\.py$', f) and not f.startswith('PRESUBMIT_test'):
          results.append(p)
    except OSError:
      pass
933

934
  logging.debug('Presubmit files: %s', ','.join(results))
935
  return results
936 937


938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967
class GetTryMastersExecuter(object):
  @staticmethod
  def ExecPresubmitScript(script_text, presubmit_path, project, change):
    """Executes GetPreferredTryMasters() from a single presubmit script.

    Args:
      script_text: The text of the presubmit script.
      presubmit_path: Project script to run.
      project: Project name to pass to presubmit script for bot selection.

    Return:
      A map of try masters to map of builders to set of tests.
    """
    context = {}
    try:
      exec script_text in context
    except Exception, e:
      raise PresubmitFailure('"%s" had an exception.\n%s'
                             % (presubmit_path, e))

    function_name = 'GetPreferredTryMasters'
    if function_name not in context:
      return {}
    get_preferred_try_masters = context[function_name]
    if not len(inspect.getargspec(get_preferred_try_masters)[0]) == 2:
      raise PresubmitFailure(
          'Expected function "GetPreferredTryMasters" to take two arguments.')
    return get_preferred_try_masters(project, change)


968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998
class GetPostUploadExecuter(object):
  @staticmethod
  def ExecPresubmitScript(script_text, presubmit_path, cl, change):
    """Executes PostUploadHook() from a single presubmit script.

    Args:
      script_text: The text of the presubmit script.
      presubmit_path: Project script to run.
      cl: The Changelist object.
      change: The Change object.

    Return:
      A list of results objects.
    """
    context = {}
    try:
      exec script_text in context
    except Exception, e:
      raise PresubmitFailure('"%s" had an exception.\n%s'
                             % (presubmit_path, e))

    function_name = 'PostUploadHook'
    if function_name not in context:
      return {}
    post_upload_hook = context[function_name]
    if not len(inspect.getargspec(post_upload_hook)[0]) == 3:
      raise PresubmitFailure(
          'Expected function "PostUploadHook" to take three arguments.')
    return post_upload_hook(cl, change, OutputApi(False))


999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060
def _MergeMasters(masters1, masters2):
  """Merges two master maps. Merges also the tests of each builder."""
  result = {}
  for (master, builders) in itertools.chain(masters1.iteritems(),
                                            masters2.iteritems()):
    new_builders = result.setdefault(master, {})
    for (builder, tests) in builders.iteritems():
      new_builders.setdefault(builder, set([])).update(tests)
  return result


def DoGetTryMasters(change,
                    changed_files,
                    repository_root,
                    default_presubmit,
                    project,
                    verbose,
                    output_stream):
  """Get the list of try masters from the presubmit scripts.

  Args:
    changed_files: List of modified files.
    repository_root: The repository root.
    default_presubmit: A default presubmit script to execute in any case.
    project: Optional name of a project used in selecting trybots.
    verbose: Prints debug info.
    output_stream: A stream to write debug output to.

  Return:
    Map of try masters to map of builders to set of tests.
  """
  presubmit_files = ListRelevantPresubmitFiles(changed_files, repository_root)
  if not presubmit_files and verbose:
    output_stream.write("Warning, no PRESUBMIT.py found.\n")
  results = {}
  executer = GetTryMastersExecuter()

  if default_presubmit:
    if verbose:
      output_stream.write("Running default presubmit script.\n")
    fake_path = os.path.join(repository_root, 'PRESUBMIT.py')
    results = _MergeMasters(results, executer.ExecPresubmitScript(
        default_presubmit, fake_path, project, change))
  for filename in presubmit_files:
    filename = os.path.abspath(filename)
    if verbose:
      output_stream.write("Running %s\n" % filename)
    # Accept CRLF presubmit script.
    presubmit_script = gclient_utils.FileRead(filename, 'rU')
    results = _MergeMasters(results, executer.ExecPresubmitScript(
        presubmit_script, filename, project, change))

  # Make sets to lists again for later JSON serialization.
  for builders in results.itervalues():
    for builder in builders:
      builders[builder] = list(builders[builder])

  if results and verbose:
    output_stream.write('%s\n' % str(results))
  return results


1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103
def DoPostUploadExecuter(change,
                         cl,
                         repository_root,
                         verbose,
                         output_stream):
  """Execute the post upload hook.

  Args:
    change: The Change object.
    cl: The Changelist object.
    repository_root: The repository root.
    verbose: Prints debug info.
    output_stream: A stream to write debug output to.
  """
  presubmit_files = ListRelevantPresubmitFiles(
      change.LocalPaths(), repository_root)
  if not presubmit_files and verbose:
    output_stream.write("Warning, no PRESUBMIT.py found.\n")
  results = []
  executer = GetPostUploadExecuter()
  # The root presubmit file should be executed after the ones in subdirectories.
  # i.e. the specific post upload hooks should run before the general ones.
  # Thus, reverse the order provided by ListRelevantPresubmitFiles.
  presubmit_files.reverse()

  for filename in presubmit_files:
    filename = os.path.abspath(filename)
    if verbose:
      output_stream.write("Running %s\n" % filename)
    # Accept CRLF presubmit script.
    presubmit_script = gclient_utils.FileRead(filename, 'rU')
    results.extend(executer.ExecPresubmitScript(
        presubmit_script, filename, cl, change))
  output_stream.write('\n')
  if results:
    output_stream.write('** Post Upload Hook Messages **\n')
  for result in results:
    result.handle(output_stream)
    output_stream.write('\n')

  return results


1104
class PresubmitExecuter(object):
1105
  def __init__(self, change, committing, rietveld_obj, verbose,
1106
               gerrit_obj=None, dry_run=None):
1107 1108
    """
    Args:
1109
      change: The Change object.
1110
      committing: True if 'git cl land' is running, False if 'git cl upload' is.
1111
      rietveld_obj: rietveld.Rietveld client object.
1112 1113
      gerrit_obj: provides basic Gerrit codereview functionality.
      dry_run: if true, some Checks will be skipped.
1114
    """
1115
    self.change = change
1116
    self.committing = committing
1117
    self.rietveld = rietveld_obj
1118
    self.gerrit = gerrit_obj
1119
    self.verbose = verbose
1120
    self.dry_run = dry_run
1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132

  def ExecPresubmitScript(self, script_text, presubmit_path):
    """Executes a single presubmit script.

    Args:
      script_text: The text of the presubmit script.
      presubmit_path: The path to the presubmit file (this will be reported via
        input_api.PresubmitLocalPath()).

    Return:
      A list of result objects, empty if no problems.
    """
1133

1134 1135 1136 1137 1138
    # Change to the presubmit file's directory to support local imports.
    main_path = os.getcwd()
    os.chdir(os.path.dirname(presubmit_path))

    # Load the presubmit script into context.
1139
    input_api = InputApi(self.change, presubmit_path, self.committing,
1140
                         self.rietveld, self.verbose,
1141
                         gerrit_obj=self.gerrit, dry_run=self.dry_run)
1142
    context = {}
1143 1144 1145 1146
    try:
      exec script_text in context
    except Exception, e:
      raise PresubmitFailure('"%s" had an exception.\n%s' % (presubmit_path, e))
1147 1148 1149 1150 1151 1152 1153 1154

    # These function names must change if we make substantial changes to
    # the presubmit API that are not backwards compatible.
    if self.committing:
      function_name = 'CheckChangeOnCommit'
    else:
      function_name = 'CheckChangeOnUpload'
    if function_name in context:
1155
      context['__args'] = (input_api, OutputApi(self.committing))
1156
      logging.debug('Running %s in %s', function_name, presubmit_path)
1157
      result = eval(function_name + '(*__args)', context)
1158
      logging.debug('Running %s done.', function_name)
1159 1160
      if not (isinstance(result, types.TupleType) or
              isinstance(result, types.ListType)):
1161
        raise PresubmitFailure(
1162 1163 1164
          'Presubmit functions must return a tuple or list')
      for item in result:
        if not isinstance(item, OutputApi.PresubmitResult):
1165
          raise PresubmitFailure(
1166 1167 1168 1169 1170
            'All presubmit results must be of types derived from '
            'output_api.PresubmitResult')
    else:
      result = ()  # no error since the script doesn't care about current event.

1171 1172
    input_api.ShutdownPool()

1173 1174
    # Return the process to the original working directory.
    os.chdir(main_path)
1175 1176
    return result

1177

1178
def DoPresubmitChecks(change,
1179 1180 1181
                      committing,
                      verbose,
                      output_stream,
1182
                      input_stream,
1183
                      default_presubmit,
1184
                      may_prompt,
1185
                      rietveld_obj,
1186
                      gerrit_obj=None,
1187
                      dry_run=None):
1188 1189 1190 1191 1192 1193 1194 1195 1196 1197
  """Runs all presubmit checks that apply to the files in the change.

  This finds all PRESUBMIT.py files in directories enclosing the files in the
  change (up to the repository root) and calls the relevant entrypoint function
  depending on whether the change is being committed or uploaded.

  Prints errors, warnings and notifications.  Prompts the user for warnings
  when needed.

  Args:
1198
    change: The Change object.
1199
    committing: True if 'git cl land' is running, False if 'git cl upload' is.
1200 1201 1202
    verbose: Prints debug info.
    output_stream: A stream to write output from presubmit tests to.
    input_stream: A stream to read input from the user.
1203
    default_presubmit: A default presubmit script to execute in any case.
1204
    may_prompt: Enable (y/n) questions on warning or error.
1205
    rietveld_obj: rietveld.Rietveld object.
1206
    gerrit_obj: provides basic Gerrit codereview functionality.
1207
    dry_run: if true, some Checks will be skipped.
1208

1209 1210 1211 1212
  Warning:
    If may_prompt is true, output_stream SHOULD be sys.stdout and input_stream
    SHOULD be sys.stdin.

1213
  Return:
1214 1215
    A PresubmitOutput object. Use output.should_continue() to figure out
    if there were errors or warnings and the caller should abort.
1216
  """
1217 1218 1219 1220 1221
  old_environ = os.environ
  try:
    # Make sure python subprocesses won't generate .pyc files.
    os.environ = os.environ.copy()
    os.environ['PYTHONDONTWRITEBYTECODE'] = '1'
1222

1223 1224 1225
    output = PresubmitOutput(input_stream, output_stream)
    if committing:
      output.write("Running presubmit commit checks ...\n")
1226
    else:
1227 1228 1229
      output.write("Running presubmit upload checks ...\n")
    start_time = time.time()
    presubmit_files = ListRelevantPresubmitFiles(
1230
        change.AbsoluteLocalPaths(), change.RepositoryRoot())
1231
    if not presubmit_files and verbose:
1232
      output.write("Warning, no PRESUBMIT.py found.\n")
1233
    results = []
1234
    executer = PresubmitExecuter(change, committing, rietveld_obj, verbose,
1235
                                 gerrit_obj, dry_run)
1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258
    if default_presubmit:
      if verbose:
        output.write("Running default presubmit script.\n")
      fake_path = os.path.join(change.RepositoryRoot(), 'PRESUBMIT.py')
      results += executer.ExecPresubmitScript(default_presubmit, fake_path)
    for filename in presubmit_files:
      filename = os.path.abspath(filename)
      if verbose:
        output.write("Running %s\n" % filename)
      # Accept CRLF presubmit script.
      presubmit_script = gclient_utils.FileRead(filename, 'rU')
      results += executer.ExecPresubmitScript(presubmit_script, filename)

    errors = []
    notifications = []
    warnings = []
    for result in results:
      if result.fatal:
        errors.append(result)
      elif result.should_prompt:
        warnings.append(result)
      else:
        notifications.append(result)
1259

1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281
    output.write('\n')
    for name, items in (('Messages', notifications),
                        ('Warnings', warnings),
                        ('ERRORS', errors)):
      if items:
        output.write('** Presubmit %s **\n' % name)
        for item in items:
          item.handle(output)
          output.write('\n')

    total_time = time.time() - start_time
    if total_time > 1.0:
      output.write("Presubmit checks took %.1fs to calculate.\n\n" % total_time)

    if not errors:
      if not warnings:
        output.write('Presubmit checks passed.\n')
      elif may_prompt:
        output.prompt_yes_no('There were presubmit warnings. '
                            'Are you sure you wish to continue? (y/N): ')
      else:
        output.fail()
1282

1283 1284 1285
    global _ASKED_FOR_FEEDBACK
    # Ask for feedback one time out of 5.
    if (len(results) and random.randint(0, 4) == 0 and not _ASKED_FOR_FEEDBACK):
1286 1287 1288 1289
      output.write(
          'Was the presubmit check useful? If not, run "git cl presubmit -v"\n'
          'to figure out which PRESUBMIT.py was run, then run git blame\n'
          'on the file to figure out who to ask for help.\n')
1290 1291 1292 1293
      _ASKED_FOR_FEEDBACK = True
    return output
  finally:
    os.environ = old_environ
1294 1295 1296 1297


def ScanSubDirs(mask, recursive):
  if not recursive:
pgervais@chromium.org's avatar
pgervais@chromium.org committed
1298
    return [x for x in glob.glob(mask) if x not in ('.svn', '.git')]
1299 1300 1301 1302 1303
  else:
    results = []
    for root, dirs, files in os.walk('.'):
      if '.svn' in dirs:
        dirs.remove('.svn')
1304 1305
      if '.git' in dirs:
        dirs.remove('.git')
1306 1307 1308 1309 1310 1311 1312
      for name in files:
        if fnmatch.fnmatch(name, mask):
          results.append(os.path.join(root, name))
    return results


def ParseFiles(args, recursive):
1313
  logging.debug('Searching for %s', args)
1314 1315
  files = []
  for arg in args:
1316
    files.extend([('M', f) for f in ScanSubDirs(arg, recursive)])
1317 1318 1319
  return files


1320 1321 1322
def load_files(options, args):
  """Tries to determine the SCM."""
  files = []
1323 1324
  if args:
    files = ParseFiles(args, options.recursive)
1325 1326
  change_scm = scm.determine_scm(options.root)
  if change_scm == 'git':
1327
    change_class = GitChange
1328
    upstream = options.upstream or None
1329
    if not files:
1330
      files = scm.GIT.CaptureStatus([], options.root, upstream)
1331
  else:
1332
    logging.info('Doesn\'t seem under source control. Got %d files', len(args))
1333
    if not files:
1334 1335 1336 1337 1338
      return None, None
    change_class = Change
  return change_class, files


1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356
class NonexistantCannedCheckFilter(Exception):
  pass


@contextlib.contextmanager
def canned_check_filter(method_names):
  filtered = {}
  try:
    for method_name in method_names:
      if not hasattr(presubmit_canned_checks, method_name):
        raise NonexistantCannedCheckFilter(method_name)
      filtered[method_name] = getattr(presubmit_canned_checks, method_name)
      setattr(presubmit_canned_checks, method_name, lambda *_a, **_kw: [])
    yield
  finally:
    for name, method in filtered.iteritems():
      setattr(presubmit_canned_checks, name, method)

1357

1358
def CallCommand(cmd_data):
1359 1360 1361 1362 1363
  """Runs an external program, potentially from a child process created by the
  multiprocessing module.

  multiprocessing needs a top level function with a single argument.
  """
1364 1365 1366
  cmd_data.kwargs['stdout'] = subprocess.PIPE
  cmd_data.kwargs['stderr'] = subprocess.STDOUT
  try:
1367
    start = time.time()
1368
    (out, _), code = subprocess.communicate(cmd_data.cmd, **cmd_data.kwargs)
1369
    duration = time.time() - start
1370
  except OSError as e:
1371 1372 1373 1374
    duration = time.time() - start
    return cmd_data.message(
        '%s exec failure (%4.2fs)\n   %s' % (cmd_data.name, duration, e))
  if code != 0:
1375
    return cmd_data.message(
1376 1377 1378
        '%s (%4.2fs) failed\n%s' % (cmd_data.name, duration, out))
  if cmd_data.info:
    return cmd_data.info('%s (%4.2fs)' % (cmd_data.name, duration))
1379

1380

1381
def main(argv=None):
1382
  parser = optparse.OptionParser(usage="%prog [options] <files...>",
1383
                                 version="%prog " + str(__version__))
1384
  parser.add_option("-c", "--commit", action="store_true", default=False,
1385
                   help="Use commit instead of upload checks")
1386 1387
  parser.add_option("-u", "--upload", action="store_false", dest='commit',
                   help="Use upload instead of commit checks")
1388 1389
  parser.add_option("-r", "--recursive", action="store_true",
                   help="Act recursively")
1390 1391
  parser.add_option("-v", "--verbose", action="count", default=0,
                   help="Use 2 times for more debug info")
1392
  parser.add_option("--name", default='no name')
1393
  parser.add_option("--author")
1394 1395 1396
  parser.add_option("--description", default='')
  parser.add_option("--issue", type='int', default=0)
  parser.add_option("--patchset", type='int', default=0)
1397 1398 1399 1400 1401
  parser.add_option("--root", default=os.getcwd(),
                    help="Search for PRESUBMIT.py up to this directory. "
                    "If inherit-review-settings-ok is present in this "
                    "directory, parent directories up to the root file "
                    "system directories will also be searched.")
1402 1403 1404
  parser.add_option("--upstream",
                    help="Git only: the base ref or upstream branch against "
                    "which the diff should be computed.")
1405 1406
  parser.add_option("--default_presubmit")
  parser.add_option("--may_prompt", action='store_true', default=False)
1407 1408 1409 1410
  parser.add_option("--skip_canned", action='append', default=[],
                    help="A list of checks to skip which appear in "
                    "presubmit_canned_checks. Can be provided multiple times "
                    "to skip multiple canned checks.")
1411 1412
  parser.add_option("--dry_run", action='store_true',
                    help=optparse.SUPPRESS_HELP)
1413
  parser.add_option("--gerrit_url", help=optparse.SUPPRESS_HELP)
1414 1415
  parser.add_option("--gerrit_fetch", action='store_true',
                    help=optparse.SUPPRESS_HELP)
1416 1417
  parser.add_option("--rietveld_url", help=optparse.SUPPRESS_HELP)
  parser.add_option("--rietveld_email", help=optparse.SUPPRESS_HELP)
1418 1419
  parser.add_option("--rietveld_fetch", action='store_true', default=False,
                    help=optparse.SUPPRESS_HELP)
1420 1421 1422 1423
  # These are for OAuth2 authentication for bots. See also apply_issue.py
  parser.add_option("--rietveld_email_file", help=optparse.SUPPRESS_HELP)
  parser.add_option("--rietveld_private_key_file", help=optparse.SUPPRESS_HELP)

1424
  # TODO(phajdan.jr): Update callers and remove obsolete --trybot-json .
1425 1426
  parser.add_option("--trybot-json",
                    help="Output trybot information to the file specified.")
1427
  auth.add_auth_options(parser)
1428
  options, args = parser.parse_args(argv)
1429
  auth_config = auth.extract_auth_config_from_options(options)
1430

1431
  if options.verbose >= 2:
1432
    logging.basicConfig(level=logging.DEBUG)
1433 1434 1435 1436
  elif options.verbose:
    logging.basicConfig(level=logging.INFO)
  else:
    logging.basicConfig(level=logging.ERROR)
1437

1438 1439 1440 1441 1442 1443
  if (any((options.rietveld_url, options.rietveld_email_file,
           options.rietveld_fetch, options.rietveld_private_key_file))
      and any((options.gerrit_url, options.gerrit_fetch))):
    parser.error('Options for only codereview --rietveld_* or --gerrit_* '
                 'allowed')

1444 1445 1446 1447 1448 1449 1450
  if options.rietveld_email and options.rietveld_email_file:
    parser.error("Only one of --rietveld_email or --rietveld_email_file "
                 "can be passed to this program.")
  if options.rietveld_email_file:
    with open(options.rietveld_email_file, "rb") as f:
      options.rietveld_email = f.read().strip()

1451 1452 1453
  change_class, files = load_files(options, args)
  if not change_class:
    parser.error('For unversioned directory, <files> is not optional.')
1454
  logging.info('Found %d file(s).', len(files))
1455

1456 1457
  rietveld_obj, gerrit_obj = None, None

1458
  if options.rietveld_url:
1459
    # The empty password is permitted: '' is not None.
1460
    if options.rietveld_private_key_file:
1461 1462 1463 1464 1465
      rietveld_obj = rietveld.JwtOAuth2Rietveld(
        options.rietveld_url,
        options.rietveld_email,
        options.rietveld_private_key_file)
    else:
1466 1467
      rietveld_obj = rietveld.CachingRietveld(
        options.rietveld_url,
1468 1469
        auth_config,
        options.rietveld_email)
1470 1471 1472 1473 1474 1475 1476
    if options.rietveld_fetch:
      assert options.issue
      props = rietveld_obj.get_issue_properties(options.issue, False)
      options.author = props['owner_email']
      options.description = props['description']
      logging.info('Got author: "%s"', options.author)
      logging.info('Got description: """\n%s\n"""', options.description)
1477 1478

  if options.gerrit_url and options.gerrit_fetch:
1479
    assert options.issue and options.patchset
1480 1481 1482 1483 1484
    rietveld_obj = None
    gerrit_obj = GerritAccessor(urlparse.urlparse(options.gerrit_url).netloc)
    options.author = gerrit_obj.GetChangeOwner(options.issue)
    options.description = gerrit_obj.GetChangeDescription(options.issue,
                                                          options.patchset)
1485 1486 1487
    logging.info('Got author: "%s"', options.author)
    logging.info('Got description: """\n%s\n"""', options.description)

1488
  try:
1489 1490 1491
    with canned_check_filter(options.skip_canned):
      results = DoPresubmitChecks(
          change_class(options.name,
1492 1493 1494 1495 1496 1497 1498
                       options.description,
                       options.root,
                       files,
                       options.issue,
                       options.patchset,
                       options.author,
                       upstream=options.upstream),
1499 1500 1501 1502 1503 1504
          options.commit,
          options.verbose,
          sys.stdout,
          sys.stdin,
          options.default_presubmit,
          options.may_prompt,
1505
          rietveld_obj,
1506
          gerrit_obj,
1507
          options.dry_run)
1508
    return not results.should_continue()
1509 1510 1511 1512
  except NonexistantCannedCheckFilter, e:
    print >> sys.stderr, (
      'Attempted to skip nonexistent canned presubmit check: %s' % e.message)
    return 2
1513 1514 1515 1516 1517
  except PresubmitFailure, e:
    print >> sys.stderr, e
    print >> sys.stderr, 'Maybe your depot_tools is out of date?'
    print >> sys.stderr, 'If all fails, contact maruel@'
    return 2
1518 1519 1520


if __name__ == '__main__':
1521
  fix_encoding.fix_encoding()
1522 1523 1524 1525
  try:
    sys.exit(main())
  except KeyboardInterrupt:
    sys.stderr.write('interrupted\n')
1526
    sys.exit(2)