blob: a95d8557f2b1611dd1871d8f055a4852c428aa34 [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
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000225 def AffectedFiles(self, include_dirs=False, include_deletes=True):
226 """Same as input_api.change.AffectedFiles() except only lists files
227 (and optionally directories) in the same directory as the current presubmit
228 script, or subdirectories thereof.
229 """
maruel@chromium.org3d235242009-05-15 12:40:48 +0000230 dir_with_slash = normpath("%s/" % self.PresubmitLocalPath())
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000231 if len(dir_with_slash) == 1:
232 dir_with_slash = ''
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000233 return filter(lambda x: normpath(x.LocalPath()).startswith(dir_with_slash),
234 self.change.AffectedFiles(include_dirs, include_deletes))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000235
236 def LocalPaths(self, include_dirs=False):
237 """Returns local paths of input_api.AffectedFiles()."""
238 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
239
240 def AbsoluteLocalPaths(self, include_dirs=False):
241 """Returns absolute local paths of input_api.AffectedFiles()."""
242 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
243
244 def ServerPaths(self, include_dirs=False):
245 """Returns server paths of input_api.AffectedFiles()."""
246 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
247
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000248 def AffectedTextFiles(self, include_deletes=None):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000249 """Same as input_api.change.AffectedTextFiles() except only lists files
250 in the same directory as the current presubmit script, or subdirectories
251 thereof.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000252 """
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000253 if include_deletes is not None:
254 warnings.warn("AffectedTextFiles(include_deletes=%s)"
255 " is deprecated and ignored" % str(include_deletes),
256 category=DeprecationWarning,
257 stacklevel=2)
258 return filter(lambda x: x.IsTextFile(),
259 self.AffectedFiles(include_dirs=False, include_deletes=False))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000260
261 def RightHandSideLines(self):
262 """An iterator over all text lines in "new" version of changed files.
263
264 Only lists lines from new or modified text files in the change that are
265 contained by the directory of the currently executing presubmit script.
266
267 This is useful for doing line-by-line regex checks, like checking for
268 trailing whitespace.
269
270 Yields:
271 a 3 tuple:
272 the AffectedFile instance of the current file;
273 integer line number (1-based); and
274 the contents of the line as a string.
275 """
276 return InputApi._RightHandSideLinesImpl(
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000277 filter(lambda x: x.IsTextFile(),
278 self.AffectedFiles(include_deletes=False)))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000279
280 @staticmethod
281 def _RightHandSideLinesImpl(affected_files):
282 """Implements RightHandSideLines for InputApi and GclChange."""
283 for af in affected_files:
284 lines = af.NewContents()
285 line_number = 0
286 for line in lines:
287 line_number += 1
288 yield (af, line_number, line)
289
290
291class AffectedFile(object):
292 """Representation of a file in a change."""
293
294 def __init__(self, path, action, repository_root=''):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000295 self._path = path
296 self._action = action
297 self._repository_root = repository_root
298 self._is_directory = None
299 self._properties = {}
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000300
301 def ServerPath(self):
302 """Returns a path string that identifies the file in the SCM system.
303
304 Returns the empty string if the file does not exist in SCM.
305 """
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000306 return ""
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000307
308 def LocalPath(self):
309 """Returns the path of this file on the local disk relative to client root.
310 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000311 return normpath(self._path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000312
313 def AbsoluteLocalPath(self):
314 """Returns the absolute path of this file on the local disk.
315 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000316 return normpath(os.path.join(self._repository_root, self.LocalPath()))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000317
318 def IsDirectory(self):
319 """Returns true if this object is a directory."""
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000320 if self._is_directory is None:
321 path = self.AbsoluteLocalPath()
322 self._is_directory = (os.path.exists(path) and
323 os.path.isdir(path))
324 return self._is_directory
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000325
326 def Action(self):
327 """Returns the action on this opened file, e.g. A, M, D, etc."""
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000328 # TODO(maruel): Somewhat crappy, Could be "A" or "A +" for svn but
329 # different for other SCM.
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000330 return self._action
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000331
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000332 def Property(self, property_name):
333 """Returns the specified SCM property of this file, or None if no such
334 property.
335 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000336 return self._properties.get(property_name, None)
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000337
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000338 def IsTextFile(self):
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000339 """Returns True if the file is a text file and not a binary file.
340
341 Deleted files are not text file."""
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000342 raise NotImplementedError() # Implement when needed
343
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000344 def NewContents(self):
345 """Returns an iterator over the lines in the new version of file.
346
347 The new version is the file in the user's workspace, i.e. the "right hand
348 side".
349
350 Contents will be empty if the file is a directory or does not exist.
351 """
352 if self.IsDirectory():
353 return []
354 else:
355 return gcl.ReadFile(self.AbsoluteLocalPath()).splitlines()
356
357 def OldContents(self):
358 """Returns an iterator over the lines in the old version of file.
359
360 The old version is the file in depot, i.e. the "left hand side".
361 """
362 raise NotImplementedError() # Implement when needed
363
364 def OldFileTempPath(self):
365 """Returns the path on local disk where the old contents resides.
366
367 The old version is the file in depot, i.e. the "left hand side".
368 This is a read-only cached copy of the old contents. *DO NOT* try to
369 modify this file.
370 """
371 raise NotImplementedError() # Implement if/when needed.
372
373
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000374class SvnAffectedFile(AffectedFile):
375 """Representation of a file in a change out of a Subversion checkout."""
376
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000377 def __init__(self, *args, **kwargs):
378 AffectedFile.__init__(self, *args, **kwargs)
379 self._server_path = None
380 self._is_text_file = None
381
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000382 def ServerPath(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000383 if self._server_path is None:
384 self._server_path = gclient.CaptureSVNInfo(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000385 self.AbsoluteLocalPath()).get('URL', '')
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000386 return self._server_path
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000387
388 def IsDirectory(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000389 if self._is_directory is None:
390 path = self.AbsoluteLocalPath()
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000391 if os.path.exists(path):
392 # Retrieve directly from the file system; it is much faster than
393 # querying subversion, especially on Windows.
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000394 self._is_directory = os.path.isdir(path)
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000395 else:
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000396 self._is_directory = gclient.CaptureSVNInfo(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000397 path).get('Node Kind') in ('dir', 'directory')
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000398 return self._is_directory
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000399
400 def Property(self, property_name):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000401 if not property_name in self._properties:
402 self._properties[property_name] = gcl.GetSVNFileProperty(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000403 self.AbsoluteLocalPath(), property_name)
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000404 return self._properties[property_name]
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000405
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000406 def IsTextFile(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000407 if self._is_text_file is None:
408 if self.Action() == 'D':
409 # A deleted file is not a text file.
410 self._is_text_file = False
411 elif self.IsDirectory():
412 self._is_text_file = False
413 else:
414 mime_type = gcl.GetSVNFileProperty(self.AbsoluteLocalPath(),
415 'svn:mime-type')
416 self._is_text_file = (not mime_type or mime_type.startswith('text/'))
417 return self._is_text_file
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000418
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000419
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000420class GclChange(object):
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000421 """Describe a change.
422
423 Used directly by the presubmit scripts to query the current change being
424 tested.
425
426 Instance members:
427 tags: Dictionnary of KEY=VALUE pairs found in the change description.
428 self.KEY: equivalent to tags['KEY']
429 """
430
431 # Matches key/value (or "tag") lines in changelist descriptions.
432 _tag_line_re = re.compile(
433 '^\s*(?P<key>[A-Z][A-Z_0-9]*)\s*=\s*(?P<value>.*?)\s*$')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000434
435 def __init__(self, change_info, repository_root=''):
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000436 # Do not keep a reference to the original change_info.
437 self._name = change_info.name
438 self._full_description = change_info.description
439 self._repository_root = repository_root
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000440
441 # From the description text, build up a dictionary of key/value pairs
442 # plus the description minus all key/value or "tag" lines.
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000443 self._description_without_tags = []
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000444 self.tags = {}
445 for line in change_info.description.splitlines():
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000446 m = self._tag_line_re.match(line)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000447 if m:
448 self.tags[m.group('key')] = m.group('value')
449 else:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000450 self._description_without_tags.append(line)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000451
452 # Change back to text and remove whitespace at end.
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000453 self._description_without_tags = '\n'.join(self._description_without_tags)
454 self._description_without_tags = self._description_without_tags.rstrip()
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000455
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000456 self._affected_files = [
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000457 SvnAffectedFile(info[1], info[0].strip(), repository_root)
458 for info in change_info.files
459 ]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000460
461 def Change(self):
462 """Returns the change name."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000463 return self._name
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000464
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000465 def DescriptionText(self):
466 """Returns the user-entered changelist description, minus tags.
467
468 Any line in the user-provided description starting with e.g. "FOO="
469 (whitespace permitted before and around) is considered a tag line. Such
470 lines are stripped out of the description this function returns.
471 """
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000472 return self._description_without_tags
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000473
474 def FullDescriptionText(self):
475 """Returns the complete changelist description including tags."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000476 return self._full_description
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000477
478 def RepositoryRoot(self):
479 """Returns the repository root for this change, as an absolute path."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000480 return self._repository_root
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000481
482 def __getattr__(self, attr):
483 """Return keys directly as attributes on the object.
484
485 You may use a friendly name (from SPECIAL_KEYS) or the actual name of
486 the key.
487 """
maruel@chromium.orge1a524f2009-05-27 14:43:46 +0000488 return self.tags.get(attr)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000489
490 def AffectedFiles(self, include_dirs=False, include_deletes=True):
491 """Returns a list of AffectedFile instances for all files in the change.
492
493 Args:
494 include_deletes: If false, deleted files will be filtered out.
495 include_dirs: True to include directories in the list
496
497 Returns:
498 [AffectedFile(path, action), AffectedFile(path, action)]
499 """
500 if include_dirs:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000501 affected = self._affected_files
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000502 else:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000503 affected = filter(lambda x: not x.IsDirectory(), self._affected_files)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000504
505 if include_deletes:
506 return affected
507 else:
508 return filter(lambda x: x.Action() != 'D', affected)
509
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000510 def AffectedTextFiles(self, include_deletes=None):
511 """Return a list of the existing text files in a change."""
512 if include_deletes is not None:
513 warnings.warn("AffectedTextFiles(include_deletes=%s)"
514 " is deprecated and ignored" % str(include_deletes),
515 category=DeprecationWarning,
516 stacklevel=2)
517 return filter(lambda x: x.IsTextFile(),
518 self.AffectedFiles(include_dirs=False, include_deletes=False))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000519
520 def LocalPaths(self, include_dirs=False):
521 """Convenience function."""
522 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
523
524 def AbsoluteLocalPaths(self, include_dirs=False):
525 """Convenience function."""
526 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
527
528 def ServerPaths(self, include_dirs=False):
529 """Convenience function."""
530 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
531
532 def RightHandSideLines(self):
533 """An iterator over all text lines in "new" version of changed files.
534
535 Lists lines from new or modified text files in the change.
536
537 This is useful for doing line-by-line regex checks, like checking for
538 trailing whitespace.
539
540 Yields:
541 a 3 tuple:
542 the AffectedFile instance of the current file;
543 integer line number (1-based); and
544 the contents of the line as a string.
545 """
546 return InputApi._RightHandSideLinesImpl(
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000547 filter(lambda x: x.IsTextFile(),
548 self.AffectedFiles(include_deletes=False)))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000549
550
551def ListRelevantPresubmitFiles(files):
552 """Finds all presubmit files that apply to a given set of source files.
553
554 Args:
555 files: An iterable container containing file paths.
556
557 Return:
558 ['foo/blat/PRESUBMIT.py', 'mat/gat/PRESUBMIT.py']
559 """
560 checked_dirs = {} # Keys are directory paths, values are ignored.
561 source_dirs = [os.path.dirname(f) for f in files]
562 presubmit_files = []
563 for dir in source_dirs:
564 while (True):
565 if dir in checked_dirs:
566 break # We've already walked up from this directory.
567
568 test_path = os.path.join(dir, 'PRESUBMIT.py')
569 if os.path.isfile(test_path):
570 presubmit_files.append(normpath(test_path))
571
572 checked_dirs[dir] = ''
573 if dir in ['', '.']:
574 break
575 else:
576 dir = os.path.dirname(dir)
577 return presubmit_files
578
579
580class PresubmitExecuter(object):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000581 def __init__(self, change_info, committing):
582 """
583 Args:
584 change_info: The ChangeInfo object for the change.
585 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
586 """
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000587 # TODO(maruel): Determine the SCM.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000588 self.change = GclChange(change_info, gcl.GetRepositoryRoot())
589 self.committing = committing
590
591 def ExecPresubmitScript(self, script_text, presubmit_path):
592 """Executes a single presubmit script.
593
594 Args:
595 script_text: The text of the presubmit script.
596 presubmit_path: The path to the presubmit file (this will be reported via
597 input_api.PresubmitLocalPath()).
598
599 Return:
600 A list of result objects, empty if no problems.
601 """
602 input_api = InputApi(self.change, presubmit_path)
603 context = {}
604 exec script_text in context
605
606 # These function names must change if we make substantial changes to
607 # the presubmit API that are not backwards compatible.
608 if self.committing:
609 function_name = 'CheckChangeOnCommit'
610 else:
611 function_name = 'CheckChangeOnUpload'
612 if function_name in context:
613 context['__args'] = (input_api, OutputApi())
614 result = eval(function_name + '(*__args)', context)
615 if not (isinstance(result, types.TupleType) or
616 isinstance(result, types.ListType)):
617 raise exceptions.RuntimeError(
618 'Presubmit functions must return a tuple or list')
619 for item in result:
620 if not isinstance(item, OutputApi.PresubmitResult):
621 raise exceptions.RuntimeError(
622 'All presubmit results must be of types derived from '
623 'output_api.PresubmitResult')
624 else:
625 result = () # no error since the script doesn't care about current event.
626
627 return result
628
629
630def DoPresubmitChecks(change_info,
631 committing,
632 verbose,
633 output_stream,
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000634 input_stream,
635 default_presubmit):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000636 """Runs all presubmit checks that apply to the files in the change.
637
638 This finds all PRESUBMIT.py files in directories enclosing the files in the
639 change (up to the repository root) and calls the relevant entrypoint function
640 depending on whether the change is being committed or uploaded.
641
642 Prints errors, warnings and notifications. Prompts the user for warnings
643 when needed.
644
645 Args:
646 change_info: The ChangeInfo object for the change.
647 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
648 verbose: Prints debug info.
649 output_stream: A stream to write output from presubmit tests to.
650 input_stream: A stream to read input from the user.
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000651 default_presubmit: A default presubmit script to execute in any case.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000652
653 Return:
654 True if execution can continue, False if not.
655 """
656 presubmit_files = ListRelevantPresubmitFiles(change_info.FileList())
657 if not presubmit_files and verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000658 output_stream.write("Warning, no presubmit.py found.\n")
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000659 results = []
660 executer = PresubmitExecuter(change_info, committing)
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000661 if default_presubmit:
662 if verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000663 output_stream.write("Running default presubmit script.\n")
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000664 results += executer.ExecPresubmitScript(default_presubmit, 'PRESUBMIT.py')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000665 for filename in presubmit_files:
maruel@chromium.org3d235242009-05-15 12:40:48 +0000666 filename = os.path.abspath(filename)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000667 if verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000668 output_stream.write("Running %s\n" % filename)
maruel@chromium.orgc1675e22009-04-27 20:30:48 +0000669 # Accept CRLF presubmit script.
maruel@chromium.org277003e2009-05-01 12:51:43 +0000670 presubmit_script = gcl.ReadFile(filename, 'rU')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000671 results += executer.ExecPresubmitScript(presubmit_script, filename)
672
673 errors = []
674 notifications = []
675 warnings = []
676 for result in results:
677 if not result.IsFatal() and not result.ShouldPrompt():
678 notifications.append(result)
679 elif result.ShouldPrompt():
680 warnings.append(result)
681 else:
682 errors.append(result)
683
684 error_count = 0
685 for name, items in (('Messages', notifications),
686 ('Warnings', warnings),
687 ('ERRORS', errors)):
688 if items:
689 output_stream.write('\n** Presubmit %s **\n\n' % name)
690 for item in items:
691 if not item._Handle(output_stream, input_stream,
692 may_prompt=False):
693 error_count += 1
694 output_stream.write('\n')
695 if not errors and warnings:
696 output_stream.write(
697 'There were presubmit warnings. Sure you want to continue? (y/N): ')
698 response = input_stream.readline()
699 if response.strip().lower() != 'y':
700 error_count += 1
701 return (error_count == 0)
702
703
704def ScanSubDirs(mask, recursive):
705 if not recursive:
706 return [x for x in glob.glob(mask) if '.svn' not in x]
707 else:
708 results = []
709 for root, dirs, files in os.walk('.'):
710 if '.svn' in dirs:
711 dirs.remove('.svn')
712 for name in files:
713 if fnmatch.fnmatch(name, mask):
714 results.append(os.path.join(root, name))
715 return results
716
717
718def ParseFiles(args, recursive):
719 files = []
720 for arg in args:
721 files.extend([('M', file) for file in ScanSubDirs(arg, recursive)])
722 return files
723
724
725def Main(argv):
726 parser = optparse.OptionParser(usage="%prog [options]",
727 version="%prog " + str(__version__))
728 parser.add_option("-c", "--commit", action="store_true",
729 help="Use commit instead of upload checks")
730 parser.add_option("-r", "--recursive", action="store_true",
731 help="Act recursively")
732 parser.add_option("-v", "--verbose", action="store_true",
733 help="Verbose output")
734 options, args = parser.parse_args(argv[1:])
735 files = ParseFiles(args, options.recursive)
736 if options.verbose:
737 print "Found %d files." % len(files)
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000738 return not DoPresubmitChecks(gcl.ChangeInfo(name='temp', files=files),
739 options.commit,
740 options.verbose,
741 sys.stdout,
742 sys.stdin,
743 default_presubmit=None)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000744
745
746if __name__ == '__main__':
747 sys.exit(Main(sys.argv))