blob: a4ae07824d68a97ea3e2235f37f65c7367b8d32d [file] [log] [blame]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001#!/usr/bin/python
maruel@chromium.orgcb2985f2010-11-03 14:08:31 +00002# Copyright (c) 2010 The Chromium Authors. All rights reserved.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00003# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Enables directory-specific presubmit checks to run at upload and/or commit.
7"""
8
maruel@chromium.orgb1901a62010-06-16 00:18:47 +00009__version__ = '1.3.5'
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000010
11# TODO(joi) Add caching where appropriate/needed. The API is designed to allow
12# caching (between all different invocations of presubmit scripts for a given
13# change). We should add it as our presubmit scripts start feeling slow.
14
15import cPickle # Exposed through the API.
16import cStringIO # Exposed through the API.
17import exceptions
18import fnmatch
19import glob
maruel@chromium.orgdf1595a2009-06-11 02:00:13 +000020import logging
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000021import marshal # Exposed through the API.
22import optparse
23import os # Somewhat exposed through the API.
24import pickle # Exposed through the API.
maruel@chromium.orgce8e46b2009-06-26 22:31:51 +000025import random
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000026import re # Exposed through the API.
27import subprocess # Exposed through the API.
28import sys # Parts exposed through API.
29import tempfile # Exposed through the API.
jam@chromium.org2a891dc2009-08-20 20:33:37 +000030import time
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +000031import traceback # Exposed through the API.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000032import types
maruel@chromium.org1487d532009-06-06 00:22:57 +000033import unittest # Exposed through the API.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000034import urllib2 # Exposed through the API.
maruel@chromium.orgcb2985f2010-11-03 14:08:31 +000035from warnings import warn
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000036
maruel@chromium.orgfb11c7b2010-03-18 18:22:14 +000037try:
38 import simplejson as json
39except ImportError:
40 try:
41 import json
tony@chromium.org5e9f2ed2010-04-08 01:38:19 +000042 # Some versions of python2.5 have an incomplete json module. Check to make
43 # sure loads exists.
maruel@chromium.orgb17b55b2010-11-03 14:42:37 +000044 # Statement seems to have no effect
45 # pylint: disable=W0104
tony@chromium.org5e9f2ed2010-04-08 01:38:19 +000046 json.loads
47 except (ImportError, AttributeError):
maruel@chromium.org59c7ba62010-03-20 00:13:07 +000048 # Import the one included in depot_tools.
maruel@chromium.orgd08cb1e2010-03-20 00:25:19 +000049 sys.path.append(os.path.join(os.path.dirname(__file__), 'third_party'))
50 import simplejson as json
maruel@chromium.orgfb11c7b2010-03-18 18:22:14 +000051
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000052# Local imports.
maruel@chromium.org5aeb7dd2009-11-17 18:09:01 +000053import gclient_utils
dpranke@chromium.org2a009622011-03-01 02:43:31 +000054import owners
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000055import presubmit_canned_checks
maruel@chromium.org5aeb7dd2009-11-17 18:09:01 +000056import scm
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000057
58
maruel@chromium.orgce8e46b2009-06-26 22:31:51 +000059# Ask for feedback only once in program lifetime.
60_ASKED_FOR_FEEDBACK = False
61
62
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000063class NotImplementedException(Exception):
64 """We're leaving placeholders in a bunch of places to remind us of the
65 design of the API, but we have not implemented all of it yet. Implement as
66 the need arises.
67 """
68 pass
69
70
71def normpath(path):
72 '''Version of os.path.normpath that also changes backward slashes to
73 forward slashes when not running on Windows.
74 '''
75 # This is safe to always do because the Windows version of os.path.normpath
76 # will replace forward slashes with backward slashes.
77 path = path.replace(os.sep, '/')
78 return os.path.normpath(path)
79
maruel@chromium.orgcb2985f2010-11-03 14:08:31 +000080
gspencer@google.comefb94502009-10-09 17:57:08 +000081def PromptYesNo(input_stream, output_stream, prompt):
82 output_stream.write(prompt)
83 response = input_stream.readline().strip().lower()
84 return response == 'y' or response == 'yes'
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000085
maruel@chromium.orgcb2985f2010-11-03 14:08:31 +000086
87def _RightHandSideLinesImpl(affected_files):
88 """Implements RightHandSideLines for InputApi and GclChange."""
89 for af in affected_files:
maruel@chromium.orgab05d582011-02-09 23:41:22 +000090 lines = af.ChangedContents()
maruel@chromium.orgcb2985f2010-11-03 14:08:31 +000091 for line in lines:
maruel@chromium.orgab05d582011-02-09 23:41:22 +000092 yield (af, line[0], line[1])
maruel@chromium.orgcb2985f2010-11-03 14:08:31 +000093
94
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000095class OutputApi(object):
96 """This class (more like a module) gets passed to presubmit scripts so that
97 they can specify various types of results.
98 """
maruel@chromium.orgb17b55b2010-11-03 14:42:37 +000099 # Method could be a function
100 # pylint: disable=R0201
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000101 class PresubmitResult(object):
102 """Base class for result objects."""
103
104 def __init__(self, message, items=None, long_text=''):
105 """
106 message: A short one-line message to indicate errors.
107 items: A list of short strings to indicate where errors occurred.
108 long_text: multi-line text output, e.g. from another tool
109 """
110 self._message = message
111 self._items = []
112 if items:
113 self._items = items
114 self._long_text = long_text.rstrip()
115
116 def _Handle(self, output_stream, input_stream, may_prompt=True):
117 """Writes this result to the output stream.
118
119 Args:
120 output_stream: Where to write
121
122 Returns:
123 True if execution may continue, False otherwise.
124 """
125 output_stream.write(self._message)
126 output_stream.write('\n')
estade@chromium.org593258b2010-01-28 00:04:36 +0000127 if len(self._items) > 0:
128 output_stream.write(' ' + ' \\\n '.join(map(str, self._items)) + '\n')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000129 if self._long_text:
maruel@chromium.org310b3552010-11-01 13:23:35 +0000130 # Sometimes self._long_text is a ascii string, a codepage string
131 # (on windows), or a unicode object.
132 try:
133 long_text = self._long_text.decode()
134 except UnicodeDecodeError:
135 long_text = self._long_text.decode('ascii', 'replace')
136
maruel@chromium.orgb0dfd352009-06-10 14:12:54 +0000137 output_stream.write('\n***************\n%s\n***************\n' %
maruel@chromium.org310b3552010-11-01 13:23:35 +0000138 long_text)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000139
140 if self.ShouldPrompt() and may_prompt:
gspencer@google.comefb94502009-10-09 17:57:08 +0000141 if not PromptYesNo(input_stream, output_stream,
142 'Are you sure you want to continue? (y/N): '):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000143 return False
144
145 return not self.IsFatal()
146
147 def IsFatal(self):
148 """An error that is fatal stops g4 mail/submit immediately, i.e. before
149 other presubmit scripts are run.
150 """
151 return False
152
153 def ShouldPrompt(self):
154 """Whether this presubmit result should result in a prompt warning."""
155 return False
156
157 class PresubmitError(PresubmitResult):
158 """A hard presubmit error."""
159 def IsFatal(self):
160 return True
161
162 class PresubmitPromptWarning(PresubmitResult):
163 """An warning that prompts the user if they want to continue."""
164 def ShouldPrompt(self):
165 return True
166
167 class PresubmitNotifyResult(PresubmitResult):
168 """Just print something to the screen -- but it's not even a warning."""
169 pass
170
171 class MailTextResult(PresubmitResult):
172 """A warning that should be included in the review request email."""
173 def __init__(self, *args, **kwargs):
maruel@chromium.orgcb2985f2010-11-03 14:08:31 +0000174 super(OutputApi.MailTextResult, self).__init__()
175 raise NotImplementedException()
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000176
177
178class InputApi(object):
179 """An instance of this object is passed to presubmit scripts so they can
180 know stuff about the change they're looking at.
181 """
maruel@chromium.orgb17b55b2010-11-03 14:42:37 +0000182 # Method could be a function
183 # pylint: disable=R0201
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000184
maruel@chromium.org3410d912009-06-09 20:56:16 +0000185 # File extensions that are considered source files from a style guide
186 # perspective. Don't modify this list from a presubmit script!
187 DEFAULT_WHITE_LIST = (
188 # C++ and friends
maruel@chromium.orga73d7932010-01-28 23:50:06 +0000189 r".*\.c$", r".*\.cc$", r".*\.cpp$", r".*\.h$", r".*\.m$", r".*\.mm$",
maruel@chromium.orgab05d582011-02-09 23:41:22 +0000190 r".*\.inl$", r".*\.asm$", r".*\.hxx$", r".*\.hpp$", r".*\.s$", r".*\.S$",
maruel@chromium.org3410d912009-06-09 20:56:16 +0000191 # Scripts
maruel@chromium.orga73d7932010-01-28 23:50:06 +0000192 r".*\.js$", r".*\.py$", r".*\.sh$", r".*\.rb$", r".*\.pl$", r".*\.pm$",
maruel@chromium.orgef776482010-01-28 16:04:32 +0000193 # No extension at all, note that ALL CAPS files are black listed in
194 # DEFAULT_BLACK_LIST below.
maruel@chromium.orga73d7932010-01-28 23:50:06 +0000195 r"(^|.*?[\\\/])[^.]+$",
maruel@chromium.org3410d912009-06-09 20:56:16 +0000196 # Other
maruel@chromium.orga73d7932010-01-28 23:50:06 +0000197 r".*\.java$", r".*\.mk$", r".*\.am$",
maruel@chromium.org3410d912009-06-09 20:56:16 +0000198 )
199
200 # Path regexp that should be excluded from being considered containing source
201 # files. Don't modify this list from a presubmit script!
202 DEFAULT_BLACK_LIST = (
203 r".*\bexperimental[\\\/].*",
204 r".*\bthird_party[\\\/].*",
205 # Output directories (just in case)
206 r".*\bDebug[\\\/].*",
207 r".*\bRelease[\\\/].*",
208 r".*\bxcodebuild[\\\/].*",
209 r".*\bsconsbuild[\\\/].*",
210 # All caps files like README and LICENCE.
maruel@chromium.orgab05d582011-02-09 23:41:22 +0000211 r".*\b[A-Z0-9_]{2,}$",
maruel@chromium.orgdf1595a2009-06-11 02:00:13 +0000212 # SCM (can happen in dual SCM configuration). (Slightly over aggressive)
maruel@chromium.org5d0dc432011-01-03 02:40:37 +0000213 r"(|.*[\\\/])\.git[\\\/].*",
214 r"(|.*[\\\/])\.svn[\\\/].*",
maruel@chromium.org3410d912009-06-09 20:56:16 +0000215 )
216
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +0000217 def __init__(self, change, presubmit_path, is_committing):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000218 """Builds an InputApi object.
219
220 Args:
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000221 change: A presubmit.Change object.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000222 presubmit_path: The path to the presubmit script being processed.
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +0000223 is_committing: True if the change is about to be committed.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000224 """
maruel@chromium.org9711bba2009-05-22 23:51:39 +0000225 # Version number of the presubmit_support script.
226 self.version = [int(x) for x in __version__.split('.')]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000227 self.change = change
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +0000228 self.is_committing = is_committing
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000229
230 # We expose various modules and functions as attributes of the input_api
231 # so that presubmit scripts don't have to import them.
232 self.basename = os.path.basename
233 self.cPickle = cPickle
234 self.cStringIO = cStringIO
maruel@chromium.orgfb11c7b2010-03-18 18:22:14 +0000235 self.json = json
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000236 self.os_path = os.path
237 self.pickle = pickle
238 self.marshal = marshal
239 self.re = re
240 self.subprocess = subprocess
241 self.tempfile = tempfile
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +0000242 self.traceback = traceback
maruel@chromium.org1487d532009-06-06 00:22:57 +0000243 self.unittest = unittest
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000244 self.urllib2 = urllib2
245
maruel@chromium.orgc0b22972009-06-25 16:19:14 +0000246 # To easily fork python.
247 self.python_executable = sys.executable
248 self.environ = os.environ
249
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000250 # InputApi.platform is the platform you're currently running on.
251 self.platform = sys.platform
252
253 # The local path of the currently-being-processed presubmit script.
maruel@chromium.org3d235242009-05-15 12:40:48 +0000254 self._current_presubmit_path = os.path.dirname(presubmit_path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000255
256 # We carry the canned checks so presubmit scripts can easily use them.
257 self.canned_checks = presubmit_canned_checks
258
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000259 # TODO(dpranke): figure out a list of all approved owners for a repo
260 # in order to be able to handle wildcard OWNERS files?
261 self.owners_db = owners.Database(change.RepositoryRoot(),
262 fopen=file, os_path=self.os_path)
263
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000264 def PresubmitLocalPath(self):
265 """Returns the local path of the presubmit script currently being run.
266
267 This is useful if you don't want to hard-code absolute paths in the
268 presubmit script. For example, It can be used to find another file
269 relative to the PRESUBMIT.py script, so the whole tree can be branched and
270 the presubmit script still works, without editing its content.
271 """
maruel@chromium.org3d235242009-05-15 12:40:48 +0000272 return self._current_presubmit_path
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000273
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000274 def DepotToLocalPath(self, depot_path):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000275 """Translate a depot path to a local path (relative to client root).
276
277 Args:
278 Depot path as a string.
279
280 Returns:
281 The local path of the depot path under the user's current client, or None
282 if the file is not mapped.
283
284 Remember to check for the None case and show an appropriate error!
285 """
maruel@chromium.org5aeb7dd2009-11-17 18:09:01 +0000286 local_path = scm.SVN.CaptureInfo(depot_path).get('Path')
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000287 if local_path:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000288 return local_path
289
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000290 def LocalToDepotPath(self, local_path):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000291 """Translate a local path to a depot path.
292
293 Args:
294 Local path (relative to current directory, or absolute) as a string.
295
296 Returns:
297 The depot path (SVN URL) of the file if mapped, otherwise None.
298 """
maruel@chromium.org5aeb7dd2009-11-17 18:09:01 +0000299 depot_path = scm.SVN.CaptureInfo(local_path).get('URL')
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000300 if depot_path:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000301 return depot_path
302
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000303 def AffectedFiles(self, include_dirs=False, include_deletes=True):
304 """Same as input_api.change.AffectedFiles() except only lists files
305 (and optionally directories) in the same directory as the current presubmit
306 script, or subdirectories thereof.
307 """
maruel@chromium.org3d235242009-05-15 12:40:48 +0000308 dir_with_slash = normpath("%s/" % self.PresubmitLocalPath())
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000309 if len(dir_with_slash) == 1:
310 dir_with_slash = ''
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000311 return filter(
312 lambda x: normpath(x.AbsoluteLocalPath()).startswith(dir_with_slash),
313 self.change.AffectedFiles(include_dirs, include_deletes))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000314
315 def LocalPaths(self, include_dirs=False):
316 """Returns local paths of input_api.AffectedFiles()."""
317 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
318
319 def AbsoluteLocalPaths(self, include_dirs=False):
320 """Returns absolute local paths of input_api.AffectedFiles()."""
321 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
322
323 def ServerPaths(self, include_dirs=False):
324 """Returns server paths of input_api.AffectedFiles()."""
325 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
326
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000327 def AffectedTextFiles(self, include_deletes=None):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000328 """Same as input_api.change.AffectedTextFiles() except only lists files
329 in the same directory as the current presubmit script, or subdirectories
330 thereof.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000331 """
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000332 if include_deletes is not None:
maruel@chromium.orgcb2985f2010-11-03 14:08:31 +0000333 warn("AffectedTextFiles(include_deletes=%s)"
334 " is deprecated and ignored" % str(include_deletes),
335 category=DeprecationWarning,
336 stacklevel=2)
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000337 return filter(lambda x: x.IsTextFile(),
338 self.AffectedFiles(include_dirs=False, include_deletes=False))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000339
maruel@chromium.org3410d912009-06-09 20:56:16 +0000340 def FilterSourceFile(self, affected_file, white_list=None, black_list=None):
341 """Filters out files that aren't considered "source file".
342
343 If white_list or black_list is None, InputApi.DEFAULT_WHITE_LIST
344 and InputApi.DEFAULT_BLACK_LIST is used respectively.
345
346 The lists will be compiled as regular expression and
347 AffectedFile.LocalPath() needs to pass both list.
348
349 Note: Copy-paste this function to suit your needs or use a lambda function.
350 """
maruel@chromium.orgcb2985f2010-11-03 14:08:31 +0000351 def Find(affected_file, items):
maruel@chromium.orgab05d582011-02-09 23:41:22 +0000352 local_path = affected_file.LocalPath()
maruel@chromium.orgcb2985f2010-11-03 14:08:31 +0000353 for item in items:
maruel@chromium.orgdf1595a2009-06-11 02:00:13 +0000354 if self.re.match(item, local_path):
355 logging.debug("%s matched %s" % (item, local_path))
maruel@chromium.org3410d912009-06-09 20:56:16 +0000356 return True
357 return False
358 return (Find(affected_file, white_list or self.DEFAULT_WHITE_LIST) and
359 not Find(affected_file, black_list or self.DEFAULT_BLACK_LIST))
360
361 def AffectedSourceFiles(self, source_file):
362 """Filter the list of AffectedTextFiles by the function source_file.
363
364 If source_file is None, InputApi.FilterSourceFile() is used.
365 """
366 if not source_file:
367 source_file = self.FilterSourceFile
368 return filter(source_file, self.AffectedTextFiles())
369
370 def RightHandSideLines(self, source_file_filter=None):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000371 """An iterator over all text lines in "new" version of changed files.
372
373 Only lists lines from new or modified text files in the change that are
374 contained by the directory of the currently executing presubmit script.
375
376 This is useful for doing line-by-line regex checks, like checking for
377 trailing whitespace.
378
379 Yields:
380 a 3 tuple:
381 the AffectedFile instance of the current file;
382 integer line number (1-based); and
383 the contents of the line as a string.
maruel@chromium.org1487d532009-06-06 00:22:57 +0000384
385 Note: The cariage return (LF or CR) is stripped off.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000386 """
maruel@chromium.org3410d912009-06-09 20:56:16 +0000387 files = self.AffectedSourceFiles(source_file_filter)
maruel@chromium.orgcb2985f2010-11-03 14:08:31 +0000388 return _RightHandSideLinesImpl(files)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000389
maruel@chromium.orge3608df2009-11-10 20:22:57 +0000390 def ReadFile(self, file_item, mode='r'):
maruel@chromium.org44a17ad2009-06-08 14:14:35 +0000391 """Reads an arbitrary file.
thestig@chromium.orgda8cddd2009-08-13 00:25:55 +0000392
maruel@chromium.org44a17ad2009-06-08 14:14:35 +0000393 Deny reading anything outside the repository.
394 """
maruel@chromium.orge3608df2009-11-10 20:22:57 +0000395 if isinstance(file_item, AffectedFile):
396 file_item = file_item.AbsoluteLocalPath()
397 if not file_item.startswith(self.change.RepositoryRoot()):
maruel@chromium.org44a17ad2009-06-08 14:14:35 +0000398 raise IOError('Access outside the repository root is denied.')
maruel@chromium.org5aeb7dd2009-11-17 18:09:01 +0000399 return gclient_utils.FileRead(file_item, mode)
maruel@chromium.org44a17ad2009-06-08 14:14:35 +0000400
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000401
402class AffectedFile(object):
403 """Representation of a file in a change."""
maruel@chromium.orgb17b55b2010-11-03 14:42:37 +0000404 # Method could be a function
405 # pylint: disable=R0201
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000406 def __init__(self, path, action, repository_root=''):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000407 self._path = path
408 self._action = action
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000409 self._local_root = repository_root
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000410 self._is_directory = None
411 self._properties = {}
maruel@chromium.org5d0dc432011-01-03 02:40:37 +0000412 logging.debug('%s(%s)' % (self.__class__.__name__, self._path))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000413
414 def ServerPath(self):
415 """Returns a path string that identifies the file in the SCM system.
416
417 Returns the empty string if the file does not exist in SCM.
418 """
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000419 return ""
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000420
421 def LocalPath(self):
422 """Returns the path of this file on the local disk relative to client root.
423 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000424 return normpath(self._path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000425
426 def AbsoluteLocalPath(self):
427 """Returns the absolute path of this file on the local disk.
428 """
chase@chromium.org8e416c82009-10-06 04:30:44 +0000429 return os.path.abspath(os.path.join(self._local_root, self.LocalPath()))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000430
431 def IsDirectory(self):
432 """Returns true if this object is a directory."""
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000433 if self._is_directory is None:
434 path = self.AbsoluteLocalPath()
435 self._is_directory = (os.path.exists(path) and
436 os.path.isdir(path))
437 return self._is_directory
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000438
439 def Action(self):
440 """Returns the action on this opened file, e.g. A, M, D, etc."""
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000441 # TODO(maruel): Somewhat crappy, Could be "A" or "A +" for svn but
442 # different for other SCM.
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000443 return self._action
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000444
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000445 def Property(self, property_name):
446 """Returns the specified SCM property of this file, or None if no such
447 property.
448 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000449 return self._properties.get(property_name, None)
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000450
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000451 def IsTextFile(self):
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000452 """Returns True if the file is a text file and not a binary file.
thestig@chromium.orgda8cddd2009-08-13 00:25:55 +0000453
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000454 Deleted files are not text file."""
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000455 raise NotImplementedError() # Implement when needed
456
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000457 def NewContents(self):
458 """Returns an iterator over the lines in the new version of file.
459
460 The new version is the file in the user's workspace, i.e. the "right hand
461 side".
462
463 Contents will be empty if the file is a directory or does not exist.
maruel@chromium.org1487d532009-06-06 00:22:57 +0000464 Note: The cariage returns (LF or CR) are stripped off.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000465 """
466 if self.IsDirectory():
467 return []
468 else:
maruel@chromium.org5aeb7dd2009-11-17 18:09:01 +0000469 return gclient_utils.FileRead(self.AbsoluteLocalPath(),
470 'rU').splitlines()
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000471
472 def OldContents(self):
473 """Returns an iterator over the lines in the old version of file.
474
475 The old version is the file in depot, i.e. the "left hand side".
476 """
477 raise NotImplementedError() # Implement when needed
478
479 def OldFileTempPath(self):
480 """Returns the path on local disk where the old contents resides.
481
482 The old version is the file in depot, i.e. the "left hand side".
483 This is a read-only cached copy of the old contents. *DO NOT* try to
484 modify this file.
485 """
486 raise NotImplementedError() # Implement if/when needed.
487
maruel@chromium.orgab05d582011-02-09 23:41:22 +0000488 def ChangedContents(self):
489 """Returns a list of tuples (line number, line text) of all new lines.
490
491 This relies on the scm diff output describing each changed code section
492 with a line of the form
493
494 ^@@ <old line num>,<old size> <new line num>,<new size> @@$
495 """
496 new_lines = []
497 line_num = 0
498
499 if self.IsDirectory():
500 return []
501
502 for line in self.GenerateScmDiff().splitlines():
503 m = re.match(r'^@@ [0-9\,\+\-]+ \+([0-9]+)\,[0-9]+ @@', line)
504 if m:
505 line_num = int(m.groups(1)[0])
506 continue
507 if line.startswith('+') and not line.startswith('++'):
508 new_lines.append((line_num, line[1:]))
509 if not line.startswith('-'):
510 line_num += 1
511 return new_lines
512
maruel@chromium.org5de13972009-06-10 18:16:06 +0000513 def __str__(self):
514 return self.LocalPath()
515
maruel@chromium.orgab05d582011-02-09 23:41:22 +0000516 def GenerateScmDiff(self):
517 raise NotImplementedError() # Implemented in derived classes.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000518
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000519class SvnAffectedFile(AffectedFile):
520 """Representation of a file in a change out of a Subversion checkout."""
maruel@chromium.orgb17b55b2010-11-03 14:42:37 +0000521 # Method 'NNN' is abstract in class 'NNN' but is not overridden
522 # pylint: disable=W0223
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000523
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000524 def __init__(self, *args, **kwargs):
525 AffectedFile.__init__(self, *args, **kwargs)
526 self._server_path = None
527 self._is_text_file = None
528
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000529 def ServerPath(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000530 if self._server_path is None:
maruel@chromium.org5aeb7dd2009-11-17 18:09:01 +0000531 self._server_path = scm.SVN.CaptureInfo(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000532 self.AbsoluteLocalPath()).get('URL', '')
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000533 return self._server_path
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000534
535 def IsDirectory(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000536 if self._is_directory is None:
537 path = self.AbsoluteLocalPath()
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000538 if os.path.exists(path):
539 # Retrieve directly from the file system; it is much faster than
540 # querying subversion, especially on Windows.
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000541 self._is_directory = os.path.isdir(path)
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000542 else:
maruel@chromium.org5aeb7dd2009-11-17 18:09:01 +0000543 self._is_directory = scm.SVN.CaptureInfo(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000544 path).get('Node Kind') in ('dir', 'directory')
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000545 return self._is_directory
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000546
547 def Property(self, property_name):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000548 if not property_name in self._properties:
maruel@chromium.org5aeb7dd2009-11-17 18:09:01 +0000549 self._properties[property_name] = scm.SVN.GetFileProperty(
maruel@chromium.org196f8cb2009-06-11 00:32:06 +0000550 self.AbsoluteLocalPath(), property_name).rstrip()
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000551 return self._properties[property_name]
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000552
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000553 def IsTextFile(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000554 if self._is_text_file is None:
555 if self.Action() == 'D':
556 # A deleted file is not a text file.
557 self._is_text_file = False
558 elif self.IsDirectory():
559 self._is_text_file = False
560 else:
maruel@chromium.org5aeb7dd2009-11-17 18:09:01 +0000561 mime_type = scm.SVN.GetFileProperty(self.AbsoluteLocalPath(),
562 'svn:mime-type')
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000563 self._is_text_file = (not mime_type or mime_type.startswith('text/'))
564 return self._is_text_file
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000565
maruel@chromium.orgab05d582011-02-09 23:41:22 +0000566 def GenerateScmDiff(self):
maruel@chromium.org1f312812011-02-10 01:33:57 +0000567 return scm.SVN.GenerateDiff([self.AbsoluteLocalPath()])
568
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000569
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000570class GitAffectedFile(AffectedFile):
571 """Representation of a file in a change out of a git checkout."""
maruel@chromium.orgb17b55b2010-11-03 14:42:37 +0000572 # Method 'NNN' is abstract in class 'NNN' but is not overridden
573 # pylint: disable=W0223
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000574
575 def __init__(self, *args, **kwargs):
576 AffectedFile.__init__(self, *args, **kwargs)
577 self._server_path = None
578 self._is_text_file = None
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000579
580 def ServerPath(self):
581 if self._server_path is None:
582 raise NotImplementedException() # TODO(maruel) Implement.
583 return self._server_path
584
585 def IsDirectory(self):
586 if self._is_directory is None:
587 path = self.AbsoluteLocalPath()
588 if os.path.exists(path):
589 # Retrieve directly from the file system; it is much faster than
590 # querying subversion, especially on Windows.
591 self._is_directory = os.path.isdir(path)
592 else:
593 # raise NotImplementedException() # TODO(maruel) Implement.
594 self._is_directory = False
595 return self._is_directory
596
597 def Property(self, property_name):
598 if not property_name in self._properties:
599 raise NotImplementedException() # TODO(maruel) Implement.
600 return self._properties[property_name]
601
602 def IsTextFile(self):
603 if self._is_text_file is None:
604 if self.Action() == 'D':
605 # A deleted file is not a text file.
606 self._is_text_file = False
607 elif self.IsDirectory():
608 self._is_text_file = False
609 else:
610 # raise NotImplementedException() # TODO(maruel) Implement.
611 self._is_text_file = os.path.isfile(self.AbsoluteLocalPath())
612 return self._is_text_file
613
maruel@chromium.orgab05d582011-02-09 23:41:22 +0000614 def GenerateScmDiff(self):
615 return scm.GIT.GenerateDiff(self._local_root, files=[self.LocalPath(),])
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000616
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000617class Change(object):
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000618 """Describe a change.
619
620 Used directly by the presubmit scripts to query the current change being
621 tested.
thestig@chromium.orgda8cddd2009-08-13 00:25:55 +0000622
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000623 Instance members:
624 tags: Dictionnary of KEY=VALUE pairs found in the change description.
625 self.KEY: equivalent to tags['KEY']
626 """
627
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000628 _AFFECTED_FILES = AffectedFile
629
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000630 # Matches key/value (or "tag") lines in changelist descriptions.
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000631 _TAG_LINE_RE = re.compile(
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000632 '^\s*(?P<key>[A-Z][A-Z_0-9]*)\s*=\s*(?P<value>.*?)\s*$')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000633
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000634 def __init__(self, name, description, local_root, files, issue, patchset):
635 if files is None:
636 files = []
637 self._name = name
638 self._full_description = description
chase@chromium.org8e416c82009-10-06 04:30:44 +0000639 # Convert root into an absolute path.
640 self._local_root = os.path.abspath(local_root)
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000641 self.issue = issue
642 self.patchset = patchset
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000643
644 # TODO(dpranke): implement - get from the patchset?
645 self.approvers = set()
646
thestig@chromium.orgda8cddd2009-08-13 00:25:55 +0000647 self.scm = ''
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000648
649 # From the description text, build up a dictionary of key/value pairs
650 # plus the description minus all key/value or "tag" lines.
maruel@chromium.orgfa410372010-09-10 17:01:01 +0000651 description_without_tags = []
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000652 self.tags = {}
maruel@chromium.org8d5c9a52009-06-12 15:59:08 +0000653 for line in self._full_description.splitlines():
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000654 m = self._TAG_LINE_RE.match(line)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000655 if m:
656 self.tags[m.group('key')] = m.group('value')
657 else:
maruel@chromium.orgfa410372010-09-10 17:01:01 +0000658 description_without_tags.append(line)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000659
660 # Change back to text and remove whitespace at end.
maruel@chromium.orgfa410372010-09-10 17:01:01 +0000661 self._description_without_tags = (
662 '\n'.join(description_without_tags).rstrip())
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000663
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000664 self._affected_files = [
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000665 self._AFFECTED_FILES(info[1], info[0].strip(), self._local_root)
666 for info in files
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000667 ]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000668
maruel@chromium.org92022ec2009-06-11 01:59:28 +0000669 def Name(self):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000670 """Returns the change name."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000671 return self._name
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000672
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000673 def DescriptionText(self):
674 """Returns the user-entered changelist description, minus tags.
675
676 Any line in the user-provided description starting with e.g. "FOO="
677 (whitespace permitted before and around) is considered a tag line. Such
678 lines are stripped out of the description this function returns.
679 """
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000680 return self._description_without_tags
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000681
682 def FullDescriptionText(self):
683 """Returns the complete changelist description including tags."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000684 return self._full_description
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000685
686 def RepositoryRoot(self):
maruel@chromium.org92022ec2009-06-11 01:59:28 +0000687 """Returns the repository (checkout) root directory for this change,
688 as an absolute path.
689 """
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000690 return self._local_root
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000691
692 def __getattr__(self, attr):
maruel@chromium.org92022ec2009-06-11 01:59:28 +0000693 """Return tags directly as attributes on the object."""
694 if not re.match(r"^[A-Z_]*$", attr):
695 raise AttributeError(self, attr)
maruel@chromium.orge1a524f2009-05-27 14:43:46 +0000696 return self.tags.get(attr)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000697
698 def AffectedFiles(self, include_dirs=False, include_deletes=True):
699 """Returns a list of AffectedFile instances for all files in the change.
700
701 Args:
702 include_deletes: If false, deleted files will be filtered out.
703 include_dirs: True to include directories in the list
704
705 Returns:
706 [AffectedFile(path, action), AffectedFile(path, action)]
707 """
708 if include_dirs:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000709 affected = self._affected_files
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000710 else:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000711 affected = filter(lambda x: not x.IsDirectory(), self._affected_files)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000712
713 if include_deletes:
714 return affected
715 else:
716 return filter(lambda x: x.Action() != 'D', affected)
717
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000718 def AffectedTextFiles(self, include_deletes=None):
719 """Return a list of the existing text files in a change."""
720 if include_deletes is not None:
maruel@chromium.orgcb2985f2010-11-03 14:08:31 +0000721 warn("AffectedTextFiles(include_deletes=%s)"
722 " is deprecated and ignored" % str(include_deletes),
723 category=DeprecationWarning,
724 stacklevel=2)
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000725 return filter(lambda x: x.IsTextFile(),
726 self.AffectedFiles(include_dirs=False, include_deletes=False))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000727
728 def LocalPaths(self, include_dirs=False):
729 """Convenience function."""
730 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
731
732 def AbsoluteLocalPaths(self, include_dirs=False):
733 """Convenience function."""
734 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
735
736 def ServerPaths(self, include_dirs=False):
737 """Convenience function."""
738 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
739
740 def RightHandSideLines(self):
741 """An iterator over all text lines in "new" version of changed files.
742
743 Lists lines from new or modified text files in the change.
744
745 This is useful for doing line-by-line regex checks, like checking for
746 trailing whitespace.
747
748 Yields:
749 a 3 tuple:
750 the AffectedFile instance of the current file;
751 integer line number (1-based); and
752 the contents of the line as a string.
753 """
maruel@chromium.orgcb2985f2010-11-03 14:08:31 +0000754 return _RightHandSideLinesImpl(
755 x for x in self.AffectedFiles(include_deletes=False)
756 if x.IsTextFile())
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000757
758
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000759class SvnChange(Change):
760 _AFFECTED_FILES = SvnAffectedFile
761
thestig@chromium.orgda8cddd2009-08-13 00:25:55 +0000762 def __init__(self, *args, **kwargs):
763 Change.__init__(self, *args, **kwargs)
764 self.scm = 'svn'
thestig@chromium.org6bd31702009-09-02 23:29:07 +0000765 self._changelists = None
766
767 def _GetChangeLists(self):
768 """Get all change lists."""
769 if self._changelists == None:
770 previous_cwd = os.getcwd()
771 os.chdir(self.RepositoryRoot())
maruel@chromium.orgcb2985f2010-11-03 14:08:31 +0000772 # Need to import here to avoid circular dependency.
773 import gcl
thestig@chromium.org6bd31702009-09-02 23:29:07 +0000774 self._changelists = gcl.GetModifiedFiles()
775 os.chdir(previous_cwd)
776 return self._changelists
thestig@chromium.orgda8cddd2009-08-13 00:25:55 +0000777
778 def GetAllModifiedFiles(self):
779 """Get all modified files."""
thestig@chromium.org6bd31702009-09-02 23:29:07 +0000780 changelists = self._GetChangeLists()
thestig@chromium.orgda8cddd2009-08-13 00:25:55 +0000781 all_modified_files = []
782 for cl in changelists.values():
thestig@chromium.org6bd31702009-09-02 23:29:07 +0000783 all_modified_files.extend(
784 [os.path.join(self.RepositoryRoot(), f[1]) for f in cl])
thestig@chromium.orgda8cddd2009-08-13 00:25:55 +0000785 return all_modified_files
786
787 def GetModifiedFiles(self):
788 """Get modified files in the current CL."""
thestig@chromium.org6bd31702009-09-02 23:29:07 +0000789 changelists = self._GetChangeLists()
790 return [os.path.join(self.RepositoryRoot(), f[1])
791 for f in changelists[self.Name()]]
thestig@chromium.orgda8cddd2009-08-13 00:25:55 +0000792
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000793
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000794class GitChange(Change):
795 _AFFECTED_FILES = GitAffectedFile
796
thestig@chromium.orgda8cddd2009-08-13 00:25:55 +0000797 def __init__(self, *args, **kwargs):
798 Change.__init__(self, *args, **kwargs)
799 self.scm = 'git'
800
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000801
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000802def ListRelevantPresubmitFiles(files, root):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000803 """Finds all presubmit files that apply to a given set of source files.
804
maruel@chromium.orgb1901a62010-06-16 00:18:47 +0000805 If inherit-review-settings-ok is present right under root, looks for
806 PRESUBMIT.py in directories enclosing root.
807
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000808 Args:
809 files: An iterable container containing file paths.
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000810 root: Path where to stop searching.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000811
812 Return:
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000813 List of absolute paths of the existing PRESUBMIT.py scripts.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000814 """
maruel@chromium.orgb1901a62010-06-16 00:18:47 +0000815 files = [normpath(os.path.join(root, f)) for f in files]
816
817 # List all the individual directories containing files.
818 directories = set([os.path.dirname(f) for f in files])
819
820 # Ignore root if inherit-review-settings-ok is present.
821 if os.path.isfile(os.path.join(root, 'inherit-review-settings-ok')):
822 root = None
823
824 # Collect all unique directories that may contain PRESUBMIT.py.
825 candidates = set()
826 for directory in directories:
827 while True:
828 if directory in candidates:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000829 break
maruel@chromium.orgb1901a62010-06-16 00:18:47 +0000830 candidates.add(directory)
831 if directory == root:
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000832 break
maruel@chromium.orgb1901a62010-06-16 00:18:47 +0000833 parent_dir = os.path.dirname(directory)
834 if parent_dir == directory:
835 # We hit the system root directory.
836 break
837 directory = parent_dir
838
839 # Look for PRESUBMIT.py in all candidate directories.
840 results = []
841 for directory in sorted(list(candidates)):
842 p = os.path.join(directory, 'PRESUBMIT.py')
843 if os.path.isfile(p):
844 results.append(p)
845
maruel@chromium.org5d0dc432011-01-03 02:40:37 +0000846 logging.debug('Presubmit files: %s' % ','.join(results))
maruel@chromium.orgb1901a62010-06-16 00:18:47 +0000847 return results
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000848
849
thestig@chromium.orgde243452009-10-06 21:02:56 +0000850class GetTrySlavesExecuter(object):
maruel@chromium.orgcb2985f2010-11-03 14:08:31 +0000851 @staticmethod
852 def ExecPresubmitScript(script_text):
thestig@chromium.orgde243452009-10-06 21:02:56 +0000853 """Executes GetPreferredTrySlaves() from a single presubmit script.
854
855 Args:
856 script_text: The text of the presubmit script.
857
858 Return:
859 A list of try slaves.
860 """
861 context = {}
862 exec script_text in context
863
864 function_name = 'GetPreferredTrySlaves'
865 if function_name in context:
866 result = eval(function_name + '()', context)
867 if not isinstance(result, types.ListType):
868 raise exceptions.RuntimeError(
869 'Presubmit functions must return a list, got a %s instead: %s' %
870 (type(result), str(result)))
871 for item in result:
872 if not isinstance(item, basestring):
873 raise exceptions.RuntimeError('All try slaves names must be strings.')
874 if item != item.strip():
875 raise exceptions.RuntimeError('Try slave names cannot start/end'
876 'with whitespace')
877 else:
878 result = []
879 return result
880
881
882def DoGetTrySlaves(changed_files,
883 repository_root,
884 default_presubmit,
885 verbose,
886 output_stream):
887 """Get the list of try servers from the presubmit scripts.
888
889 Args:
890 changed_files: List of modified files.
891 repository_root: The repository root.
892 default_presubmit: A default presubmit script to execute in any case.
893 verbose: Prints debug info.
894 output_stream: A stream to write debug output to.
895
896 Return:
897 List of try slaves
898 """
899 presubmit_files = ListRelevantPresubmitFiles(changed_files, repository_root)
900 if not presubmit_files and verbose:
901 output_stream.write("Warning, no presubmit.py found.\n")
902 results = []
903 executer = GetTrySlavesExecuter()
904 if default_presubmit:
905 if verbose:
906 output_stream.write("Running default presubmit script.\n")
907 results += executer.ExecPresubmitScript(default_presubmit)
908 for filename in presubmit_files:
909 filename = os.path.abspath(filename)
910 if verbose:
911 output_stream.write("Running %s\n" % filename)
912 # Accept CRLF presubmit script.
maruel@chromium.org5aeb7dd2009-11-17 18:09:01 +0000913 presubmit_script = gclient_utils.FileRead(filename, 'rU')
thestig@chromium.orgde243452009-10-06 21:02:56 +0000914 results += executer.ExecPresubmitScript(presubmit_script)
915
916 slaves = list(set(results))
917 if slaves and verbose:
918 output_stream.write(', '.join(slaves))
919 output_stream.write('\n')
920 return slaves
921
922
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000923class PresubmitExecuter(object):
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000924 def __init__(self, change, committing):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000925 """
926 Args:
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000927 change: The Change object.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000928 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
929 """
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000930 self.change = change
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000931 self.committing = committing
932
933 def ExecPresubmitScript(self, script_text, presubmit_path):
934 """Executes a single presubmit script.
935
936 Args:
937 script_text: The text of the presubmit script.
938 presubmit_path: The path to the presubmit file (this will be reported via
939 input_api.PresubmitLocalPath()).
940
941 Return:
942 A list of result objects, empty if no problems.
943 """
chase@chromium.org8e416c82009-10-06 04:30:44 +0000944
945 # Change to the presubmit file's directory to support local imports.
946 main_path = os.getcwd()
947 os.chdir(os.path.dirname(presubmit_path))
948
949 # Load the presubmit script into context.
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +0000950 input_api = InputApi(self.change, presubmit_path, self.committing)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000951 context = {}
952 exec script_text in context
953
954 # These function names must change if we make substantial changes to
955 # the presubmit API that are not backwards compatible.
956 if self.committing:
957 function_name = 'CheckChangeOnCommit'
958 else:
959 function_name = 'CheckChangeOnUpload'
960 if function_name in context:
961 context['__args'] = (input_api, OutputApi())
maruel@chromium.org5d0dc432011-01-03 02:40:37 +0000962 logging.debug('Running %s in %s' % (function_name, presubmit_path))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000963 result = eval(function_name + '(*__args)', context)
maruel@chromium.org5d0dc432011-01-03 02:40:37 +0000964 logging.debug('Running %s done.' % function_name)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000965 if not (isinstance(result, types.TupleType) or
966 isinstance(result, types.ListType)):
967 raise exceptions.RuntimeError(
968 'Presubmit functions must return a tuple or list')
969 for item in result:
970 if not isinstance(item, OutputApi.PresubmitResult):
971 raise exceptions.RuntimeError(
972 'All presubmit results must be of types derived from '
973 'output_api.PresubmitResult')
974 else:
975 result = () # no error since the script doesn't care about current event.
976
chase@chromium.org8e416c82009-10-06 04:30:44 +0000977 # Return the process to the original working directory.
978 os.chdir(main_path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000979 return result
980
981
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000982def DoPresubmitChecks(change,
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000983 committing,
984 verbose,
985 output_stream,
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000986 input_stream,
maruel@chromium.orgb0dfd352009-06-10 14:12:54 +0000987 default_presubmit,
988 may_prompt):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000989 """Runs all presubmit checks that apply to the files in the change.
990
991 This finds all PRESUBMIT.py files in directories enclosing the files in the
992 change (up to the repository root) and calls the relevant entrypoint function
993 depending on whether the change is being committed or uploaded.
994
995 Prints errors, warnings and notifications. Prompts the user for warnings
996 when needed.
997
998 Args:
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000999 change: The Change object.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001000 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
1001 verbose: Prints debug info.
1002 output_stream: A stream to write output from presubmit tests to.
1003 input_stream: A stream to read input from the user.
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +00001004 default_presubmit: A default presubmit script to execute in any case.
maruel@chromium.orgb0dfd352009-06-10 14:12:54 +00001005 may_prompt: Enable (y/n) questions on warning or error.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001006
maruel@chromium.orgce8e46b2009-06-26 22:31:51 +00001007 Warning:
1008 If may_prompt is true, output_stream SHOULD be sys.stdout and input_stream
1009 SHOULD be sys.stdin.
1010
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001011 Return:
1012 True if execution can continue, False if not.
1013 """
maruel@chromium.org8d195232010-10-05 12:58:49 +00001014 print "Running presubmit hooks..."
jam@chromium.org2a891dc2009-08-20 20:33:37 +00001015 start_time = time.time()
maruel@chromium.org4ff922a2009-06-12 20:20:19 +00001016 presubmit_files = ListRelevantPresubmitFiles(change.AbsoluteLocalPaths(True),
1017 change.RepositoryRoot())
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001018 if not presubmit_files and verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +00001019 output_stream.write("Warning, no presubmit.py found.\n")
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001020 results = []
maruel@chromium.org4ff922a2009-06-12 20:20:19 +00001021 executer = PresubmitExecuter(change, committing)
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +00001022 if default_presubmit:
1023 if verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +00001024 output_stream.write("Running default presubmit script.\n")
maruel@chromium.org4ff922a2009-06-12 20:20:19 +00001025 fake_path = os.path.join(change.RepositoryRoot(), 'PRESUBMIT.py')
maruel@chromium.org4661e0c2009-06-04 00:45:26 +00001026 results += executer.ExecPresubmitScript(default_presubmit, fake_path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001027 for filename in presubmit_files:
maruel@chromium.org3d235242009-05-15 12:40:48 +00001028 filename = os.path.abspath(filename)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001029 if verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +00001030 output_stream.write("Running %s\n" % filename)
maruel@chromium.orgc1675e22009-04-27 20:30:48 +00001031 # Accept CRLF presubmit script.
maruel@chromium.org5aeb7dd2009-11-17 18:09:01 +00001032 presubmit_script = gclient_utils.FileRead(filename, 'rU')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001033 results += executer.ExecPresubmitScript(presubmit_script, filename)
1034
1035 errors = []
1036 notifications = []
1037 warnings = []
1038 for result in results:
1039 if not result.IsFatal() and not result.ShouldPrompt():
1040 notifications.append(result)
1041 elif result.ShouldPrompt():
1042 warnings.append(result)
1043 else:
1044 errors.append(result)
1045
1046 error_count = 0
1047 for name, items in (('Messages', notifications),
1048 ('Warnings', warnings),
1049 ('ERRORS', errors)):
1050 if items:
maruel@chromium.orgb0dfd352009-06-10 14:12:54 +00001051 output_stream.write('** Presubmit %s **\n' % name)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001052 for item in items:
maruel@chromium.orgb17b55b2010-11-03 14:42:37 +00001053 # Access to a protected member XXX of a client class
1054 # pylint: disable=W0212
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001055 if not item._Handle(output_stream, input_stream,
1056 may_prompt=False):
1057 error_count += 1
1058 output_stream.write('\n')
pam@chromium.orged9a0832009-09-09 22:48:55 +00001059
1060 total_time = time.time() - start_time
1061 if total_time > 1.0:
1062 print "Presubmit checks took %.1fs to calculate." % total_time
1063
maruel@chromium.org07bbc212009-06-11 02:08:41 +00001064 if not errors and warnings and may_prompt:
gspencer@google.comefb94502009-10-09 17:57:08 +00001065 if not PromptYesNo(input_stream, output_stream,
1066 'There were presubmit warnings. '
1067 'Are you sure you wish to continue? (y/N): '):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001068 error_count += 1
maruel@chromium.orgce8e46b2009-06-26 22:31:51 +00001069
1070 global _ASKED_FOR_FEEDBACK
1071 # Ask for feedback one time out of 5.
1072 if (len(results) and random.randint(0, 4) == 0 and not _ASKED_FOR_FEEDBACK):
1073 output_stream.write("Was the presubmit check useful? Please send feedback "
1074 "& hate mail to maruel@chromium.org!\n")
1075 _ASKED_FOR_FEEDBACK = True
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001076 return (error_count == 0)
1077
1078
1079def ScanSubDirs(mask, recursive):
1080 if not recursive:
maruel@chromium.orgc70a2202009-06-17 12:55:10 +00001081 return [x for x in glob.glob(mask) if '.svn' not in x and '.git' not in x]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001082 else:
1083 results = []
1084 for root, dirs, files in os.walk('.'):
1085 if '.svn' in dirs:
1086 dirs.remove('.svn')
maruel@chromium.orgc70a2202009-06-17 12:55:10 +00001087 if '.git' in dirs:
1088 dirs.remove('.git')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001089 for name in files:
1090 if fnmatch.fnmatch(name, mask):
1091 results.append(os.path.join(root, name))
1092 return results
1093
1094
1095def ParseFiles(args, recursive):
maruel@chromium.org7444c502011-02-09 14:02:11 +00001096 logging.debug('Searching for %s' % args)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001097 files = []
1098 for arg in args:
maruel@chromium.orge3608df2009-11-10 20:22:57 +00001099 files.extend([('M', f) for f in ScanSubDirs(arg, recursive)])
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001100 return files
1101
1102
1103def Main(argv):
1104 parser = optparse.OptionParser(usage="%prog [options]",
1105 version="%prog " + str(__version__))
maruel@chromium.orgc70a2202009-06-17 12:55:10 +00001106 parser.add_option("-c", "--commit", action="store_true", default=False,
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001107 help="Use commit instead of upload checks")
maruel@chromium.orgc70a2202009-06-17 12:55:10 +00001108 parser.add_option("-u", "--upload", action="store_false", dest='commit',
1109 help="Use upload instead of commit checks")
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001110 parser.add_option("-r", "--recursive", action="store_true",
1111 help="Act recursively")
maruel@chromium.orgc70a2202009-06-17 12:55:10 +00001112 parser.add_option("-v", "--verbose", action="store_true", default=False,
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001113 help="Verbose output")
maruel@chromium.orgc70a2202009-06-17 12:55:10 +00001114 parser.add_option("--files")
maruel@chromium.org4ff922a2009-06-12 20:20:19 +00001115 parser.add_option("--name", default='no name')
1116 parser.add_option("--description", default='')
1117 parser.add_option("--issue", type='int', default=0)
1118 parser.add_option("--patchset", type='int', default=0)
maruel@chromium.orgb1901a62010-06-16 00:18:47 +00001119 parser.add_option("--root", default=os.getcwd(),
1120 help="Search for PRESUBMIT.py up to this directory. "
1121 "If inherit-review-settings-ok is present in this "
1122 "directory, parent directories up to the root file "
1123 "system directories will also be searched.")
maruel@chromium.orgc70a2202009-06-17 12:55:10 +00001124 parser.add_option("--default_presubmit")
1125 parser.add_option("--may_prompt", action='store_true', default=False)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001126 options, args = parser.parse_args(argv[1:])
maruel@chromium.org7444c502011-02-09 14:02:11 +00001127 if options.verbose:
1128 logging.basicConfig(level=logging.DEBUG)
1129 if os.path.isdir(os.path.join(options.root, '.svn')):
maruel@chromium.orgc70a2202009-06-17 12:55:10 +00001130 change_class = SvnChange
1131 if not options.files:
1132 if args:
1133 options.files = ParseFiles(args, options.recursive)
1134 else:
1135 # Grab modified files.
maruel@chromium.org5aeb7dd2009-11-17 18:09:01 +00001136 options.files = scm.SVN.CaptureStatus([options.root])
maruel@chromium.orgc70a2202009-06-17 12:55:10 +00001137 else:
maruel@chromium.org7444c502011-02-09 14:02:11 +00001138 is_git = os.path.isdir(os.path.join(options.root, '.git'))
1139 if not is_git:
1140 is_git = (0 == subprocess.call(
1141 ['git', 'rev-parse', '--show-cdup'],
1142 stdout=subprocess.PIPE, cwd=options.root))
1143 if is_git:
1144 # Only look at the subdirectories below cwd.
1145 change_class = GitChange
1146 if not options.files:
1147 if args:
1148 options.files = ParseFiles(args, options.recursive)
1149 else:
1150 # Grab modified files.
1151 options.files = scm.GIT.CaptureStatus([options.root])
1152 else:
1153 logging.info('Doesn\'t seem under source control.')
1154 change_class = Change
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001155 if options.verbose:
maruel@chromium.org7444c502011-02-09 14:02:11 +00001156 if not options.files:
1157 print "Found no files."
1158 elif len(options.files) != 1:
chase@chromium.org8e416c82009-10-06 04:30:44 +00001159 print "Found %d files." % len(options.files)
1160 else:
1161 print "Found 1 file."
maruel@chromium.orgc70a2202009-06-17 12:55:10 +00001162 return not DoPresubmitChecks(change_class(options.name,
1163 options.description,
1164 options.root,
1165 options.files,
1166 options.issue,
1167 options.patchset),
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +00001168 options.commit,
1169 options.verbose,
1170 sys.stdout,
1171 sys.stdin,
maruel@chromium.org4ff922a2009-06-12 20:20:19 +00001172 options.default_presubmit,
1173 options.may_prompt)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001174
1175
1176if __name__ == '__main__':
1177 sys.exit(Main(sys.argv))