blob: 6cbdfed14d43d8323d679ca3a6c27206da253565 [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.org9711bba2009-05-22 23:51:39 +00009__version__ = '1.1'
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
20import marshal # Exposed through the API.
21import optparse
22import os # Somewhat exposed through the API.
23import pickle # Exposed through the API.
24import re # Exposed through the API.
25import subprocess # Exposed through the API.
26import sys # Parts exposed through API.
27import tempfile # Exposed through the API.
28import types
29import urllib2 # Exposed through the API.
maruel@chromium.org1e08c002009-05-28 19:09:33 +000030import warnings
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000031
32# Local imports.
33# TODO(joi) Would be cleaner to factor out utils in gcl to separate module, but
34# for now it would only be a couple of functions so hardly worth it.
35import gcl
maruel@chromium.org46a94102009-05-12 20:32:43 +000036import gclient
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000037import presubmit_canned_checks
38
39
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000040class NotImplementedException(Exception):
41 """We're leaving placeholders in a bunch of places to remind us of the
42 design of the API, but we have not implemented all of it yet. Implement as
43 the need arises.
44 """
45 pass
46
47
48def normpath(path):
49 '''Version of os.path.normpath that also changes backward slashes to
50 forward slashes when not running on Windows.
51 '''
52 # This is safe to always do because the Windows version of os.path.normpath
53 # will replace forward slashes with backward slashes.
54 path = path.replace(os.sep, '/')
55 return os.path.normpath(path)
56
57
maruel@chromium.org1e08c002009-05-28 19:09:33 +000058def deprecated(func):
59 """This is a decorator which can be used to mark functions as deprecated.
60
61 It will result in a warning being emmitted when the function is used."""
62 def newFunc(*args, **kwargs):
63 warnings.warn("Call to deprecated function %s." % func.__name__,
64 category=DeprecationWarning,
65 stacklevel=2)
66 return func(*args, **kwargs)
67 newFunc.__name__ = func.__name__
68 newFunc.__doc__ = func.__doc__
69 newFunc.__dict__.update(func.__dict__)
70 return newFunc
71
72
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000073class OutputApi(object):
74 """This class (more like a module) gets passed to presubmit scripts so that
75 they can specify various types of results.
76 """
77
78 class PresubmitResult(object):
79 """Base class for result objects."""
80
81 def __init__(self, message, items=None, long_text=''):
82 """
83 message: A short one-line message to indicate errors.
84 items: A list of short strings to indicate where errors occurred.
85 long_text: multi-line text output, e.g. from another tool
86 """
87 self._message = message
88 self._items = []
89 if items:
90 self._items = items
91 self._long_text = long_text.rstrip()
92
93 def _Handle(self, output_stream, input_stream, may_prompt=True):
94 """Writes this result to the output stream.
95
96 Args:
97 output_stream: Where to write
98
99 Returns:
100 True if execution may continue, False otherwise.
101 """
102 output_stream.write(self._message)
103 output_stream.write('\n')
104 for item in self._items:
105 output_stream.write(' %s\n' % item)
106 if self._long_text:
107 output_stream.write('\n***************\n%s\n***************\n\n' %
108 self._long_text)
109
110 if self.ShouldPrompt() and may_prompt:
111 output_stream.write('Are you sure you want to continue? (y/N): ')
112 response = input_stream.readline()
113 if response.strip().lower() != 'y':
114 return False
115
116 return not self.IsFatal()
117
118 def IsFatal(self):
119 """An error that is fatal stops g4 mail/submit immediately, i.e. before
120 other presubmit scripts are run.
121 """
122 return False
123
124 def ShouldPrompt(self):
125 """Whether this presubmit result should result in a prompt warning."""
126 return False
127
128 class PresubmitError(PresubmitResult):
129 """A hard presubmit error."""
130 def IsFatal(self):
131 return True
132
133 class PresubmitPromptWarning(PresubmitResult):
134 """An warning that prompts the user if they want to continue."""
135 def ShouldPrompt(self):
136 return True
137
138 class PresubmitNotifyResult(PresubmitResult):
139 """Just print something to the screen -- but it's not even a warning."""
140 pass
141
142 class MailTextResult(PresubmitResult):
143 """A warning that should be included in the review request email."""
144 def __init__(self, *args, **kwargs):
145 raise NotImplementedException() # TODO(joi) Implement.
146
147
148class InputApi(object):
149 """An instance of this object is passed to presubmit scripts so they can
150 know stuff about the change they're looking at.
151 """
152
153 def __init__(self, change, presubmit_path):
154 """Builds an InputApi object.
155
156 Args:
157 change: A presubmit.GclChange object.
158 presubmit_path: The path to the presubmit script being processed.
159 """
maruel@chromium.org9711bba2009-05-22 23:51:39 +0000160 # Version number of the presubmit_support script.
161 self.version = [int(x) for x in __version__.split('.')]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000162 self.change = change
163
164 # We expose various modules and functions as attributes of the input_api
165 # so that presubmit scripts don't have to import them.
166 self.basename = os.path.basename
167 self.cPickle = cPickle
168 self.cStringIO = cStringIO
169 self.os_path = os.path
170 self.pickle = pickle
171 self.marshal = marshal
172 self.re = re
173 self.subprocess = subprocess
174 self.tempfile = tempfile
175 self.urllib2 = urllib2
176
177 # InputApi.platform is the platform you're currently running on.
178 self.platform = sys.platform
179
180 # The local path of the currently-being-processed presubmit script.
maruel@chromium.org3d235242009-05-15 12:40:48 +0000181 self._current_presubmit_path = os.path.dirname(presubmit_path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000182
183 # We carry the canned checks so presubmit scripts can easily use them.
184 self.canned_checks = presubmit_canned_checks
185
186 def PresubmitLocalPath(self):
187 """Returns the local path of the presubmit script currently being run.
188
189 This is useful if you don't want to hard-code absolute paths in the
190 presubmit script. For example, It can be used to find another file
191 relative to the PRESUBMIT.py script, so the whole tree can be branched and
192 the presubmit script still works, without editing its content.
193 """
maruel@chromium.org3d235242009-05-15 12:40:48 +0000194 return self._current_presubmit_path
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000195
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000196 def DepotToLocalPath(self, depot_path):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000197 """Translate a depot path to a local path (relative to client root).
198
199 Args:
200 Depot path as a string.
201
202 Returns:
203 The local path of the depot path under the user's current client, or None
204 if the file is not mapped.
205
206 Remember to check for the None case and show an appropriate error!
207 """
maruel@chromium.org46a94102009-05-12 20:32:43 +0000208 local_path = gclient.CaptureSVNInfo(depot_path).get('Path')
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000209 if local_path:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000210 return local_path
211
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000212 def LocalToDepotPath(self, local_path):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000213 """Translate a local path to a depot path.
214
215 Args:
216 Local path (relative to current directory, or absolute) as a string.
217
218 Returns:
219 The depot path (SVN URL) of the file if mapped, otherwise None.
220 """
maruel@chromium.org46a94102009-05-12 20:32:43 +0000221 depot_path = gclient.CaptureSVNInfo(local_path).get('URL')
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000222 if depot_path:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000223 return depot_path
224
225 @staticmethod
226 def FilterTextFiles(affected_files, include_deletes=True):
227 """Filters out all except text files and optionally also filters out
228 deleted files.
229
230 Args:
231 affected_files: List of AffectedFiles objects.
232 include_deletes: If false, deleted files will be filtered out.
233
234 Returns:
235 Filtered list of AffectedFiles objects.
236 """
237 output_files = []
238 for af in affected_files:
239 if include_deletes or af.Action() != 'D':
240 path = af.AbsoluteLocalPath()
241 mime_type = gcl.GetSVNFileProperty(path, 'svn:mime-type')
242 if not mime_type or mime_type.startswith('text/'):
243 output_files.append(af)
244 return output_files
245
246 def AffectedFiles(self, include_dirs=False, include_deletes=True):
247 """Same as input_api.change.AffectedFiles() except only lists files
248 (and optionally directories) in the same directory as the current presubmit
249 script, or subdirectories thereof.
250 """
251 output_files = []
maruel@chromium.org3d235242009-05-15 12:40:48 +0000252 dir_with_slash = normpath("%s/" % self.PresubmitLocalPath())
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000253 if len(dir_with_slash) == 1:
254 dir_with_slash = ''
255 for af in self.change.AffectedFiles(include_dirs, include_deletes):
256 af_path = normpath(af.LocalPath())
257 if af_path.startswith(dir_with_slash):
258 output_files.append(af)
259 return output_files
260
261 def LocalPaths(self, include_dirs=False):
262 """Returns local paths of input_api.AffectedFiles()."""
263 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
264
265 def AbsoluteLocalPaths(self, include_dirs=False):
266 """Returns absolute local paths of input_api.AffectedFiles()."""
267 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
268
269 def ServerPaths(self, include_dirs=False):
270 """Returns server paths of input_api.AffectedFiles()."""
271 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
272
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000273 @deprecated
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000274 def AffectedTextFiles(self, include_deletes=True):
275 """Same as input_api.change.AffectedTextFiles() except only lists files
276 in the same directory as the current presubmit script, or subdirectories
277 thereof.
278
279 Warning: This function retrieves the svn property on each file so it can be
280 slow for large change lists.
281 """
282 return InputApi.FilterTextFiles(self.AffectedFiles(include_dirs=False),
283 include_deletes)
284
285 def RightHandSideLines(self):
286 """An iterator over all text lines in "new" version of changed files.
287
288 Only lists lines from new or modified text files in the change that are
289 contained by the directory of the currently executing presubmit script.
290
291 This is useful for doing line-by-line regex checks, like checking for
292 trailing whitespace.
293
294 Yields:
295 a 3 tuple:
296 the AffectedFile instance of the current file;
297 integer line number (1-based); and
298 the contents of the line as a string.
299 """
300 return InputApi._RightHandSideLinesImpl(
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000301 filter(lambda x: x.IsTextFile(),
302 self.AffectedFiles(include_deletes=False)))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000303
304 @staticmethod
305 def _RightHandSideLinesImpl(affected_files):
306 """Implements RightHandSideLines for InputApi and GclChange."""
307 for af in affected_files:
308 lines = af.NewContents()
309 line_number = 0
310 for line in lines:
311 line_number += 1
312 yield (af, line_number, line)
313
314
315class AffectedFile(object):
316 """Representation of a file in a change."""
317
318 def __init__(self, path, action, repository_root=''):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000319 self._path = path
320 self._action = action
321 self._repository_root = repository_root
322 self._is_directory = None
323 self._properties = {}
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000324
325 def ServerPath(self):
326 """Returns a path string that identifies the file in the SCM system.
327
328 Returns the empty string if the file does not exist in SCM.
329 """
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000330 return ""
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000331
332 def LocalPath(self):
333 """Returns the path of this file on the local disk relative to client root.
334 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000335 return normpath(self._path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000336
337 def AbsoluteLocalPath(self):
338 """Returns the absolute path of this file on the local disk.
339 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000340 return normpath(os.path.join(self._repository_root, self.LocalPath()))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000341
342 def IsDirectory(self):
343 """Returns true if this object is a directory."""
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000344 if self._is_directory is None:
345 path = self.AbsoluteLocalPath()
346 self._is_directory = (os.path.exists(path) and
347 os.path.isdir(path))
348 return self._is_directory
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000349
350 def Action(self):
351 """Returns the action on this opened file, e.g. A, M, D, etc."""
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000352 # TODO(maruel): Somewhat crappy, Could be "A" or "A +" for svn but
353 # different for other SCM.
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000354 return self._action
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000355
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000356 def Property(self, property_name):
357 """Returns the specified SCM property of this file, or None if no such
358 property.
359 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000360 return self._properties.get(property_name, None)
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000361
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000362 def IsTextFile(self):
363 """Returns True if the file is a text file and not a binary file."""
364 raise NotImplementedError() # Implement when needed
365
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000366 def NewContents(self):
367 """Returns an iterator over the lines in the new version of file.
368
369 The new version is the file in the user's workspace, i.e. the "right hand
370 side".
371
372 Contents will be empty if the file is a directory or does not exist.
373 """
374 if self.IsDirectory():
375 return []
376 else:
377 return gcl.ReadFile(self.AbsoluteLocalPath()).splitlines()
378
379 def OldContents(self):
380 """Returns an iterator over the lines in the old version of file.
381
382 The old version is the file in depot, i.e. the "left hand side".
383 """
384 raise NotImplementedError() # Implement when needed
385
386 def OldFileTempPath(self):
387 """Returns the path on local disk where the old contents resides.
388
389 The old version is the file in depot, i.e. the "left hand side".
390 This is a read-only cached copy of the old contents. *DO NOT* try to
391 modify this file.
392 """
393 raise NotImplementedError() # Implement if/when needed.
394
395
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000396class SvnAffectedFile(AffectedFile):
397 """Representation of a file in a change out of a Subversion checkout."""
398
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000399 def __init__(self, *args, **kwargs):
400 AffectedFile.__init__(self, *args, **kwargs)
401 self._server_path = None
402 self._is_text_file = None
403
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000404 def ServerPath(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000405 if self._server_path is None:
406 self._server_path = gclient.CaptureSVNInfo(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000407 self.AbsoluteLocalPath()).get('URL', '')
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000408 return self._server_path
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000409
410 def IsDirectory(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000411 if self._is_directory is None:
412 path = self.AbsoluteLocalPath()
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000413 if os.path.exists(path):
414 # Retrieve directly from the file system; it is much faster than
415 # querying subversion, especially on Windows.
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000416 self._is_directory = os.path.isdir(path)
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000417 else:
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000418 self._is_directory = gclient.CaptureSVNInfo(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000419 path).get('Node Kind') in ('dir', 'directory')
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000420 return self._is_directory
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000421
422 def Property(self, property_name):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000423 if not property_name in self._properties:
424 self._properties[property_name] = gcl.GetSVNFileProperty(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000425 self.AbsoluteLocalPath(), property_name)
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000426 return self._properties[property_name]
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000427
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000428 def IsTextFile(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000429 if self._is_text_file is None:
430 if self.Action() == 'D':
431 # A deleted file is not a text file.
432 self._is_text_file = False
433 elif self.IsDirectory():
434 self._is_text_file = False
435 else:
436 mime_type = gcl.GetSVNFileProperty(self.AbsoluteLocalPath(),
437 'svn:mime-type')
438 self._is_text_file = (not mime_type or mime_type.startswith('text/'))
439 return self._is_text_file
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000440
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000441
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000442class GclChange(object):
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000443 """Describe a change.
444
445 Used directly by the presubmit scripts to query the current change being
446 tested.
447
448 Instance members:
449 tags: Dictionnary of KEY=VALUE pairs found in the change description.
450 self.KEY: equivalent to tags['KEY']
451 """
452
453 # Matches key/value (or "tag") lines in changelist descriptions.
454 _tag_line_re = re.compile(
455 '^\s*(?P<key>[A-Z][A-Z_0-9]*)\s*=\s*(?P<value>.*?)\s*$')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000456
457 def __init__(self, change_info, repository_root=''):
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000458 # Do not keep a reference to the original change_info.
459 self._name = change_info.name
460 self._full_description = change_info.description
461 self._repository_root = repository_root
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000462
463 # From the description text, build up a dictionary of key/value pairs
464 # plus the description minus all key/value or "tag" lines.
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000465 self._description_without_tags = []
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000466 self.tags = {}
467 for line in change_info.description.splitlines():
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000468 m = self._tag_line_re.match(line)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000469 if m:
470 self.tags[m.group('key')] = m.group('value')
471 else:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000472 self._description_without_tags.append(line)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000473
474 # Change back to text and remove whitespace at end.
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000475 self._description_without_tags = '\n'.join(self._description_without_tags)
476 self._description_without_tags = self._description_without_tags.rstrip()
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000477
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000478 self._affected_files = [
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000479 SvnAffectedFile(info[1], info[0].strip(), repository_root)
480 for info in change_info.files
481 ]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000482
483 def Change(self):
484 """Returns the change name."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000485 return self._name
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000486
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000487 def DescriptionText(self):
488 """Returns the user-entered changelist description, minus tags.
489
490 Any line in the user-provided description starting with e.g. "FOO="
491 (whitespace permitted before and around) is considered a tag line. Such
492 lines are stripped out of the description this function returns.
493 """
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000494 return self._description_without_tags
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000495
496 def FullDescriptionText(self):
497 """Returns the complete changelist description including tags."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000498 return self._full_description
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000499
500 def RepositoryRoot(self):
501 """Returns the repository root for this change, as an absolute path."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000502 return self._repository_root
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000503
504 def __getattr__(self, attr):
505 """Return keys directly as attributes on the object.
506
507 You may use a friendly name (from SPECIAL_KEYS) or the actual name of
508 the key.
509 """
maruel@chromium.orge1a524f2009-05-27 14:43:46 +0000510 return self.tags.get(attr)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000511
512 def AffectedFiles(self, include_dirs=False, include_deletes=True):
513 """Returns a list of AffectedFile instances for all files in the change.
514
515 Args:
516 include_deletes: If false, deleted files will be filtered out.
517 include_dirs: True to include directories in the list
518
519 Returns:
520 [AffectedFile(path, action), AffectedFile(path, action)]
521 """
522 if include_dirs:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000523 affected = self._affected_files
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000524 else:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000525 affected = filter(lambda x: not x.IsDirectory(), self._affected_files)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000526
527 if include_deletes:
528 return affected
529 else:
530 return filter(lambda x: x.Action() != 'D', affected)
531
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000532 @deprecated
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000533 def AffectedTextFiles(self, include_deletes=True):
534 """Return a list of the text files in a change.
535
536 It's common to want to iterate over only the text files.
537
538 Args:
539 include_deletes: Controls whether to return files with "delete" actions,
540 which commonly aren't relevant to presubmit scripts.
541 """
542 return InputApi.FilterTextFiles(self.AffectedFiles(include_dirs=False),
543 include_deletes)
544
545 def LocalPaths(self, include_dirs=False):
546 """Convenience function."""
547 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
548
549 def AbsoluteLocalPaths(self, include_dirs=False):
550 """Convenience function."""
551 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
552
553 def ServerPaths(self, include_dirs=False):
554 """Convenience function."""
555 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
556
557 def RightHandSideLines(self):
558 """An iterator over all text lines in "new" version of changed files.
559
560 Lists lines from new or modified text files in the change.
561
562 This is useful for doing line-by-line regex checks, like checking for
563 trailing whitespace.
564
565 Yields:
566 a 3 tuple:
567 the AffectedFile instance of the current file;
568 integer line number (1-based); and
569 the contents of the line as a string.
570 """
571 return InputApi._RightHandSideLinesImpl(
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000572 filter(lambda x: x.IsTextFile(),
573 self.AffectedFiles(include_deletes=False)))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000574
575
576def ListRelevantPresubmitFiles(files):
577 """Finds all presubmit files that apply to a given set of source files.
578
579 Args:
580 files: An iterable container containing file paths.
581
582 Return:
583 ['foo/blat/PRESUBMIT.py', 'mat/gat/PRESUBMIT.py']
584 """
585 checked_dirs = {} # Keys are directory paths, values are ignored.
586 source_dirs = [os.path.dirname(f) for f in files]
587 presubmit_files = []
588 for dir in source_dirs:
589 while (True):
590 if dir in checked_dirs:
591 break # We've already walked up from this directory.
592
593 test_path = os.path.join(dir, 'PRESUBMIT.py')
594 if os.path.isfile(test_path):
595 presubmit_files.append(normpath(test_path))
596
597 checked_dirs[dir] = ''
598 if dir in ['', '.']:
599 break
600 else:
601 dir = os.path.dirname(dir)
602 return presubmit_files
603
604
605class PresubmitExecuter(object):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000606 def __init__(self, change_info, committing):
607 """
608 Args:
609 change_info: The ChangeInfo object for the change.
610 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
611 """
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000612 # TODO(maruel): Determine the SCM.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000613 self.change = GclChange(change_info, gcl.GetRepositoryRoot())
614 self.committing = committing
615
616 def ExecPresubmitScript(self, script_text, presubmit_path):
617 """Executes a single presubmit script.
618
619 Args:
620 script_text: The text of the presubmit script.
621 presubmit_path: The path to the presubmit file (this will be reported via
622 input_api.PresubmitLocalPath()).
623
624 Return:
625 A list of result objects, empty if no problems.
626 """
627 input_api = InputApi(self.change, presubmit_path)
628 context = {}
629 exec script_text in context
630
631 # These function names must change if we make substantial changes to
632 # the presubmit API that are not backwards compatible.
633 if self.committing:
634 function_name = 'CheckChangeOnCommit'
635 else:
636 function_name = 'CheckChangeOnUpload'
637 if function_name in context:
638 context['__args'] = (input_api, OutputApi())
639 result = eval(function_name + '(*__args)', context)
640 if not (isinstance(result, types.TupleType) or
641 isinstance(result, types.ListType)):
642 raise exceptions.RuntimeError(
643 'Presubmit functions must return a tuple or list')
644 for item in result:
645 if not isinstance(item, OutputApi.PresubmitResult):
646 raise exceptions.RuntimeError(
647 'All presubmit results must be of types derived from '
648 'output_api.PresubmitResult')
649 else:
650 result = () # no error since the script doesn't care about current event.
651
652 return result
653
654
655def DoPresubmitChecks(change_info,
656 committing,
657 verbose,
658 output_stream,
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000659 input_stream,
660 default_presubmit):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000661 """Runs all presubmit checks that apply to the files in the change.
662
663 This finds all PRESUBMIT.py files in directories enclosing the files in the
664 change (up to the repository root) and calls the relevant entrypoint function
665 depending on whether the change is being committed or uploaded.
666
667 Prints errors, warnings and notifications. Prompts the user for warnings
668 when needed.
669
670 Args:
671 change_info: The ChangeInfo object for the change.
672 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
673 verbose: Prints debug info.
674 output_stream: A stream to write output from presubmit tests to.
675 input_stream: A stream to read input from the user.
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000676 default_presubmit: A default presubmit script to execute in any case.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000677
678 Return:
679 True if execution can continue, False if not.
680 """
681 presubmit_files = ListRelevantPresubmitFiles(change_info.FileList())
682 if not presubmit_files and verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000683 output_stream.write("Warning, no presubmit.py found.\n")
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000684 results = []
685 executer = PresubmitExecuter(change_info, committing)
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000686 if default_presubmit:
687 if verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000688 output_stream.write("Running default presubmit script.\n")
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000689 results += executer.ExecPresubmitScript(default_presubmit, 'PRESUBMIT.py')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000690 for filename in presubmit_files:
maruel@chromium.org3d235242009-05-15 12:40:48 +0000691 filename = os.path.abspath(filename)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000692 if verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000693 output_stream.write("Running %s\n" % filename)
maruel@chromium.orgc1675e22009-04-27 20:30:48 +0000694 # Accept CRLF presubmit script.
maruel@chromium.org277003e2009-05-01 12:51:43 +0000695 presubmit_script = gcl.ReadFile(filename, 'rU')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000696 results += executer.ExecPresubmitScript(presubmit_script, filename)
697
698 errors = []
699 notifications = []
700 warnings = []
701 for result in results:
702 if not result.IsFatal() and not result.ShouldPrompt():
703 notifications.append(result)
704 elif result.ShouldPrompt():
705 warnings.append(result)
706 else:
707 errors.append(result)
708
709 error_count = 0
710 for name, items in (('Messages', notifications),
711 ('Warnings', warnings),
712 ('ERRORS', errors)):
713 if items:
714 output_stream.write('\n** Presubmit %s **\n\n' % name)
715 for item in items:
716 if not item._Handle(output_stream, input_stream,
717 may_prompt=False):
718 error_count += 1
719 output_stream.write('\n')
720 if not errors and warnings:
721 output_stream.write(
722 'There were presubmit warnings. Sure you want to continue? (y/N): ')
723 response = input_stream.readline()
724 if response.strip().lower() != 'y':
725 error_count += 1
726 return (error_count == 0)
727
728
729def ScanSubDirs(mask, recursive):
730 if not recursive:
731 return [x for x in glob.glob(mask) if '.svn' not in x]
732 else:
733 results = []
734 for root, dirs, files in os.walk('.'):
735 if '.svn' in dirs:
736 dirs.remove('.svn')
737 for name in files:
738 if fnmatch.fnmatch(name, mask):
739 results.append(os.path.join(root, name))
740 return results
741
742
743def ParseFiles(args, recursive):
744 files = []
745 for arg in args:
746 files.extend([('M', file) for file in ScanSubDirs(arg, recursive)])
747 return files
748
749
750def Main(argv):
751 parser = optparse.OptionParser(usage="%prog [options]",
752 version="%prog " + str(__version__))
753 parser.add_option("-c", "--commit", action="store_true",
754 help="Use commit instead of upload checks")
755 parser.add_option("-r", "--recursive", action="store_true",
756 help="Act recursively")
757 parser.add_option("-v", "--verbose", action="store_true",
758 help="Verbose output")
759 options, args = parser.parse_args(argv[1:])
760 files = ParseFiles(args, options.recursive)
761 if options.verbose:
762 print "Found %d files." % len(files)
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000763 return not DoPresubmitChecks(gcl.ChangeInfo(name='temp', files=files),
764 options.commit,
765 options.verbose,
766 sys.stdout,
767 sys.stdin,
768 default_presubmit=None)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000769
770
771if __name__ == '__main__':
772 sys.exit(Main(sys.argv))