blob: f12d31294f4b3abf9f4e4e7892ca1c5e34aa2f7e [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.org4661e0c2009-06-04 00:45:26 +0000233 return filter(
234 lambda x: normpath(x.AbsoluteLocalPath()).startswith(dir_with_slash),
235 self.change.AffectedFiles(include_dirs, include_deletes))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000236
237 def LocalPaths(self, include_dirs=False):
238 """Returns local paths of input_api.AffectedFiles()."""
239 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
240
241 def AbsoluteLocalPaths(self, include_dirs=False):
242 """Returns absolute local paths of input_api.AffectedFiles()."""
243 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
244
245 def ServerPaths(self, include_dirs=False):
246 """Returns server paths of input_api.AffectedFiles()."""
247 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
248
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000249 def AffectedTextFiles(self, include_deletes=None):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000250 """Same as input_api.change.AffectedTextFiles() except only lists files
251 in the same directory as the current presubmit script, or subdirectories
252 thereof.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000253 """
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000254 if include_deletes is not None:
255 warnings.warn("AffectedTextFiles(include_deletes=%s)"
256 " is deprecated and ignored" % str(include_deletes),
257 category=DeprecationWarning,
258 stacklevel=2)
259 return filter(lambda x: x.IsTextFile(),
260 self.AffectedFiles(include_dirs=False, include_deletes=False))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000261
262 def RightHandSideLines(self):
263 """An iterator over all text lines in "new" version of changed files.
264
265 Only lists lines from new or modified text files in the change that are
266 contained by the directory of the currently executing presubmit script.
267
268 This is useful for doing line-by-line regex checks, like checking for
269 trailing whitespace.
270
271 Yields:
272 a 3 tuple:
273 the AffectedFile instance of the current file;
274 integer line number (1-based); and
275 the contents of the line as a string.
276 """
277 return InputApi._RightHandSideLinesImpl(
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000278 filter(lambda x: x.IsTextFile(),
279 self.AffectedFiles(include_deletes=False)))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000280
281 @staticmethod
282 def _RightHandSideLinesImpl(affected_files):
283 """Implements RightHandSideLines for InputApi and GclChange."""
284 for af in affected_files:
285 lines = af.NewContents()
286 line_number = 0
287 for line in lines:
288 line_number += 1
289 yield (af, line_number, line)
290
291
292class AffectedFile(object):
293 """Representation of a file in a change."""
294
295 def __init__(self, path, action, repository_root=''):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000296 self._path = path
297 self._action = action
298 self._repository_root = repository_root
299 self._is_directory = None
300 self._properties = {}
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000301
302 def ServerPath(self):
303 """Returns a path string that identifies the file in the SCM system.
304
305 Returns the empty string if the file does not exist in SCM.
306 """
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000307 return ""
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000308
309 def LocalPath(self):
310 """Returns the path of this file on the local disk relative to client root.
311 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000312 return normpath(self._path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000313
314 def AbsoluteLocalPath(self):
315 """Returns the absolute path of this file on the local disk.
316 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000317 return normpath(os.path.join(self._repository_root, self.LocalPath()))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000318
319 def IsDirectory(self):
320 """Returns true if this object is a directory."""
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000321 if self._is_directory is None:
322 path = self.AbsoluteLocalPath()
323 self._is_directory = (os.path.exists(path) and
324 os.path.isdir(path))
325 return self._is_directory
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000326
327 def Action(self):
328 """Returns the action on this opened file, e.g. A, M, D, etc."""
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000329 # TODO(maruel): Somewhat crappy, Could be "A" or "A +" for svn but
330 # different for other SCM.
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000331 return self._action
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000332
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000333 def Property(self, property_name):
334 """Returns the specified SCM property of this file, or None if no such
335 property.
336 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000337 return self._properties.get(property_name, None)
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000338
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000339 def IsTextFile(self):
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000340 """Returns True if the file is a text file and not a binary file.
341
342 Deleted files are not text file."""
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000343 raise NotImplementedError() # Implement when needed
344
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000345 def NewContents(self):
346 """Returns an iterator over the lines in the new version of file.
347
348 The new version is the file in the user's workspace, i.e. the "right hand
349 side".
350
351 Contents will be empty if the file is a directory or does not exist.
352 """
353 if self.IsDirectory():
354 return []
355 else:
356 return gcl.ReadFile(self.AbsoluteLocalPath()).splitlines()
357
358 def OldContents(self):
359 """Returns an iterator over the lines in the old version of file.
360
361 The old version is the file in depot, i.e. the "left hand side".
362 """
363 raise NotImplementedError() # Implement when needed
364
365 def OldFileTempPath(self):
366 """Returns the path on local disk where the old contents resides.
367
368 The old version is the file in depot, i.e. the "left hand side".
369 This is a read-only cached copy of the old contents. *DO NOT* try to
370 modify this file.
371 """
372 raise NotImplementedError() # Implement if/when needed.
373
374
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000375class SvnAffectedFile(AffectedFile):
376 """Representation of a file in a change out of a Subversion checkout."""
377
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000378 def __init__(self, *args, **kwargs):
379 AffectedFile.__init__(self, *args, **kwargs)
380 self._server_path = None
381 self._is_text_file = None
382
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000383 def ServerPath(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000384 if self._server_path is None:
385 self._server_path = gclient.CaptureSVNInfo(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000386 self.AbsoluteLocalPath()).get('URL', '')
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000387 return self._server_path
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000388
389 def IsDirectory(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000390 if self._is_directory is None:
391 path = self.AbsoluteLocalPath()
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000392 if os.path.exists(path):
393 # Retrieve directly from the file system; it is much faster than
394 # querying subversion, especially on Windows.
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000395 self._is_directory = os.path.isdir(path)
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000396 else:
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000397 self._is_directory = gclient.CaptureSVNInfo(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000398 path).get('Node Kind') in ('dir', 'directory')
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000399 return self._is_directory
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000400
401 def Property(self, property_name):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000402 if not property_name in self._properties:
403 self._properties[property_name] = gcl.GetSVNFileProperty(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000404 self.AbsoluteLocalPath(), property_name)
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000405 return self._properties[property_name]
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000406
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000407 def IsTextFile(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000408 if self._is_text_file is None:
409 if self.Action() == 'D':
410 # A deleted file is not a text file.
411 self._is_text_file = False
412 elif self.IsDirectory():
413 self._is_text_file = False
414 else:
415 mime_type = gcl.GetSVNFileProperty(self.AbsoluteLocalPath(),
416 'svn:mime-type')
417 self._is_text_file = (not mime_type or mime_type.startswith('text/'))
418 return self._is_text_file
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000419
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000420
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000421class GclChange(object):
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000422 """Describe a change.
423
424 Used directly by the presubmit scripts to query the current change being
425 tested.
426
427 Instance members:
428 tags: Dictionnary of KEY=VALUE pairs found in the change description.
429 self.KEY: equivalent to tags['KEY']
430 """
431
432 # Matches key/value (or "tag") lines in changelist descriptions.
433 _tag_line_re = re.compile(
434 '^\s*(?P<key>[A-Z][A-Z_0-9]*)\s*=\s*(?P<value>.*?)\s*$')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000435
436 def __init__(self, change_info, repository_root=''):
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000437 # Do not keep a reference to the original change_info.
438 self._name = change_info.name
439 self._full_description = change_info.description
440 self._repository_root = repository_root
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000441
442 # From the description text, build up a dictionary of key/value pairs
443 # plus the description minus all key/value or "tag" lines.
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000444 self._description_without_tags = []
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000445 self.tags = {}
446 for line in change_info.description.splitlines():
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000447 m = self._tag_line_re.match(line)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000448 if m:
449 self.tags[m.group('key')] = m.group('value')
450 else:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000451 self._description_without_tags.append(line)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000452
453 # Change back to text and remove whitespace at end.
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000454 self._description_without_tags = '\n'.join(self._description_without_tags)
455 self._description_without_tags = self._description_without_tags.rstrip()
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000456
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000457 self._affected_files = [
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000458 SvnAffectedFile(info[1], info[0].strip(), repository_root)
459 for info in change_info.files
460 ]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000461
462 def Change(self):
463 """Returns the change name."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000464 return self._name
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000465
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000466 def DescriptionText(self):
467 """Returns the user-entered changelist description, minus tags.
468
469 Any line in the user-provided description starting with e.g. "FOO="
470 (whitespace permitted before and around) is considered a tag line. Such
471 lines are stripped out of the description this function returns.
472 """
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000473 return self._description_without_tags
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000474
475 def FullDescriptionText(self):
476 """Returns the complete changelist description including tags."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000477 return self._full_description
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000478
479 def RepositoryRoot(self):
480 """Returns the repository root for this change, as an absolute path."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000481 return self._repository_root
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000482
483 def __getattr__(self, attr):
484 """Return keys directly as attributes on the object.
485
486 You may use a friendly name (from SPECIAL_KEYS) or the actual name of
487 the key.
488 """
maruel@chromium.orge1a524f2009-05-27 14:43:46 +0000489 return self.tags.get(attr)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000490
491 def AffectedFiles(self, include_dirs=False, include_deletes=True):
492 """Returns a list of AffectedFile instances for all files in the change.
493
494 Args:
495 include_deletes: If false, deleted files will be filtered out.
496 include_dirs: True to include directories in the list
497
498 Returns:
499 [AffectedFile(path, action), AffectedFile(path, action)]
500 """
501 if include_dirs:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000502 affected = self._affected_files
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000503 else:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000504 affected = filter(lambda x: not x.IsDirectory(), self._affected_files)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000505
506 if include_deletes:
507 return affected
508 else:
509 return filter(lambda x: x.Action() != 'D', affected)
510
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000511 def AffectedTextFiles(self, include_deletes=None):
512 """Return a list of the existing text files in a change."""
513 if include_deletes is not None:
514 warnings.warn("AffectedTextFiles(include_deletes=%s)"
515 " is deprecated and ignored" % str(include_deletes),
516 category=DeprecationWarning,
517 stacklevel=2)
518 return filter(lambda x: x.IsTextFile(),
519 self.AffectedFiles(include_dirs=False, include_deletes=False))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000520
521 def LocalPaths(self, include_dirs=False):
522 """Convenience function."""
523 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
524
525 def AbsoluteLocalPaths(self, include_dirs=False):
526 """Convenience function."""
527 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
528
529 def ServerPaths(self, include_dirs=False):
530 """Convenience function."""
531 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
532
533 def RightHandSideLines(self):
534 """An iterator over all text lines in "new" version of changed files.
535
536 Lists lines from new or modified text files in the change.
537
538 This is useful for doing line-by-line regex checks, like checking for
539 trailing whitespace.
540
541 Yields:
542 a 3 tuple:
543 the AffectedFile instance of the current file;
544 integer line number (1-based); and
545 the contents of the line as a string.
546 """
547 return InputApi._RightHandSideLinesImpl(
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000548 filter(lambda x: x.IsTextFile(),
549 self.AffectedFiles(include_deletes=False)))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000550
551
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000552def ListRelevantPresubmitFiles(files, root):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000553 """Finds all presubmit files that apply to a given set of source files.
554
555 Args:
556 files: An iterable container containing file paths.
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000557 root: Path where to stop searching.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000558
559 Return:
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000560 List of absolute paths of the existing PRESUBMIT.py scripts.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000561 """
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000562 entries = []
563 for f in files:
564 f = normpath(os.path.join(root, f))
565 while f:
566 f = os.path.dirname(f)
567 if f in entries:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000568 break
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000569 entries.append(f)
570 if f == root:
571 break
572 entries.sort()
573 entries = map(lambda x: os.path.join(x, 'PRESUBMIT.py'), entries)
574 return filter(lambda x: os.path.isfile(x), entries)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000575
576
577class PresubmitExecuter(object):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000578 def __init__(self, change_info, committing):
579 """
580 Args:
581 change_info: The ChangeInfo object for the change.
582 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
583 """
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000584 # TODO(maruel): Determine the SCM.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000585 self.change = GclChange(change_info, gcl.GetRepositoryRoot())
586 self.committing = committing
587
588 def ExecPresubmitScript(self, script_text, presubmit_path):
589 """Executes a single presubmit script.
590
591 Args:
592 script_text: The text of the presubmit script.
593 presubmit_path: The path to the presubmit file (this will be reported via
594 input_api.PresubmitLocalPath()).
595
596 Return:
597 A list of result objects, empty if no problems.
598 """
599 input_api = InputApi(self.change, presubmit_path)
600 context = {}
601 exec script_text in context
602
603 # These function names must change if we make substantial changes to
604 # the presubmit API that are not backwards compatible.
605 if self.committing:
606 function_name = 'CheckChangeOnCommit'
607 else:
608 function_name = 'CheckChangeOnUpload'
609 if function_name in context:
610 context['__args'] = (input_api, OutputApi())
611 result = eval(function_name + '(*__args)', context)
612 if not (isinstance(result, types.TupleType) or
613 isinstance(result, types.ListType)):
614 raise exceptions.RuntimeError(
615 'Presubmit functions must return a tuple or list')
616 for item in result:
617 if not isinstance(item, OutputApi.PresubmitResult):
618 raise exceptions.RuntimeError(
619 'All presubmit results must be of types derived from '
620 'output_api.PresubmitResult')
621 else:
622 result = () # no error since the script doesn't care about current event.
623
624 return result
625
626
627def DoPresubmitChecks(change_info,
628 committing,
629 verbose,
630 output_stream,
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000631 input_stream,
632 default_presubmit):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000633 """Runs all presubmit checks that apply to the files in the change.
634
635 This finds all PRESUBMIT.py files in directories enclosing the files in the
636 change (up to the repository root) and calls the relevant entrypoint function
637 depending on whether the change is being committed or uploaded.
638
639 Prints errors, warnings and notifications. Prompts the user for warnings
640 when needed.
641
642 Args:
643 change_info: The ChangeInfo object for the change.
644 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
645 verbose: Prints debug info.
646 output_stream: A stream to write output from presubmit tests to.
647 input_stream: A stream to read input from the user.
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000648 default_presubmit: A default presubmit script to execute in any case.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000649
650 Return:
651 True if execution can continue, False if not.
652 """
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000653 checkout_root = gcl.GetRepositoryRoot()
654 presubmit_files = ListRelevantPresubmitFiles(change_info.FileList(),
655 checkout_root)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000656 if not presubmit_files and verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000657 output_stream.write("Warning, no presubmit.py found.\n")
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000658 results = []
659 executer = PresubmitExecuter(change_info, committing)
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000660 if default_presubmit:
661 if verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000662 output_stream.write("Running default presubmit script.\n")
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000663 fake_path = os.path.join(checkout_root, 'PRESUBMIT.py')
664 results += executer.ExecPresubmitScript(default_presubmit, fake_path)
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))