blob: 928929e35f74554d6126816d9d46945077034b0b [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.org1487d532009-06-06 00:22:57 +00009__version__ = '1.2'
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000010
11# TODO(joi) Add caching where appropriate/needed. The API is designed to allow
12# caching (between all different invocations of presubmit scripts for a given
13# change). We should add it as our presubmit scripts start feeling slow.
14
15import cPickle # Exposed through the API.
16import cStringIO # Exposed through the API.
17import exceptions
18import fnmatch
19import glob
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
maruel@chromium.org1487d532009-06-06 00:22:57 +000029import unittest # Exposed through the API.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000030import urllib2 # Exposed through the API.
maruel@chromium.org1e08c002009-05-28 19:09:33 +000031import warnings
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000032
33# Local imports.
34# TODO(joi) Would be cleaner to factor out utils in gcl to separate module, but
35# for now it would only be a couple of functions so hardly worth it.
36import gcl
maruel@chromium.org46a94102009-05-12 20:32:43 +000037import gclient
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000038import presubmit_canned_checks
39
40
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000041class NotImplementedException(Exception):
42 """We're leaving placeholders in a bunch of places to remind us of the
43 design of the API, but we have not implemented all of it yet. Implement as
44 the need arises.
45 """
46 pass
47
48
49def normpath(path):
50 '''Version of os.path.normpath that also changes backward slashes to
51 forward slashes when not running on Windows.
52 '''
53 # This is safe to always do because the Windows version of os.path.normpath
54 # will replace forward slashes with backward slashes.
55 path = path.replace(os.sep, '/')
56 return os.path.normpath(path)
57
58
maruel@chromium.org1e08c002009-05-28 19:09:33 +000059def deprecated(func):
60 """This is a decorator which can be used to mark functions as deprecated.
61
62 It will result in a warning being emmitted when the function is used."""
63 def newFunc(*args, **kwargs):
64 warnings.warn("Call to deprecated function %s." % func.__name__,
65 category=DeprecationWarning,
66 stacklevel=2)
67 return func(*args, **kwargs)
68 newFunc.__name__ = func.__name__
69 newFunc.__doc__ = func.__doc__
70 newFunc.__dict__.update(func.__dict__)
71 return newFunc
72
73
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000074class OutputApi(object):
75 """This class (more like a module) gets passed to presubmit scripts so that
76 they can specify various types of results.
77 """
78
79 class PresubmitResult(object):
80 """Base class for result objects."""
81
82 def __init__(self, message, items=None, long_text=''):
83 """
84 message: A short one-line message to indicate errors.
85 items: A list of short strings to indicate where errors occurred.
86 long_text: multi-line text output, e.g. from another tool
87 """
88 self._message = message
89 self._items = []
90 if items:
91 self._items = items
92 self._long_text = long_text.rstrip()
93
94 def _Handle(self, output_stream, input_stream, may_prompt=True):
95 """Writes this result to the output stream.
96
97 Args:
98 output_stream: Where to write
99
100 Returns:
101 True if execution may continue, False otherwise.
102 """
103 output_stream.write(self._message)
104 output_stream.write('\n')
105 for item in self._items:
106 output_stream.write(' %s\n' % item)
107 if self._long_text:
108 output_stream.write('\n***************\n%s\n***************\n\n' %
109 self._long_text)
110
111 if self.ShouldPrompt() and may_prompt:
112 output_stream.write('Are you sure you want to continue? (y/N): ')
113 response = input_stream.readline()
114 if response.strip().lower() != 'y':
115 return False
116
117 return not self.IsFatal()
118
119 def IsFatal(self):
120 """An error that is fatal stops g4 mail/submit immediately, i.e. before
121 other presubmit scripts are run.
122 """
123 return False
124
125 def ShouldPrompt(self):
126 """Whether this presubmit result should result in a prompt warning."""
127 return False
128
129 class PresubmitError(PresubmitResult):
130 """A hard presubmit error."""
131 def IsFatal(self):
132 return True
133
134 class PresubmitPromptWarning(PresubmitResult):
135 """An warning that prompts the user if they want to continue."""
136 def ShouldPrompt(self):
137 return True
138
139 class PresubmitNotifyResult(PresubmitResult):
140 """Just print something to the screen -- but it's not even a warning."""
141 pass
142
143 class MailTextResult(PresubmitResult):
144 """A warning that should be included in the review request email."""
145 def __init__(self, *args, **kwargs):
146 raise NotImplementedException() # TODO(joi) Implement.
147
148
149class InputApi(object):
150 """An instance of this object is passed to presubmit scripts so they can
151 know stuff about the change they're looking at.
152 """
153
154 def __init__(self, change, presubmit_path):
155 """Builds an InputApi object.
156
157 Args:
158 change: A presubmit.GclChange object.
159 presubmit_path: The path to the presubmit script being processed.
160 """
maruel@chromium.org9711bba2009-05-22 23:51:39 +0000161 # Version number of the presubmit_support script.
162 self.version = [int(x) for x in __version__.split('.')]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000163 self.change = change
164
165 # We expose various modules and functions as attributes of the input_api
166 # so that presubmit scripts don't have to import them.
167 self.basename = os.path.basename
168 self.cPickle = cPickle
169 self.cStringIO = cStringIO
170 self.os_path = os.path
171 self.pickle = pickle
172 self.marshal = marshal
173 self.re = re
174 self.subprocess = subprocess
175 self.tempfile = tempfile
maruel@chromium.org1487d532009-06-06 00:22:57 +0000176 self.unittest = unittest
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000177 self.urllib2 = urllib2
178
179 # InputApi.platform is the platform you're currently running on.
180 self.platform = sys.platform
181
182 # The local path of the currently-being-processed presubmit script.
maruel@chromium.org3d235242009-05-15 12:40:48 +0000183 self._current_presubmit_path = os.path.dirname(presubmit_path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000184
185 # We carry the canned checks so presubmit scripts can easily use them.
186 self.canned_checks = presubmit_canned_checks
187
188 def PresubmitLocalPath(self):
189 """Returns the local path of the presubmit script currently being run.
190
191 This is useful if you don't want to hard-code absolute paths in the
192 presubmit script. For example, It can be used to find another file
193 relative to the PRESUBMIT.py script, so the whole tree can be branched and
194 the presubmit script still works, without editing its content.
195 """
maruel@chromium.org3d235242009-05-15 12:40:48 +0000196 return self._current_presubmit_path
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000197
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000198 def DepotToLocalPath(self, depot_path):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000199 """Translate a depot path to a local path (relative to client root).
200
201 Args:
202 Depot path as a string.
203
204 Returns:
205 The local path of the depot path under the user's current client, or None
206 if the file is not mapped.
207
208 Remember to check for the None case and show an appropriate error!
209 """
maruel@chromium.org46a94102009-05-12 20:32:43 +0000210 local_path = gclient.CaptureSVNInfo(depot_path).get('Path')
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000211 if local_path:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000212 return local_path
213
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000214 def LocalToDepotPath(self, local_path):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000215 """Translate a local path to a depot path.
216
217 Args:
218 Local path (relative to current directory, or absolute) as a string.
219
220 Returns:
221 The depot path (SVN URL) of the file if mapped, otherwise None.
222 """
maruel@chromium.org46a94102009-05-12 20:32:43 +0000223 depot_path = gclient.CaptureSVNInfo(local_path).get('URL')
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000224 if depot_path:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000225 return depot_path
226
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000227 def AffectedFiles(self, include_dirs=False, include_deletes=True):
228 """Same as input_api.change.AffectedFiles() except only lists files
229 (and optionally directories) in the same directory as the current presubmit
230 script, or subdirectories thereof.
231 """
maruel@chromium.org3d235242009-05-15 12:40:48 +0000232 dir_with_slash = normpath("%s/" % self.PresubmitLocalPath())
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000233 if len(dir_with_slash) == 1:
234 dir_with_slash = ''
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000235 return filter(
236 lambda x: normpath(x.AbsoluteLocalPath()).startswith(dir_with_slash),
237 self.change.AffectedFiles(include_dirs, include_deletes))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000238
239 def LocalPaths(self, include_dirs=False):
240 """Returns local paths of input_api.AffectedFiles()."""
241 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
242
243 def AbsoluteLocalPaths(self, include_dirs=False):
244 """Returns absolute local paths of input_api.AffectedFiles()."""
245 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
246
247 def ServerPaths(self, include_dirs=False):
248 """Returns server paths of input_api.AffectedFiles()."""
249 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
250
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000251 def AffectedTextFiles(self, include_deletes=None):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000252 """Same as input_api.change.AffectedTextFiles() except only lists files
253 in the same directory as the current presubmit script, or subdirectories
254 thereof.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000255 """
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000256 if include_deletes is not None:
257 warnings.warn("AffectedTextFiles(include_deletes=%s)"
258 " is deprecated and ignored" % str(include_deletes),
259 category=DeprecationWarning,
260 stacklevel=2)
261 return filter(lambda x: x.IsTextFile(),
262 self.AffectedFiles(include_dirs=False, include_deletes=False))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000263
264 def RightHandSideLines(self):
265 """An iterator over all text lines in "new" version of changed files.
266
267 Only lists lines from new or modified text files in the change that are
268 contained by the directory of the currently executing presubmit script.
269
270 This is useful for doing line-by-line regex checks, like checking for
271 trailing whitespace.
272
273 Yields:
274 a 3 tuple:
275 the AffectedFile instance of the current file;
276 integer line number (1-based); and
277 the contents of the line as a string.
maruel@chromium.org1487d532009-06-06 00:22:57 +0000278
279 Note: The cariage return (LF or CR) is stripped off.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000280 """
281 return InputApi._RightHandSideLinesImpl(
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000282 filter(lambda x: x.IsTextFile(),
283 self.AffectedFiles(include_deletes=False)))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000284
285 @staticmethod
286 def _RightHandSideLinesImpl(affected_files):
287 """Implements RightHandSideLines for InputApi and GclChange."""
288 for af in affected_files:
289 lines = af.NewContents()
290 line_number = 0
291 for line in lines:
292 line_number += 1
293 yield (af, line_number, line)
294
295
296class AffectedFile(object):
297 """Representation of a file in a change."""
298
299 def __init__(self, path, action, repository_root=''):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000300 self._path = path
301 self._action = action
302 self._repository_root = repository_root
303 self._is_directory = None
304 self._properties = {}
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000305
306 def ServerPath(self):
307 """Returns a path string that identifies the file in the SCM system.
308
309 Returns the empty string if the file does not exist in SCM.
310 """
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000311 return ""
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000312
313 def LocalPath(self):
314 """Returns the path of this file on the local disk relative to client root.
315 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000316 return normpath(self._path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000317
318 def AbsoluteLocalPath(self):
319 """Returns the absolute path of this file on the local disk.
320 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000321 return normpath(os.path.join(self._repository_root, self.LocalPath()))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000322
323 def IsDirectory(self):
324 """Returns true if this object is a directory."""
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000325 if self._is_directory is None:
326 path = self.AbsoluteLocalPath()
327 self._is_directory = (os.path.exists(path) and
328 os.path.isdir(path))
329 return self._is_directory
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000330
331 def Action(self):
332 """Returns the action on this opened file, e.g. A, M, D, etc."""
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000333 # TODO(maruel): Somewhat crappy, Could be "A" or "A +" for svn but
334 # different for other SCM.
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000335 return self._action
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000336
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000337 def Property(self, property_name):
338 """Returns the specified SCM property of this file, or None if no such
339 property.
340 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000341 return self._properties.get(property_name, None)
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000342
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000343 def IsTextFile(self):
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000344 """Returns True if the file is a text file and not a binary file.
345
346 Deleted files are not text file."""
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000347 raise NotImplementedError() # Implement when needed
348
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000349 def NewContents(self):
350 """Returns an iterator over the lines in the new version of file.
351
352 The new version is the file in the user's workspace, i.e. the "right hand
353 side".
354
355 Contents will be empty if the file is a directory or does not exist.
maruel@chromium.org1487d532009-06-06 00:22:57 +0000356 Note: The cariage returns (LF or CR) are stripped off.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000357 """
358 if self.IsDirectory():
359 return []
360 else:
361 return gcl.ReadFile(self.AbsoluteLocalPath()).splitlines()
362
363 def OldContents(self):
364 """Returns an iterator over the lines in the old version of file.
365
366 The old version is the file in depot, i.e. the "left hand side".
367 """
368 raise NotImplementedError() # Implement when needed
369
370 def OldFileTempPath(self):
371 """Returns the path on local disk where the old contents resides.
372
373 The old version is the file in depot, i.e. the "left hand side".
374 This is a read-only cached copy of the old contents. *DO NOT* try to
375 modify this file.
376 """
377 raise NotImplementedError() # Implement if/when needed.
378
379
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000380class SvnAffectedFile(AffectedFile):
381 """Representation of a file in a change out of a Subversion checkout."""
382
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000383 def __init__(self, *args, **kwargs):
384 AffectedFile.__init__(self, *args, **kwargs)
385 self._server_path = None
386 self._is_text_file = None
387
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000388 def ServerPath(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000389 if self._server_path is None:
390 self._server_path = gclient.CaptureSVNInfo(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000391 self.AbsoluteLocalPath()).get('URL', '')
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000392 return self._server_path
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000393
394 def IsDirectory(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000395 if self._is_directory is None:
396 path = self.AbsoluteLocalPath()
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000397 if os.path.exists(path):
398 # Retrieve directly from the file system; it is much faster than
399 # querying subversion, especially on Windows.
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000400 self._is_directory = os.path.isdir(path)
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000401 else:
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000402 self._is_directory = gclient.CaptureSVNInfo(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000403 path).get('Node Kind') in ('dir', 'directory')
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000404 return self._is_directory
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000405
406 def Property(self, property_name):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000407 if not property_name in self._properties:
408 self._properties[property_name] = gcl.GetSVNFileProperty(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000409 self.AbsoluteLocalPath(), property_name)
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000410 return self._properties[property_name]
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000411
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000412 def IsTextFile(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000413 if self._is_text_file is None:
414 if self.Action() == 'D':
415 # A deleted file is not a text file.
416 self._is_text_file = False
417 elif self.IsDirectory():
418 self._is_text_file = False
419 else:
420 mime_type = gcl.GetSVNFileProperty(self.AbsoluteLocalPath(),
421 'svn:mime-type')
422 self._is_text_file = (not mime_type or mime_type.startswith('text/'))
423 return self._is_text_file
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000424
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000425
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000426class GclChange(object):
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000427 """Describe a change.
428
429 Used directly by the presubmit scripts to query the current change being
430 tested.
431
432 Instance members:
433 tags: Dictionnary of KEY=VALUE pairs found in the change description.
434 self.KEY: equivalent to tags['KEY']
435 """
436
437 # Matches key/value (or "tag") lines in changelist descriptions.
438 _tag_line_re = re.compile(
439 '^\s*(?P<key>[A-Z][A-Z_0-9]*)\s*=\s*(?P<value>.*?)\s*$')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000440
441 def __init__(self, change_info, repository_root=''):
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000442 # Do not keep a reference to the original change_info.
443 self._name = change_info.name
444 self._full_description = change_info.description
445 self._repository_root = repository_root
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000446
447 # From the description text, build up a dictionary of key/value pairs
448 # plus the description minus all key/value or "tag" lines.
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000449 self._description_without_tags = []
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000450 self.tags = {}
451 for line in change_info.description.splitlines():
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000452 m = self._tag_line_re.match(line)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000453 if m:
454 self.tags[m.group('key')] = m.group('value')
455 else:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000456 self._description_without_tags.append(line)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000457
458 # Change back to text and remove whitespace at end.
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000459 self._description_without_tags = '\n'.join(self._description_without_tags)
460 self._description_without_tags = self._description_without_tags.rstrip()
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000461
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000462 self._affected_files = [
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000463 SvnAffectedFile(info[1], info[0].strip(), repository_root)
464 for info in change_info.files
465 ]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000466
467 def Change(self):
468 """Returns the change name."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000469 return self._name
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000470
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000471 def DescriptionText(self):
472 """Returns the user-entered changelist description, minus tags.
473
474 Any line in the user-provided description starting with e.g. "FOO="
475 (whitespace permitted before and around) is considered a tag line. Such
476 lines are stripped out of the description this function returns.
477 """
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000478 return self._description_without_tags
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000479
480 def FullDescriptionText(self):
481 """Returns the complete changelist description including tags."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000482 return self._full_description
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000483
484 def RepositoryRoot(self):
485 """Returns the repository root for this change, as an absolute path."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000486 return self._repository_root
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000487
488 def __getattr__(self, attr):
489 """Return keys directly as attributes on the object.
490
491 You may use a friendly name (from SPECIAL_KEYS) or the actual name of
492 the key.
493 """
maruel@chromium.orge1a524f2009-05-27 14:43:46 +0000494 return self.tags.get(attr)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000495
496 def AffectedFiles(self, include_dirs=False, include_deletes=True):
497 """Returns a list of AffectedFile instances for all files in the change.
498
499 Args:
500 include_deletes: If false, deleted files will be filtered out.
501 include_dirs: True to include directories in the list
502
503 Returns:
504 [AffectedFile(path, action), AffectedFile(path, action)]
505 """
506 if include_dirs:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000507 affected = self._affected_files
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000508 else:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000509 affected = filter(lambda x: not x.IsDirectory(), self._affected_files)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000510
511 if include_deletes:
512 return affected
513 else:
514 return filter(lambda x: x.Action() != 'D', affected)
515
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000516 def AffectedTextFiles(self, include_deletes=None):
517 """Return a list of the existing text files in a change."""
518 if include_deletes is not None:
519 warnings.warn("AffectedTextFiles(include_deletes=%s)"
520 " is deprecated and ignored" % str(include_deletes),
521 category=DeprecationWarning,
522 stacklevel=2)
523 return filter(lambda x: x.IsTextFile(),
524 self.AffectedFiles(include_dirs=False, include_deletes=False))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000525
526 def LocalPaths(self, include_dirs=False):
527 """Convenience function."""
528 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
529
530 def AbsoluteLocalPaths(self, include_dirs=False):
531 """Convenience function."""
532 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
533
534 def ServerPaths(self, include_dirs=False):
535 """Convenience function."""
536 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
537
538 def RightHandSideLines(self):
539 """An iterator over all text lines in "new" version of changed files.
540
541 Lists lines from new or modified text files in the change.
542
543 This is useful for doing line-by-line regex checks, like checking for
544 trailing whitespace.
545
546 Yields:
547 a 3 tuple:
548 the AffectedFile instance of the current file;
549 integer line number (1-based); and
550 the contents of the line as a string.
551 """
552 return InputApi._RightHandSideLinesImpl(
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000553 filter(lambda x: x.IsTextFile(),
554 self.AffectedFiles(include_deletes=False)))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000555
556
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000557def ListRelevantPresubmitFiles(files, root):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000558 """Finds all presubmit files that apply to a given set of source files.
559
560 Args:
561 files: An iterable container containing file paths.
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000562 root: Path where to stop searching.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000563
564 Return:
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000565 List of absolute paths of the existing PRESUBMIT.py scripts.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000566 """
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000567 entries = []
568 for f in files:
569 f = normpath(os.path.join(root, f))
570 while f:
571 f = os.path.dirname(f)
572 if f in entries:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000573 break
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000574 entries.append(f)
575 if f == root:
576 break
577 entries.sort()
578 entries = map(lambda x: os.path.join(x, 'PRESUBMIT.py'), entries)
579 return filter(lambda x: os.path.isfile(x), entries)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000580
581
582class PresubmitExecuter(object):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000583 def __init__(self, change_info, committing):
584 """
585 Args:
586 change_info: The ChangeInfo object for the change.
587 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
588 """
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000589 # TODO(maruel): Determine the SCM.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000590 self.change = GclChange(change_info, gcl.GetRepositoryRoot())
591 self.committing = committing
592
593 def ExecPresubmitScript(self, script_text, presubmit_path):
594 """Executes a single presubmit script.
595
596 Args:
597 script_text: The text of the presubmit script.
598 presubmit_path: The path to the presubmit file (this will be reported via
599 input_api.PresubmitLocalPath()).
600
601 Return:
602 A list of result objects, empty if no problems.
603 """
604 input_api = InputApi(self.change, presubmit_path)
605 context = {}
606 exec script_text in context
607
608 # These function names must change if we make substantial changes to
609 # the presubmit API that are not backwards compatible.
610 if self.committing:
611 function_name = 'CheckChangeOnCommit'
612 else:
613 function_name = 'CheckChangeOnUpload'
614 if function_name in context:
615 context['__args'] = (input_api, OutputApi())
616 result = eval(function_name + '(*__args)', context)
617 if not (isinstance(result, types.TupleType) or
618 isinstance(result, types.ListType)):
619 raise exceptions.RuntimeError(
620 'Presubmit functions must return a tuple or list')
621 for item in result:
622 if not isinstance(item, OutputApi.PresubmitResult):
623 raise exceptions.RuntimeError(
624 'All presubmit results must be of types derived from '
625 'output_api.PresubmitResult')
626 else:
627 result = () # no error since the script doesn't care about current event.
628
629 return result
630
631
632def DoPresubmitChecks(change_info,
633 committing,
634 verbose,
635 output_stream,
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000636 input_stream,
637 default_presubmit):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000638 """Runs all presubmit checks that apply to the files in the change.
639
640 This finds all PRESUBMIT.py files in directories enclosing the files in the
641 change (up to the repository root) and calls the relevant entrypoint function
642 depending on whether the change is being committed or uploaded.
643
644 Prints errors, warnings and notifications. Prompts the user for warnings
645 when needed.
646
647 Args:
648 change_info: The ChangeInfo object for the change.
649 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
650 verbose: Prints debug info.
651 output_stream: A stream to write output from presubmit tests to.
652 input_stream: A stream to read input from the user.
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000653 default_presubmit: A default presubmit script to execute in any case.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000654
655 Return:
656 True if execution can continue, False if not.
657 """
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000658 checkout_root = gcl.GetRepositoryRoot()
659 presubmit_files = ListRelevantPresubmitFiles(change_info.FileList(),
660 checkout_root)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000661 if not presubmit_files and verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000662 output_stream.write("Warning, no presubmit.py found.\n")
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000663 results = []
664 executer = PresubmitExecuter(change_info, committing)
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000665 if default_presubmit:
666 if verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000667 output_stream.write("Running default presubmit script.\n")
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000668 fake_path = os.path.join(checkout_root, 'PRESUBMIT.py')
669 results += executer.ExecPresubmitScript(default_presubmit, fake_path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000670 for filename in presubmit_files:
maruel@chromium.org3d235242009-05-15 12:40:48 +0000671 filename = os.path.abspath(filename)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000672 if verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000673 output_stream.write("Running %s\n" % filename)
maruel@chromium.orgc1675e22009-04-27 20:30:48 +0000674 # Accept CRLF presubmit script.
maruel@chromium.org277003e2009-05-01 12:51:43 +0000675 presubmit_script = gcl.ReadFile(filename, 'rU')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000676 results += executer.ExecPresubmitScript(presubmit_script, filename)
677
678 errors = []
679 notifications = []
680 warnings = []
681 for result in results:
682 if not result.IsFatal() and not result.ShouldPrompt():
683 notifications.append(result)
684 elif result.ShouldPrompt():
685 warnings.append(result)
686 else:
687 errors.append(result)
688
689 error_count = 0
690 for name, items in (('Messages', notifications),
691 ('Warnings', warnings),
692 ('ERRORS', errors)):
693 if items:
694 output_stream.write('\n** Presubmit %s **\n\n' % name)
695 for item in items:
696 if not item._Handle(output_stream, input_stream,
697 may_prompt=False):
698 error_count += 1
699 output_stream.write('\n')
700 if not errors and warnings:
701 output_stream.write(
702 'There were presubmit warnings. Sure you want to continue? (y/N): ')
703 response = input_stream.readline()
704 if response.strip().lower() != 'y':
705 error_count += 1
706 return (error_count == 0)
707
708
709def ScanSubDirs(mask, recursive):
710 if not recursive:
711 return [x for x in glob.glob(mask) if '.svn' not in x]
712 else:
713 results = []
714 for root, dirs, files in os.walk('.'):
715 if '.svn' in dirs:
716 dirs.remove('.svn')
717 for name in files:
718 if fnmatch.fnmatch(name, mask):
719 results.append(os.path.join(root, name))
720 return results
721
722
723def ParseFiles(args, recursive):
724 files = []
725 for arg in args:
726 files.extend([('M', file) for file in ScanSubDirs(arg, recursive)])
727 return files
728
729
730def Main(argv):
731 parser = optparse.OptionParser(usage="%prog [options]",
732 version="%prog " + str(__version__))
733 parser.add_option("-c", "--commit", action="store_true",
734 help="Use commit instead of upload checks")
735 parser.add_option("-r", "--recursive", action="store_true",
736 help="Act recursively")
737 parser.add_option("-v", "--verbose", action="store_true",
738 help="Verbose output")
739 options, args = parser.parse_args(argv[1:])
740 files = ParseFiles(args, options.recursive)
741 if options.verbose:
742 print "Found %d files." % len(files)
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000743 return not DoPresubmitChecks(gcl.ChangeInfo(name='temp', files=files),
744 options.commit,
745 options.verbose,
746 sys.stdout,
747 sys.stdin,
748 default_presubmit=None)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000749
750
751if __name__ == '__main__':
752 sys.exit(Main(sys.argv))