blob: 86398cb8f2f07166af24d6544c373bfa67968dae [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.orgd7dccf52009-06-06 18:51:58 +00009__version__ = '1.3'
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.
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +000028import traceback # Exposed through the API.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000029import types
maruel@chromium.org1487d532009-06-06 00:22:57 +000030import unittest # Exposed through the API.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000031import urllib2 # Exposed through the API.
maruel@chromium.org1e08c002009-05-28 19:09:33 +000032import warnings
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000033
34# Local imports.
35# TODO(joi) Would be cleaner to factor out utils in gcl to separate module, but
36# for now it would only be a couple of functions so hardly worth it.
37import gcl
maruel@chromium.org46a94102009-05-12 20:32:43 +000038import gclient
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000039import presubmit_canned_checks
40
41
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000042class NotImplementedException(Exception):
43 """We're leaving placeholders in a bunch of places to remind us of the
44 design of the API, but we have not implemented all of it yet. Implement as
45 the need arises.
46 """
47 pass
48
49
50def normpath(path):
51 '''Version of os.path.normpath that also changes backward slashes to
52 forward slashes when not running on Windows.
53 '''
54 # This is safe to always do because the Windows version of os.path.normpath
55 # will replace forward slashes with backward slashes.
56 path = path.replace(os.sep, '/')
57 return os.path.normpath(path)
58
59
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000060class OutputApi(object):
61 """This class (more like a module) gets passed to presubmit scripts so that
62 they can specify various types of results.
63 """
64
65 class PresubmitResult(object):
66 """Base class for result objects."""
67
68 def __init__(self, message, items=None, long_text=''):
69 """
70 message: A short one-line message to indicate errors.
71 items: A list of short strings to indicate where errors occurred.
72 long_text: multi-line text output, e.g. from another tool
73 """
74 self._message = message
75 self._items = []
76 if items:
77 self._items = items
78 self._long_text = long_text.rstrip()
79
80 def _Handle(self, output_stream, input_stream, may_prompt=True):
81 """Writes this result to the output stream.
82
83 Args:
84 output_stream: Where to write
85
86 Returns:
87 True if execution may continue, False otherwise.
88 """
89 output_stream.write(self._message)
90 output_stream.write('\n')
91 for item in self._items:
92 output_stream.write(' %s\n' % item)
93 if self._long_text:
94 output_stream.write('\n***************\n%s\n***************\n\n' %
95 self._long_text)
96
97 if self.ShouldPrompt() and may_prompt:
98 output_stream.write('Are you sure you want to continue? (y/N): ')
99 response = input_stream.readline()
100 if response.strip().lower() != 'y':
101 return False
102
103 return not self.IsFatal()
104
105 def IsFatal(self):
106 """An error that is fatal stops g4 mail/submit immediately, i.e. before
107 other presubmit scripts are run.
108 """
109 return False
110
111 def ShouldPrompt(self):
112 """Whether this presubmit result should result in a prompt warning."""
113 return False
114
115 class PresubmitError(PresubmitResult):
116 """A hard presubmit error."""
117 def IsFatal(self):
118 return True
119
120 class PresubmitPromptWarning(PresubmitResult):
121 """An warning that prompts the user if they want to continue."""
122 def ShouldPrompt(self):
123 return True
124
125 class PresubmitNotifyResult(PresubmitResult):
126 """Just print something to the screen -- but it's not even a warning."""
127 pass
128
129 class MailTextResult(PresubmitResult):
130 """A warning that should be included in the review request email."""
131 def __init__(self, *args, **kwargs):
132 raise NotImplementedException() # TODO(joi) Implement.
133
134
135class InputApi(object):
136 """An instance of this object is passed to presubmit scripts so they can
137 know stuff about the change they're looking at.
138 """
139
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +0000140 def __init__(self, change, presubmit_path, is_committing):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000141 """Builds an InputApi object.
142
143 Args:
144 change: A presubmit.GclChange object.
145 presubmit_path: The path to the presubmit script being processed.
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +0000146 is_committing: True if the change is about to be committed.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000147 """
maruel@chromium.org9711bba2009-05-22 23:51:39 +0000148 # Version number of the presubmit_support script.
149 self.version = [int(x) for x in __version__.split('.')]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000150 self.change = change
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +0000151 self.is_committing = is_committing
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000152
153 # We expose various modules and functions as attributes of the input_api
154 # so that presubmit scripts don't have to import them.
155 self.basename = os.path.basename
156 self.cPickle = cPickle
157 self.cStringIO = cStringIO
158 self.os_path = os.path
159 self.pickle = pickle
160 self.marshal = marshal
161 self.re = re
162 self.subprocess = subprocess
163 self.tempfile = tempfile
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +0000164 self.traceback = traceback
maruel@chromium.org1487d532009-06-06 00:22:57 +0000165 self.unittest = unittest
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000166 self.urllib2 = urllib2
167
168 # InputApi.platform is the platform you're currently running on.
169 self.platform = sys.platform
170
171 # The local path of the currently-being-processed presubmit script.
maruel@chromium.org3d235242009-05-15 12:40:48 +0000172 self._current_presubmit_path = os.path.dirname(presubmit_path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000173
174 # We carry the canned checks so presubmit scripts can easily use them.
175 self.canned_checks = presubmit_canned_checks
176
177 def PresubmitLocalPath(self):
178 """Returns the local path of the presubmit script currently being run.
179
180 This is useful if you don't want to hard-code absolute paths in the
181 presubmit script. For example, It can be used to find another file
182 relative to the PRESUBMIT.py script, so the whole tree can be branched and
183 the presubmit script still works, without editing its content.
184 """
maruel@chromium.org3d235242009-05-15 12:40:48 +0000185 return self._current_presubmit_path
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000186
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000187 def DepotToLocalPath(self, depot_path):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000188 """Translate a depot path to a local path (relative to client root).
189
190 Args:
191 Depot path as a string.
192
193 Returns:
194 The local path of the depot path under the user's current client, or None
195 if the file is not mapped.
196
197 Remember to check for the None case and show an appropriate error!
198 """
maruel@chromium.org46a94102009-05-12 20:32:43 +0000199 local_path = gclient.CaptureSVNInfo(depot_path).get('Path')
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000200 if local_path:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000201 return local_path
202
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000203 def LocalToDepotPath(self, local_path):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000204 """Translate a local path to a depot path.
205
206 Args:
207 Local path (relative to current directory, or absolute) as a string.
208
209 Returns:
210 The depot path (SVN URL) of the file if mapped, otherwise None.
211 """
maruel@chromium.org46a94102009-05-12 20:32:43 +0000212 depot_path = gclient.CaptureSVNInfo(local_path).get('URL')
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000213 if depot_path:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000214 return depot_path
215
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000216 def AffectedFiles(self, include_dirs=False, include_deletes=True):
217 """Same as input_api.change.AffectedFiles() except only lists files
218 (and optionally directories) in the same directory as the current presubmit
219 script, or subdirectories thereof.
220 """
maruel@chromium.org3d235242009-05-15 12:40:48 +0000221 dir_with_slash = normpath("%s/" % self.PresubmitLocalPath())
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000222 if len(dir_with_slash) == 1:
223 dir_with_slash = ''
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000224 return filter(
225 lambda x: normpath(x.AbsoluteLocalPath()).startswith(dir_with_slash),
226 self.change.AffectedFiles(include_dirs, include_deletes))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000227
228 def LocalPaths(self, include_dirs=False):
229 """Returns local paths of input_api.AffectedFiles()."""
230 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
231
232 def AbsoluteLocalPaths(self, include_dirs=False):
233 """Returns absolute local paths of input_api.AffectedFiles()."""
234 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
235
236 def ServerPaths(self, include_dirs=False):
237 """Returns server paths of input_api.AffectedFiles()."""
238 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
239
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000240 def AffectedTextFiles(self, include_deletes=None):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000241 """Same as input_api.change.AffectedTextFiles() except only lists files
242 in the same directory as the current presubmit script, or subdirectories
243 thereof.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000244 """
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000245 if include_deletes is not None:
246 warnings.warn("AffectedTextFiles(include_deletes=%s)"
247 " is deprecated and ignored" % str(include_deletes),
248 category=DeprecationWarning,
249 stacklevel=2)
250 return filter(lambda x: x.IsTextFile(),
251 self.AffectedFiles(include_dirs=False, include_deletes=False))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000252
253 def RightHandSideLines(self):
254 """An iterator over all text lines in "new" version of changed files.
255
256 Only lists lines from new or modified text files in the change that are
257 contained by the directory of the currently executing presubmit script.
258
259 This is useful for doing line-by-line regex checks, like checking for
260 trailing whitespace.
261
262 Yields:
263 a 3 tuple:
264 the AffectedFile instance of the current file;
265 integer line number (1-based); and
266 the contents of the line as a string.
maruel@chromium.org1487d532009-06-06 00:22:57 +0000267
268 Note: The cariage return (LF or CR) is stripped off.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000269 """
270 return InputApi._RightHandSideLinesImpl(
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000271 filter(lambda x: x.IsTextFile(),
272 self.AffectedFiles(include_deletes=False)))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000273
274 @staticmethod
275 def _RightHandSideLinesImpl(affected_files):
276 """Implements RightHandSideLines for InputApi and GclChange."""
277 for af in affected_files:
278 lines = af.NewContents()
279 line_number = 0
280 for line in lines:
281 line_number += 1
282 yield (af, line_number, line)
283
284
285class AffectedFile(object):
286 """Representation of a file in a change."""
287
288 def __init__(self, path, action, repository_root=''):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000289 self._path = path
290 self._action = action
291 self._repository_root = repository_root
292 self._is_directory = None
293 self._properties = {}
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000294
295 def ServerPath(self):
296 """Returns a path string that identifies the file in the SCM system.
297
298 Returns the empty string if the file does not exist in SCM.
299 """
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000300 return ""
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000301
302 def LocalPath(self):
303 """Returns the path of this file on the local disk relative to client root.
304 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000305 return normpath(self._path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000306
307 def AbsoluteLocalPath(self):
308 """Returns the absolute path of this file on the local disk.
309 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000310 return normpath(os.path.join(self._repository_root, self.LocalPath()))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000311
312 def IsDirectory(self):
313 """Returns true if this object is a directory."""
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000314 if self._is_directory is None:
315 path = self.AbsoluteLocalPath()
316 self._is_directory = (os.path.exists(path) and
317 os.path.isdir(path))
318 return self._is_directory
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000319
320 def Action(self):
321 """Returns the action on this opened file, e.g. A, M, D, etc."""
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000322 # TODO(maruel): Somewhat crappy, Could be "A" or "A +" for svn but
323 # different for other SCM.
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000324 return self._action
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000325
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000326 def Property(self, property_name):
327 """Returns the specified SCM property of this file, or None if no such
328 property.
329 """
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000330 return self._properties.get(property_name, None)
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000331
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000332 def IsTextFile(self):
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000333 """Returns True if the file is a text file and not a binary file.
334
335 Deleted files are not text file."""
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000336 raise NotImplementedError() # Implement when needed
337
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000338 def NewContents(self):
339 """Returns an iterator over the lines in the new version of file.
340
341 The new version is the file in the user's workspace, i.e. the "right hand
342 side".
343
344 Contents will be empty if the file is a directory or does not exist.
maruel@chromium.org1487d532009-06-06 00:22:57 +0000345 Note: The cariage returns (LF or CR) are stripped off.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000346 """
347 if self.IsDirectory():
348 return []
349 else:
350 return gcl.ReadFile(self.AbsoluteLocalPath()).splitlines()
351
352 def OldContents(self):
353 """Returns an iterator over the lines in the old version of file.
354
355 The old version is the file in depot, i.e. the "left hand side".
356 """
357 raise NotImplementedError() # Implement when needed
358
359 def OldFileTempPath(self):
360 """Returns the path on local disk where the old contents resides.
361
362 The old version is the file in depot, i.e. the "left hand side".
363 This is a read-only cached copy of the old contents. *DO NOT* try to
364 modify this file.
365 """
366 raise NotImplementedError() # Implement if/when needed.
367
368
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000369class SvnAffectedFile(AffectedFile):
370 """Representation of a file in a change out of a Subversion checkout."""
371
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000372 def __init__(self, *args, **kwargs):
373 AffectedFile.__init__(self, *args, **kwargs)
374 self._server_path = None
375 self._is_text_file = None
376
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000377 def ServerPath(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000378 if self._server_path is None:
379 self._server_path = gclient.CaptureSVNInfo(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000380 self.AbsoluteLocalPath()).get('URL', '')
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000381 return self._server_path
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000382
383 def IsDirectory(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000384 if self._is_directory is None:
385 path = self.AbsoluteLocalPath()
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000386 if os.path.exists(path):
387 # Retrieve directly from the file system; it is much faster than
388 # querying subversion, especially on Windows.
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000389 self._is_directory = os.path.isdir(path)
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000390 else:
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000391 self._is_directory = gclient.CaptureSVNInfo(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000392 path).get('Node Kind') in ('dir', 'directory')
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000393 return self._is_directory
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000394
395 def Property(self, property_name):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000396 if not property_name in self._properties:
397 self._properties[property_name] = gcl.GetSVNFileProperty(
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000398 self.AbsoluteLocalPath(), property_name)
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000399 return self._properties[property_name]
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000400
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000401 def IsTextFile(self):
maruel@chromium.org15bdffa2009-05-29 11:16:29 +0000402 if self._is_text_file is None:
403 if self.Action() == 'D':
404 # A deleted file is not a text file.
405 self._is_text_file = False
406 elif self.IsDirectory():
407 self._is_text_file = False
408 else:
409 mime_type = gcl.GetSVNFileProperty(self.AbsoluteLocalPath(),
410 'svn:mime-type')
411 self._is_text_file = (not mime_type or mime_type.startswith('text/'))
412 return self._is_text_file
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000413
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000414
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000415class GclChange(object):
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000416 """Describe a change.
417
418 Used directly by the presubmit scripts to query the current change being
419 tested.
420
421 Instance members:
422 tags: Dictionnary of KEY=VALUE pairs found in the change description.
423 self.KEY: equivalent to tags['KEY']
424 """
425
426 # Matches key/value (or "tag") lines in changelist descriptions.
427 _tag_line_re = re.compile(
428 '^\s*(?P<key>[A-Z][A-Z_0-9]*)\s*=\s*(?P<value>.*?)\s*$')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000429
430 def __init__(self, change_info, repository_root=''):
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000431 # Do not keep a reference to the original change_info.
432 self._name = change_info.name
433 self._full_description = change_info.description
434 self._repository_root = repository_root
maruel@chromium.org32ba2602009-06-06 18:44:48 +0000435 self.issue = change_info.issue
436 self.patchset = change_info.patchset
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000437
438 # From the description text, build up a dictionary of key/value pairs
439 # plus the description minus all key/value or "tag" lines.
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000440 self._description_without_tags = []
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000441 self.tags = {}
442 for line in change_info.description.splitlines():
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000443 m = self._tag_line_re.match(line)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000444 if m:
445 self.tags[m.group('key')] = m.group('value')
446 else:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000447 self._description_without_tags.append(line)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000448
449 # Change back to text and remove whitespace at end.
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000450 self._description_without_tags = '\n'.join(self._description_without_tags)
451 self._description_without_tags = self._description_without_tags.rstrip()
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000452
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000453 self._affected_files = [
maruel@chromium.orgdbbeedc2009-05-22 20:26:17 +0000454 SvnAffectedFile(info[1], info[0].strip(), repository_root)
455 for info in change_info.files
456 ]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000457
458 def Change(self):
459 """Returns the change name."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000460 return self._name
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000461
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000462 def DescriptionText(self):
463 """Returns the user-entered changelist description, minus tags.
464
465 Any line in the user-provided description starting with e.g. "FOO="
466 (whitespace permitted before and around) is considered a tag line. Such
467 lines are stripped out of the description this function returns.
468 """
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000469 return self._description_without_tags
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000470
471 def FullDescriptionText(self):
472 """Returns the complete changelist description including tags."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000473 return self._full_description
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000474
475 def RepositoryRoot(self):
476 """Returns the repository root for this change, as an absolute path."""
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000477 return self._repository_root
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000478
479 def __getattr__(self, attr):
480 """Return keys directly as attributes on the object.
481
482 You may use a friendly name (from SPECIAL_KEYS) or the actual name of
483 the key.
484 """
maruel@chromium.orge1a524f2009-05-27 14:43:46 +0000485 return self.tags.get(attr)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000486
487 def AffectedFiles(self, include_dirs=False, include_deletes=True):
488 """Returns a list of AffectedFile instances for all files in the change.
489
490 Args:
491 include_deletes: If false, deleted files will be filtered out.
492 include_dirs: True to include directories in the list
493
494 Returns:
495 [AffectedFile(path, action), AffectedFile(path, action)]
496 """
497 if include_dirs:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000498 affected = self._affected_files
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000499 else:
maruel@chromium.org6ebe68a2009-05-27 23:43:40 +0000500 affected = filter(lambda x: not x.IsDirectory(), self._affected_files)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000501
502 if include_deletes:
503 return affected
504 else:
505 return filter(lambda x: x.Action() != 'D', affected)
506
maruel@chromium.org77c4f0f2009-05-29 18:53:04 +0000507 def AffectedTextFiles(self, include_deletes=None):
508 """Return a list of the existing text files in a change."""
509 if include_deletes is not None:
510 warnings.warn("AffectedTextFiles(include_deletes=%s)"
511 " is deprecated and ignored" % str(include_deletes),
512 category=DeprecationWarning,
513 stacklevel=2)
514 return filter(lambda x: x.IsTextFile(),
515 self.AffectedFiles(include_dirs=False, include_deletes=False))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000516
517 def LocalPaths(self, include_dirs=False):
518 """Convenience function."""
519 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
520
521 def AbsoluteLocalPaths(self, include_dirs=False):
522 """Convenience function."""
523 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
524
525 def ServerPaths(self, include_dirs=False):
526 """Convenience function."""
527 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
528
529 def RightHandSideLines(self):
530 """An iterator over all text lines in "new" version of changed files.
531
532 Lists lines from new or modified text files in the change.
533
534 This is useful for doing line-by-line regex checks, like checking for
535 trailing whitespace.
536
537 Yields:
538 a 3 tuple:
539 the AffectedFile instance of the current file;
540 integer line number (1-based); and
541 the contents of the line as a string.
542 """
543 return InputApi._RightHandSideLinesImpl(
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000544 filter(lambda x: x.IsTextFile(),
545 self.AffectedFiles(include_deletes=False)))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000546
547
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000548def ListRelevantPresubmitFiles(files, root):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000549 """Finds all presubmit files that apply to a given set of source files.
550
551 Args:
552 files: An iterable container containing file paths.
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000553 root: Path where to stop searching.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000554
555 Return:
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000556 List of absolute paths of the existing PRESUBMIT.py scripts.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000557 """
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000558 entries = []
559 for f in files:
560 f = normpath(os.path.join(root, f))
561 while f:
562 f = os.path.dirname(f)
563 if f in entries:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000564 break
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000565 entries.append(f)
566 if f == root:
567 break
568 entries.sort()
569 entries = map(lambda x: os.path.join(x, 'PRESUBMIT.py'), entries)
570 return filter(lambda x: os.path.isfile(x), entries)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000571
572
573class PresubmitExecuter(object):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000574 def __init__(self, change_info, committing):
575 """
576 Args:
577 change_info: The ChangeInfo object for the change.
578 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
579 """
maruel@chromium.org1e08c002009-05-28 19:09:33 +0000580 # TODO(maruel): Determine the SCM.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000581 self.change = GclChange(change_info, gcl.GetRepositoryRoot())
582 self.committing = committing
583
584 def ExecPresubmitScript(self, script_text, presubmit_path):
585 """Executes a single presubmit script.
586
587 Args:
588 script_text: The text of the presubmit script.
589 presubmit_path: The path to the presubmit file (this will be reported via
590 input_api.PresubmitLocalPath()).
591
592 Return:
593 A list of result objects, empty if no problems.
594 """
maruel@chromium.orgd7dccf52009-06-06 18:51:58 +0000595 input_api = InputApi(self.change, presubmit_path, self.committing)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000596 context = {}
597 exec script_text in context
598
599 # These function names must change if we make substantial changes to
600 # the presubmit API that are not backwards compatible.
601 if self.committing:
602 function_name = 'CheckChangeOnCommit'
603 else:
604 function_name = 'CheckChangeOnUpload'
605 if function_name in context:
606 context['__args'] = (input_api, OutputApi())
607 result = eval(function_name + '(*__args)', context)
608 if not (isinstance(result, types.TupleType) or
609 isinstance(result, types.ListType)):
610 raise exceptions.RuntimeError(
611 'Presubmit functions must return a tuple or list')
612 for item in result:
613 if not isinstance(item, OutputApi.PresubmitResult):
614 raise exceptions.RuntimeError(
615 'All presubmit results must be of types derived from '
616 'output_api.PresubmitResult')
617 else:
618 result = () # no error since the script doesn't care about current event.
619
620 return result
621
622
623def DoPresubmitChecks(change_info,
624 committing,
625 verbose,
626 output_stream,
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000627 input_stream,
628 default_presubmit):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000629 """Runs all presubmit checks that apply to the files in the change.
630
631 This finds all PRESUBMIT.py files in directories enclosing the files in the
632 change (up to the repository root) and calls the relevant entrypoint function
633 depending on whether the change is being committed or uploaded.
634
635 Prints errors, warnings and notifications. Prompts the user for warnings
636 when needed.
637
638 Args:
639 change_info: The ChangeInfo object for the change.
640 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
641 verbose: Prints debug info.
642 output_stream: A stream to write output from presubmit tests to.
643 input_stream: A stream to read input from the user.
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000644 default_presubmit: A default presubmit script to execute in any case.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000645
646 Return:
647 True if execution can continue, False if not.
648 """
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000649 checkout_root = gcl.GetRepositoryRoot()
650 presubmit_files = ListRelevantPresubmitFiles(change_info.FileList(),
651 checkout_root)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000652 if not presubmit_files and verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000653 output_stream.write("Warning, no presubmit.py found.\n")
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000654 results = []
655 executer = PresubmitExecuter(change_info, committing)
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000656 if default_presubmit:
657 if verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000658 output_stream.write("Running default presubmit script.\n")
maruel@chromium.org4661e0c2009-06-04 00:45:26 +0000659 fake_path = os.path.join(checkout_root, 'PRESUBMIT.py')
660 results += executer.ExecPresubmitScript(default_presubmit, fake_path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000661 for filename in presubmit_files:
maruel@chromium.org3d235242009-05-15 12:40:48 +0000662 filename = os.path.abspath(filename)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000663 if verbose:
maruel@chromium.orgf3eee562009-05-27 00:51:10 +0000664 output_stream.write("Running %s\n" % filename)
maruel@chromium.orgc1675e22009-04-27 20:30:48 +0000665 # Accept CRLF presubmit script.
maruel@chromium.org277003e2009-05-01 12:51:43 +0000666 presubmit_script = gcl.ReadFile(filename, 'rU')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000667 results += executer.ExecPresubmitScript(presubmit_script, filename)
668
669 errors = []
670 notifications = []
671 warnings = []
672 for result in results:
673 if not result.IsFatal() and not result.ShouldPrompt():
674 notifications.append(result)
675 elif result.ShouldPrompt():
676 warnings.append(result)
677 else:
678 errors.append(result)
679
680 error_count = 0
681 for name, items in (('Messages', notifications),
682 ('Warnings', warnings),
683 ('ERRORS', errors)):
684 if items:
685 output_stream.write('\n** Presubmit %s **\n\n' % name)
686 for item in items:
687 if not item._Handle(output_stream, input_stream,
688 may_prompt=False):
689 error_count += 1
690 output_stream.write('\n')
691 if not errors and warnings:
692 output_stream.write(
693 'There were presubmit warnings. Sure you want to continue? (y/N): ')
694 response = input_stream.readline()
695 if response.strip().lower() != 'y':
696 error_count += 1
697 return (error_count == 0)
698
699
700def ScanSubDirs(mask, recursive):
701 if not recursive:
702 return [x for x in glob.glob(mask) if '.svn' not in x]
703 else:
704 results = []
705 for root, dirs, files in os.walk('.'):
706 if '.svn' in dirs:
707 dirs.remove('.svn')
708 for name in files:
709 if fnmatch.fnmatch(name, mask):
710 results.append(os.path.join(root, name))
711 return results
712
713
714def ParseFiles(args, recursive):
715 files = []
716 for arg in args:
717 files.extend([('M', file) for file in ScanSubDirs(arg, recursive)])
718 return files
719
720
721def Main(argv):
722 parser = optparse.OptionParser(usage="%prog [options]",
723 version="%prog " + str(__version__))
724 parser.add_option("-c", "--commit", action="store_true",
725 help="Use commit instead of upload checks")
726 parser.add_option("-r", "--recursive", action="store_true",
727 help="Act recursively")
728 parser.add_option("-v", "--verbose", action="store_true",
729 help="Verbose output")
730 options, args = parser.parse_args(argv[1:])
731 files = ParseFiles(args, options.recursive)
732 if options.verbose:
733 print "Found %d files." % len(files)
maruel@chromium.orgde0ba292009-06-06 19:43:27 +0000734 return not DoPresubmitChecks(gcl.ChangeInfo('No name', 0, 0, '', files),
maruel@chromium.org0ff1fab2009-05-22 13:08:15 +0000735 options.commit,
736 options.verbose,
737 sys.stdout,
738 sys.stdin,
739 default_presubmit=None)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000740
741
742if __name__ == '__main__':
743 sys.exit(Main(sys.argv))