blob: 8586a48712dd9015d1743fb764bfb38224b686ac [file] [log] [blame]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001#!/usr/bin/python
2# Copyright (c) 2006-2009 The Chromium Authors. All rights reserved.
3# 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.orgb7d46902009-06-10 14:12:10 +00009__version__ = '1.3.2'
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.
25import re # Exposed through the API.
26import subprocess # Exposed through the API.
27import sys # Parts exposed through API.
28import tempfile # Exposed through the API.
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +000029import traceback # Exposed through the API.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000030import types
maruel@chromium.org1487d532009-06-06 00:22:57 +000031import unittest # Exposed through the API.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000032import urllib2 # Exposed through the API.
maruel@chromium.org1e08c002009-05-28 19:09:33 +000033import warnings
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000034
35# Local imports.
36# TODO(joi) Would be cleaner to factor out utils in gcl to separate module, but
37# for now it would only be a couple of functions so hardly worth it.
38import gcl
maruel@chromium.org46a94102009-05-12 20:32:43 +000039import gclient
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000040import presubmit_canned_checks
41
42
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000043class NotImplementedException(Exception):
44 """We're leaving placeholders in a bunch of places to remind us of the
45 design of the API, but we have not implemented all of it yet. Implement as
46 the need arises.
47 """
48 pass
49
50
51def normpath(path):
52 '''Version of os.path.normpath that also changes backward slashes to
53 forward slashes when not running on Windows.
54 '''
55 # This is safe to always do because the Windows version of os.path.normpath
56 # will replace forward slashes with backward slashes.
57 path = path.replace(os.sep, '/')
58 return os.path.normpath(path)
59
60
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000061class OutputApi(object):
62 """This class (more like a module) gets passed to presubmit scripts so that
63 they can specify various types of results.
64 """
65
66 class PresubmitResult(object):
67 """Base class for result objects."""
68
69 def __init__(self, message, items=None, long_text=''):
70 """
71 message: A short one-line message to indicate errors.
72 items: A list of short strings to indicate where errors occurred.
73 long_text: multi-line text output, e.g. from another tool
74 """
75 self._message = message
76 self._items = []
77 if items:
78 self._items = items
79 self._long_text = long_text.rstrip()
80
81 def _Handle(self, output_stream, input_stream, may_prompt=True):
82 """Writes this result to the output stream.
83
84 Args:
85 output_stream: Where to write
86
87 Returns:
88 True if execution may continue, False otherwise.
89 """
90 output_stream.write(self._message)
91 output_stream.write('\n')
92 for item in self._items:
maruel@chromium.org5de13972009-06-10 18:16:06 +000093 output_stream.write(' %s\n' % str(item))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000094 if self._long_text:
maruel@chromium.orgb0dfd352009-06-10 14:12:54 +000095 output_stream.write('\n***************\n%s\n***************\n' %
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000096 self._long_text)
97
98 if self.ShouldPrompt() and may_prompt:
99 output_stream.write('Are you sure you want to continue? (y/N): ')
100 response = input_stream.readline()
101 if response.strip().lower() != 'y':
102 return False
103
104 return not self.IsFatal()
105
106 def IsFatal(self):
107 """An error that is fatal stops g4 mail/submit immediately, i.e. before
108 other presubmit scripts are run.
109 """
110 return False
111
112 def ShouldPrompt(self):
113 """Whether this presubmit result should result in a prompt warning."""
114 return False
115
116 class PresubmitError(PresubmitResult):
117 """A hard presubmit error."""
118 def IsFatal(self):
119 return True
120
121 class PresubmitPromptWarning(PresubmitResult):
122 """An warning that prompts the user if they want to continue."""
123 def ShouldPrompt(self):
124 return True
125
126 class PresubmitNotifyResult(PresubmitResult):
127 """Just print something to the screen -- but it's not even a warning."""
128 pass
129
130 class MailTextResult(PresubmitResult):
131 """A warning that should be included in the review request email."""
132 def __init__(self, *args, **kwargs):
133 raise NotImplementedException() # TODO(joi) Implement.
134
135
136class InputApi(object):
137 """An instance of this object is passed to presubmit scripts so they can
138 know stuff about the change they're looking at.
139 """
140
maruel@chromium.org3410d912009-06-09 20:56:16 +0000141 # File extensions that are considered source files from a style guide
142 # perspective. Don't modify this list from a presubmit script!
143 DEFAULT_WHITE_LIST = (
144 # C++ and friends
145 r".*\.c", r".*\.cc", r".*\.cpp", r".*\.h", r".*\.m", r".*\.mm",
146 r".*\.inl", r".*\.asm", r".*\.hxx", r".*\.hpp",
147 # Scripts
148 r".*\.js", r".*\.py", r".*\.json", r".*\.sh", r".*\.rb",
149 # No extension at all
150 r"(^|.*[\\\/])[^.]+$",
151 # Other
152 r".*\.java", r".*\.mk", r".*\.am",
153 )
154
155 # Path regexp that should be excluded from being considered containing source
156 # files. Don't modify this list from a presubmit script!
157 DEFAULT_BLACK_LIST = (
158 r".*\bexperimental[\\\/].*",
159 r".*\bthird_party[\\\/].*",
160 # Output directories (just in case)
161 r".*\bDebug[\\\/].*",
162 r".*\bRelease[\\\/].*",
163 r".*\bxcodebuild[\\\/].*",
164 r".*\bsconsbuild[\\\/].*",
165 # All caps files like README and LICENCE.
maruel@chromium.orgdf1595a2009-06-11 02:00:13 +0000166 r".*\b[A-Z0-9_]+$",
167 # SCM (can happen in dual SCM configuration). (Slightly over aggressive)
168 r".*\.git[\\\/].*",
169 r".*\.svn[\\\/].*",
maruel@chromium.org3410d912009-06-09 20:56:16 +0000170 )
171
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +0000172 def __init__(self, change, presubmit_path, is_committing):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000173 """Builds an InputApi object.
174
175 Args:
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000176 change: A presubmit.Change object.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000177 presubmit_path: The path to the presubmit script being processed.
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +0000178 is_committing: True if the change is about to be committed.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000179 """
maruel@chromium.org9711bba2009-05-22 23:51:39 +0000180 # Version number of the presubmit_support script.
181 self.version = [int(x) for x in __version__.split('.')]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000182 self.change = change
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +0000183 self.is_committing = is_committing
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000184
185 # We expose various modules and functions as attributes of the input_api
186 # so that presubmit scripts don't have to import them.
187 self.basename = os.path.basename
188 self.cPickle = cPickle
189 self.cStringIO = cStringIO
190 self.os_path = os.path
191 self.pickle = pickle
192 self.marshal = marshal
193 self.re = re
194 self.subprocess = subprocess
195 self.tempfile = tempfile
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +0000196 self.traceback = traceback
maruel@chromium.org1487d532009-06-06 00:22:57 +0000197 self.unittest = unittest
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000198 self.urllib2 = urllib2
199
maruel@chromium.orgc0b22972009-06-25 16:19:14 +0000200 # To easily fork python.
201 self.python_executable = sys.executable
202 self.environ = os.environ
203
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000204 # InputApi.platform is the platform you're currently running on.
205 self.platform = sys.platform
206
207 # The local path of the currently-being-processed presubmit script.
maruel@chromium.org3d235242009-05-15 12:40:48 +0000208 self._current_presubmit_path = os.path.dirname(presubmit_path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000209
210 # We carry the canned checks so presubmit scripts can easily use them.
211 self.canned_checks = presubmit_canned_checks
212
213 def PresubmitLocalPath(self):
214 """Returns the local path of the presubmit script currently being run.
215
216 This is useful if you don't want to hard-code absolute paths in the
217 presubmit script. For example, It can be used to find another file
218 relative to the PRESUBMIT.py script, so the whole tree can be branched and
219 the presubmit script still works, without editing its content.
220 """
maruel@chromium.org3d235242009-05-15 12:40:48 +0000221 return self._current_presubmit_path
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000222
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000223 def DepotToLocalPath(self, depot_path):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000224 """Translate a depot path to a local path (relative to client root).
225
226 Args:
227 Depot path as a string.
228
229 Returns:
230 The local path of the depot path under the user's current client, or None
231 if the file is not mapped.
232
233 Remember to check for the None case and show an appropriate error!
234 """
maruel@chromium.org46a94102009-05-12 20:32:43 +0000235 local_path = gclient.CaptureSVNInfo(depot_path).get('Path')
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000236 if local_path:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000237 return local_path
238
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000239 def LocalToDepotPath(self, local_path):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000240 """Translate a local path to a depot path.
241
242 Args:
243 Local path (relative to current directory, or absolute) as a string.
244
245 Returns:
246 The depot path (SVN URL) of the file if mapped, otherwise None.
247 """
maruel@chromium.org46a94102009-05-12 20:32:43 +0000248 depot_path = gclient.CaptureSVNInfo(local_path).get('URL')
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000249 if depot_path:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000250 return depot_path
251
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000252 def AffectedFiles(self, include_dirs=False, include_deletes=True):
253 """Same as input_api.change.AffectedFiles() except only lists files
254 (and optionally directories) in the same directory as the current presubmit
255 script, or subdirectories thereof.
256 """
maruel@chromium.org3d235242009-05-15 12:40:48 +0000257 dir_with_slash = normpath("%s/" % self.PresubmitLocalPath())
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000258 if len(dir_with_slash) == 1:
259 dir_with_slash = ''
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000260 return filter(
261 lambda x: normpath(x.AbsoluteLocalPath()).startswith(dir_with_slash),
262 self.change.AffectedFiles(include_dirs, include_deletes))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000263
264 def LocalPaths(self, include_dirs=False):
265 """Returns local paths of input_api.AffectedFiles()."""
266 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
267
268 def AbsoluteLocalPaths(self, include_dirs=False):
269 """Returns absolute local paths of input_api.AffectedFiles()."""
270 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
271
272 def ServerPaths(self, include_dirs=False):
273 """Returns server paths of input_api.AffectedFiles()."""
274 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
275
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000276 def AffectedTextFiles(self, include_deletes=None):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000277 """Same as input_api.change.AffectedTextFiles() except only lists files
278 in the same directory as the current presubmit script, or subdirectories
279 thereof.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000280 """
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000281 if include_deletes is not None:
282 warnings.warn("AffectedTextFiles(include_deletes=%s)"
283 " is deprecated and ignored" % str(include_deletes),
284 category=DeprecationWarning,
285 stacklevel=2)
286 return filter(lambda x: x.IsTextFile(),
287 self.AffectedFiles(include_dirs=False, include_deletes=False))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000288
maruel@chromium.org3410d912009-06-09 20:56:16 +0000289 def FilterSourceFile(self, affected_file, white_list=None, black_list=None):
290 """Filters out files that aren't considered "source file".
291
292 If white_list or black_list is None, InputApi.DEFAULT_WHITE_LIST
293 and InputApi.DEFAULT_BLACK_LIST is used respectively.
294
295 The lists will be compiled as regular expression and
296 AffectedFile.LocalPath() needs to pass both list.
297
298 Note: Copy-paste this function to suit your needs or use a lambda function.
299 """
300 def Find(affected_file, list):
301 for item in list:
maruel@chromium.orgdf1595a2009-06-11 02:00:13 +0000302 local_path = affected_file.LocalPath()
303 if self.re.match(item, local_path):
304 logging.debug("%s matched %s" % (item, local_path))
maruel@chromium.org3410d912009-06-09 20:56:16 +0000305 return True
306 return False
307 return (Find(affected_file, white_list or self.DEFAULT_WHITE_LIST) and
308 not Find(affected_file, black_list or self.DEFAULT_BLACK_LIST))
309
310 def AffectedSourceFiles(self, source_file):
311 """Filter the list of AffectedTextFiles by the function source_file.
312
313 If source_file is None, InputApi.FilterSourceFile() is used.
314 """
315 if not source_file:
316 source_file = self.FilterSourceFile
317 return filter(source_file, self.AffectedTextFiles())
318
319 def RightHandSideLines(self, source_file_filter=None):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000320 """An iterator over all text lines in "new" version of changed files.
321
322 Only lists lines from new or modified text files in the change that are
323 contained by the directory of the currently executing presubmit script.
324
325 This is useful for doing line-by-line regex checks, like checking for
326 trailing whitespace.
327
328 Yields:
329 a 3 tuple:
330 the AffectedFile instance of the current file;
331 integer line number (1-based); and
332 the contents of the line as a string.
maruel@chromium.org1487d532009-06-06 00:22:57 +0000333
334 Note: The cariage return (LF or CR) is stripped off.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000335 """
maruel@chromium.org3410d912009-06-09 20:56:16 +0000336 files = self.AffectedSourceFiles(source_file_filter)
337 return InputApi._RightHandSideLinesImpl(files)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000338
maruel@chromium.org44a17ad2009-06-08 14:14:35 +0000339 def ReadFile(self, file, mode='r'):
340 """Reads an arbitrary file.
341
342 Deny reading anything outside the repository.
343 """
344 if isinstance(file, AffectedFile):
345 file = file.AbsoluteLocalPath()
346 if not file.startswith(self.change.RepositoryRoot()):
347 raise IOError('Access outside the repository root is denied.')
348 return gcl.ReadFile(file, mode)
349
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000350 @staticmethod
351 def _RightHandSideLinesImpl(affected_files):
352 """Implements RightHandSideLines for InputApi and GclChange."""
353 for af in affected_files:
354 lines = af.NewContents()
355 line_number = 0
356 for line in lines:
357 line_number += 1
358 yield (af, line_number, line)
359
360
361class AffectedFile(object):
362 """Representation of a file in a change."""
363
364 def __init__(self, path, action, repository_root=''):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000365 self._path = path
366 self._action = action
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000367 self._local_root = repository_root
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000368 self._is_directory = None
369 self._properties = {}
maruel@chromium.orgb7d46902009-06-10 14:12:10 +0000370 self.scm = ''
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000371
372 def ServerPath(self):
373 """Returns a path string that identifies the file in the SCM system.
374
375 Returns the empty string if the file does not exist in SCM.
376 """
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000377 return ""
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000378
379 def LocalPath(self):
380 """Returns the path of this file on the local disk relative to client root.
381 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000382 return normpath(self._path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000383
384 def AbsoluteLocalPath(self):
385 """Returns the absolute path of this file on the local disk.
386 """
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000387 return normpath(os.path.join(self._local_root, self.LocalPath()))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000388
389 def IsDirectory(self):
390 """Returns true if this object is a directory."""
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000391 if self._is_directory is None:
392 path = self.AbsoluteLocalPath()
393 self._is_directory = (os.path.exists(path) and
394 os.path.isdir(path))
395 return self._is_directory
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000396
397 def Action(self):
398 """Returns the action on this opened file, e.g. A, M, D, etc."""
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000399 # TODO(maruel): Somewhat crappy, Could be "A" or "A +" for svn but
400 # different for other SCM.
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000401 return self._action
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000402
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000403 def Property(self, property_name):
404 """Returns the specified SCM property of this file, or None if no such
405 property.
406 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000407 return self._properties.get(property_name, None)
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000408
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000409 def IsTextFile(self):
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000410 """Returns True if the file is a text file and not a binary file.
411
412 Deleted files are not text file."""
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000413 raise NotImplementedError() # Implement when needed
414
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000415 def NewContents(self):
416 """Returns an iterator over the lines in the new version of file.
417
418 The new version is the file in the user's workspace, i.e. the "right hand
419 side".
420
421 Contents will be empty if the file is a directory or does not exist.
maruel@chromium.org1487d532009-06-06 00:22:57 +0000422 Note: The cariage returns (LF or CR) are stripped off.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000423 """
424 if self.IsDirectory():
425 return []
426 else:
427 return gcl.ReadFile(self.AbsoluteLocalPath()).splitlines()
428
429 def OldContents(self):
430 """Returns an iterator over the lines in the old version of file.
431
432 The old version is the file in depot, i.e. the "left hand side".
433 """
434 raise NotImplementedError() # Implement when needed
435
436 def OldFileTempPath(self):
437 """Returns the path on local disk where the old contents resides.
438
439 The old version is the file in depot, i.e. the "left hand side".
440 This is a read-only cached copy of the old contents. *DO NOT* try to
441 modify this file.
442 """
443 raise NotImplementedError() # Implement if/when needed.
444
maruel@chromium.org5de13972009-06-10 18:16:06 +0000445 def __str__(self):
446 return self.LocalPath()
447
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000448
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000449class SvnAffectedFile(AffectedFile):
450 """Representation of a file in a change out of a Subversion checkout."""
451
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000452 def __init__(self, *args, **kwargs):
453 AffectedFile.__init__(self, *args, **kwargs)
454 self._server_path = None
455 self._is_text_file = None
maruel@chromium.orgb7d46902009-06-10 14:12:10 +0000456 self.scm = 'svn'
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000457
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000458 def ServerPath(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000459 if self._server_path is None:
460 self._server_path = gclient.CaptureSVNInfo(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000461 self.AbsoluteLocalPath()).get('URL', '')
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000462 return self._server_path
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000463
464 def IsDirectory(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000465 if self._is_directory is None:
466 path = self.AbsoluteLocalPath()
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000467 if os.path.exists(path):
468 # Retrieve directly from the file system; it is much faster than
469 # querying subversion, especially on Windows.
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000470 self._is_directory = os.path.isdir(path)
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000471 else:
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000472 self._is_directory = gclient.CaptureSVNInfo(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000473 path).get('Node Kind') in ('dir', 'directory')
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000474 return self._is_directory
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000475
476 def Property(self, property_name):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000477 if not property_name in self._properties:
478 self._properties[property_name] = gcl.GetSVNFileProperty(
maruel@chromium.org196f8cb2009-06-11 00:32:06 +0000479 self.AbsoluteLocalPath(), property_name).rstrip()
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000480 return self._properties[property_name]
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000481
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000482 def IsTextFile(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000483 if self._is_text_file is None:
484 if self.Action() == 'D':
485 # A deleted file is not a text file.
486 self._is_text_file = False
487 elif self.IsDirectory():
488 self._is_text_file = False
489 else:
490 mime_type = gcl.GetSVNFileProperty(self.AbsoluteLocalPath(),
491 'svn:mime-type')
492 self._is_text_file = (not mime_type or mime_type.startswith('text/'))
493 return self._is_text_file
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000494
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000495
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000496class GitAffectedFile(AffectedFile):
497 """Representation of a file in a change out of a git checkout."""
498
499 def __init__(self, *args, **kwargs):
500 AffectedFile.__init__(self, *args, **kwargs)
501 self._server_path = None
502 self._is_text_file = None
503 self.scm = 'git'
504
505 def ServerPath(self):
506 if self._server_path is None:
507 raise NotImplementedException() # TODO(maruel) Implement.
508 return self._server_path
509
510 def IsDirectory(self):
511 if self._is_directory is None:
512 path = self.AbsoluteLocalPath()
513 if os.path.exists(path):
514 # Retrieve directly from the file system; it is much faster than
515 # querying subversion, especially on Windows.
516 self._is_directory = os.path.isdir(path)
517 else:
518 # raise NotImplementedException() # TODO(maruel) Implement.
519 self._is_directory = False
520 return self._is_directory
521
522 def Property(self, property_name):
523 if not property_name in self._properties:
524 raise NotImplementedException() # TODO(maruel) Implement.
525 return self._properties[property_name]
526
527 def IsTextFile(self):
528 if self._is_text_file is None:
529 if self.Action() == 'D':
530 # A deleted file is not a text file.
531 self._is_text_file = False
532 elif self.IsDirectory():
533 self._is_text_file = False
534 else:
535 # raise NotImplementedException() # TODO(maruel) Implement.
536 self._is_text_file = os.path.isfile(self.AbsoluteLocalPath())
537 return self._is_text_file
538
539
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000540class Change(object):
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000541 """Describe a change.
542
543 Used directly by the presubmit scripts to query the current change being
544 tested.
545
546 Instance members:
547 tags: Dictionnary of KEY=VALUE pairs found in the change description.
548 self.KEY: equivalent to tags['KEY']
549 """
550
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000551 _AFFECTED_FILES = AffectedFile
552
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000553 # Matches key/value (or "tag") lines in changelist descriptions.
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000554 _TAG_LINE_RE = re.compile(
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000555 '^\s*(?P<key>[A-Z][A-Z_0-9]*)\s*=\s*(?P<value>.*?)\s*$')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000556
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000557 def __init__(self, name, description, local_root, files, issue, patchset):
558 if files is None:
559 files = []
560 self._name = name
561 self._full_description = description
562 self._local_root = local_root
563 self.issue = issue
564 self.patchset = patchset
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000565
566 # From the description text, build up a dictionary of key/value pairs
567 # plus the description minus all key/value or "tag" lines.
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000568 self._description_without_tags = []
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000569 self.tags = {}
maruel@chromium.org8d5c9a52009-06-12 15:59:08 +0000570 for line in self._full_description.splitlines():
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000571 m = self._TAG_LINE_RE.match(line)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000572 if m:
573 self.tags[m.group('key')] = m.group('value')
574 else:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000575 self._description_without_tags.append(line)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000576
577 # Change back to text and remove whitespace at end.
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000578 self._description_without_tags = '\n'.join(self._description_without_tags)
579 self._description_without_tags = self._description_without_tags.rstrip()
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000580
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000581 self._affected_files = [
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000582 self._AFFECTED_FILES(info[1], info[0].strip(), self._local_root)
583 for info in files
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000584 ]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000585
maruel@chromium.org92022ec2009-06-11 01:59:28 +0000586 def Name(self):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000587 """Returns the change name."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000588 return self._name
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000589
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000590 def DescriptionText(self):
591 """Returns the user-entered changelist description, minus tags.
592
593 Any line in the user-provided description starting with e.g. "FOO="
594 (whitespace permitted before and around) is considered a tag line. Such
595 lines are stripped out of the description this function returns.
596 """
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000597 return self._description_without_tags
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000598
599 def FullDescriptionText(self):
600 """Returns the complete changelist description including tags."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000601 return self._full_description
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000602
603 def RepositoryRoot(self):
maruel@chromium.org92022ec2009-06-11 01:59:28 +0000604 """Returns the repository (checkout) root directory for this change,
605 as an absolute path.
606 """
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000607 return self._local_root
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000608
609 def __getattr__(self, attr):
maruel@chromium.org92022ec2009-06-11 01:59:28 +0000610 """Return tags directly as attributes on the object."""
611 if not re.match(r"^[A-Z_]*$", attr):
612 raise AttributeError(self, attr)
maruel@chromium.orge1a524f2009-05-27 14:43:46 +0000613 return self.tags.get(attr)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000614
615 def AffectedFiles(self, include_dirs=False, include_deletes=True):
616 """Returns a list of AffectedFile instances for all files in the change.
617
618 Args:
619 include_deletes: If false, deleted files will be filtered out.
620 include_dirs: True to include directories in the list
621
622 Returns:
623 [AffectedFile(path, action), AffectedFile(path, action)]
624 """
625 if include_dirs:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000626 affected = self._affected_files
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000627 else:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000628 affected = filter(lambda x: not x.IsDirectory(), self._affected_files)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000629
630 if include_deletes:
631 return affected
632 else:
633 return filter(lambda x: x.Action() != 'D', affected)
634
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000635 def AffectedTextFiles(self, include_deletes=None):
636 """Return a list of the existing text files in a change."""
637 if include_deletes is not None:
638 warnings.warn("AffectedTextFiles(include_deletes=%s)"
639 " is deprecated and ignored" % str(include_deletes),
640 category=DeprecationWarning,
641 stacklevel=2)
642 return filter(lambda x: x.IsTextFile(),
643 self.AffectedFiles(include_dirs=False, include_deletes=False))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000644
645 def LocalPaths(self, include_dirs=False):
646 """Convenience function."""
647 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
648
649 def AbsoluteLocalPaths(self, include_dirs=False):
650 """Convenience function."""
651 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
652
653 def ServerPaths(self, include_dirs=False):
654 """Convenience function."""
655 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
656
657 def RightHandSideLines(self):
658 """An iterator over all text lines in "new" version of changed files.
659
660 Lists lines from new or modified text files in the change.
661
662 This is useful for doing line-by-line regex checks, like checking for
663 trailing whitespace.
664
665 Yields:
666 a 3 tuple:
667 the AffectedFile instance of the current file;
668 integer line number (1-based); and
669 the contents of the line as a string.
670 """
671 return InputApi._RightHandSideLinesImpl(
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000672 filter(lambda x: x.IsTextFile(),
673 self.AffectedFiles(include_deletes=False)))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000674
675
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000676class SvnChange(Change):
677 _AFFECTED_FILES = SvnAffectedFile
678
679
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000680class GitChange(Change):
681 _AFFECTED_FILES = GitAffectedFile
682
683
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000684def ListRelevantPresubmitFiles(files, root):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000685 """Finds all presubmit files that apply to a given set of source files.
686
687 Args:
688 files: An iterable container containing file paths.
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000689 root: Path where to stop searching.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000690
691 Return:
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000692 List of absolute paths of the existing PRESUBMIT.py scripts.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000693 """
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000694 entries = []
695 for f in files:
696 f = normpath(os.path.join(root, f))
697 while f:
698 f = os.path.dirname(f)
699 if f in entries:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000700 break
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000701 entries.append(f)
702 if f == root:
703 break
704 entries.sort()
705 entries = map(lambda x: os.path.join(x, 'PRESUBMIT.py'), entries)
706 return filter(lambda x: os.path.isfile(x), entries)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000707
708
709class PresubmitExecuter(object):
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000710 def __init__(self, change, committing):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000711 """
712 Args:
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000713 change: The Change object.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000714 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
715 """
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000716 self.change = change
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000717 self.committing = committing
718
719 def ExecPresubmitScript(self, script_text, presubmit_path):
720 """Executes a single presubmit script.
721
722 Args:
723 script_text: The text of the presubmit script.
724 presubmit_path: The path to the presubmit file (this will be reported via
725 input_api.PresubmitLocalPath()).
726
727 Return:
728 A list of result objects, empty if no problems.
729 """
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +0000730 input_api = InputApi(self.change, presubmit_path, self.committing)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000731 context = {}
732 exec script_text in context
733
734 # These function names must change if we make substantial changes to
735 # the presubmit API that are not backwards compatible.
736 if self.committing:
737 function_name = 'CheckChangeOnCommit'
738 else:
739 function_name = 'CheckChangeOnUpload'
740 if function_name in context:
741 context['__args'] = (input_api, OutputApi())
742 result = eval(function_name + '(*__args)', context)
743 if not (isinstance(result, types.TupleType) or
744 isinstance(result, types.ListType)):
745 raise exceptions.RuntimeError(
746 'Presubmit functions must return a tuple or list')
747 for item in result:
748 if not isinstance(item, OutputApi.PresubmitResult):
749 raise exceptions.RuntimeError(
750 'All presubmit results must be of types derived from '
751 'output_api.PresubmitResult')
752 else:
753 result = () # no error since the script doesn't care about current event.
754
755 return result
756
757
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000758def DoPresubmitChecks(change,
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000759 committing,
760 verbose,
761 output_stream,
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000762 input_stream,
maruel@chromium.orgb0dfd352009-06-10 14:12:54 +0000763 default_presubmit,
764 may_prompt):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000765 """Runs all presubmit checks that apply to the files in the change.
766
767 This finds all PRESUBMIT.py files in directories enclosing the files in the
768 change (up to the repository root) and calls the relevant entrypoint function
769 depending on whether the change is being committed or uploaded.
770
771 Prints errors, warnings and notifications. Prompts the user for warnings
772 when needed.
773
774 Args:
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000775 change: The Change object.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000776 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
777 verbose: Prints debug info.
778 output_stream: A stream to write output from presubmit tests to.
779 input_stream: A stream to read input from the user.
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000780 default_presubmit: A default presubmit script to execute in any case.
maruel@chromium.orgb0dfd352009-06-10 14:12:54 +0000781 may_prompt: Enable (y/n) questions on warning or error.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000782
783 Return:
784 True if execution can continue, False if not.
785 """
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000786 presubmit_files = ListRelevantPresubmitFiles(change.AbsoluteLocalPaths(True),
787 change.RepositoryRoot())
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000788 if not presubmit_files and verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000789 output_stream.write("Warning, no presubmit.py found.\n")
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000790 results = []
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000791 executer = PresubmitExecuter(change, committing)
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000792 if default_presubmit:
793 if verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000794 output_stream.write("Running default presubmit script.\n")
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000795 fake_path = os.path.join(change.RepositoryRoot(), 'PRESUBMIT.py')
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000796 results += executer.ExecPresubmitScript(default_presubmit, fake_path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000797 for filename in presubmit_files:
maruel@chromium.org3d235242009-05-15 12:40:48 +0000798 filename = os.path.abspath(filename)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000799 if verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000800 output_stream.write("Running %s\n" % filename)
maruel@chromium.orgc1675e22009-04-27 20:30:48 +0000801 # Accept CRLF presubmit script.
maruel@chromium.org277003e2009-05-01 12:51:43 +0000802 presubmit_script = gcl.ReadFile(filename, 'rU')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000803 results += executer.ExecPresubmitScript(presubmit_script, filename)
804
805 errors = []
806 notifications = []
807 warnings = []
808 for result in results:
809 if not result.IsFatal() and not result.ShouldPrompt():
810 notifications.append(result)
811 elif result.ShouldPrompt():
812 warnings.append(result)
813 else:
814 errors.append(result)
815
816 error_count = 0
817 for name, items in (('Messages', notifications),
818 ('Warnings', warnings),
819 ('ERRORS', errors)):
820 if items:
maruel@chromium.orgb0dfd352009-06-10 14:12:54 +0000821 output_stream.write('** Presubmit %s **\n' % name)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000822 for item in items:
823 if not item._Handle(output_stream, input_stream,
824 may_prompt=False):
825 error_count += 1
826 output_stream.write('\n')
maruel@chromium.org07bbc212009-06-11 02:08:41 +0000827 if not errors and warnings and may_prompt:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000828 output_stream.write(
829 'There were presubmit warnings. Sure you want to continue? (y/N): ')
830 response = input_stream.readline()
831 if response.strip().lower() != 'y':
832 error_count += 1
833 return (error_count == 0)
834
835
836def ScanSubDirs(mask, recursive):
837 if not recursive:
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000838 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 +0000839 else:
840 results = []
841 for root, dirs, files in os.walk('.'):
842 if '.svn' in dirs:
843 dirs.remove('.svn')
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000844 if '.git' in dirs:
845 dirs.remove('.git')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000846 for name in files:
847 if fnmatch.fnmatch(name, mask):
848 results.append(os.path.join(root, name))
849 return results
850
851
852def ParseFiles(args, recursive):
853 files = []
854 for arg in args:
855 files.extend([('M', file) for file in ScanSubDirs(arg, recursive)])
856 return files
857
858
859def Main(argv):
860 parser = optparse.OptionParser(usage="%prog [options]",
861 version="%prog " + str(__version__))
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000862 parser.add_option("-c", "--commit", action="store_true", default=False,
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000863 help="Use commit instead of upload checks")
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000864 parser.add_option("-u", "--upload", action="store_false", dest='commit',
865 help="Use upload instead of commit checks")
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000866 parser.add_option("-r", "--recursive", action="store_true",
867 help="Act recursively")
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000868 parser.add_option("-v", "--verbose", action="store_true", default=False,
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000869 help="Verbose output")
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000870 parser.add_option("--files")
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000871 parser.add_option("--name", default='no name')
872 parser.add_option("--description", default='')
873 parser.add_option("--issue", type='int', default=0)
874 parser.add_option("--patchset", type='int', default=0)
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000875 parser.add_option("--root", default='')
876 parser.add_option("--default_presubmit")
877 parser.add_option("--may_prompt", action='store_true', default=False)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000878 options, args = parser.parse_args(argv[1:])
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000879 if not options.root:
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000880 options.root = os.getcwd()
881 if os.path.isdir(os.path.join(options.root, '.git')):
882 change_class = GitChange
883 if not options.files:
884 if args:
885 options.files = ParseFiles(args, options.recursive)
886 else:
887 # Grab modified files.
888 raise NotImplementedException() # TODO(maruel) Implement.
889 elif os.path.isdir(os.path.join(options.root, '.svn')):
890 change_class = SvnChange
891 if not options.files:
892 if args:
893 options.files = ParseFiles(args, options.recursive)
894 else:
895 # Grab modified files.
896 files = gclient.CaptureSVNStatus([options.root])
897 else:
898 # Doesn't seem under source control.
899 change_class = Change
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000900 if options.verbose:
maruel@chromium.orgc70a2202009-06-17 12:55:10 +0000901 print "Found %d files." % len(options.files)
902 return not DoPresubmitChecks(change_class(options.name,
903 options.description,
904 options.root,
905 options.files,
906 options.issue,
907 options.patchset),
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000908 options.commit,
909 options.verbose,
910 sys.stdout,
911 sys.stdin,
maruel@chromium.org4ff922a2009-06-12 20:20:19 +0000912 options.default_presubmit,
913 options.may_prompt)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000914
915
916if __name__ == '__main__':
917 sys.exit(Main(sys.argv))