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