blob: c90b605468e759ae6677a97473ab4528e30ae4a1 [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.orgc1675e22009-04-27 20:30:48 +00009__version__ = '1.0.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.
30
31# Local imports.
32# TODO(joi) Would be cleaner to factor out utils in gcl to separate module, but
33# for now it would only be a couple of functions so hardly worth it.
34import gcl
35import presubmit_canned_checks
36
37
38# Matches key/value (or "tag") lines in changelist descriptions.
39_tag_line_re = re.compile(
40 '^\s*(?P<key>[A-Z][A-Z_0-9]*)\s*=\s*(?P<value>.*?)\s*$')
41
42
43# Friendly names may be used for certain keys. All values for key-value pairs
44# in change descriptions (like BUG=123) can be retrieved from a change object
45# directly as if they were attributes, e.g. change.R (or equivalently because
46# we have a friendly name for it, change.Reviewers), change.BUG (or
47# change.BugIDs) and so forth.
48#
49# Add to this mapping as needed/desired.
50SPECIAL_KEYS = {
51 'Reviewers' : 'R',
52 'BugIDs' : 'BUG',
53 'Tested': 'TESTED'
54}
55
56
57class NotImplementedException(Exception):
58 """We're leaving placeholders in a bunch of places to remind us of the
59 design of the API, but we have not implemented all of it yet. Implement as
60 the need arises.
61 """
62 pass
63
64
65def normpath(path):
66 '''Version of os.path.normpath that also changes backward slashes to
67 forward slashes when not running on Windows.
68 '''
69 # This is safe to always do because the Windows version of os.path.normpath
70 # will replace forward slashes with backward slashes.
71 path = path.replace(os.sep, '/')
72 return os.path.normpath(path)
73
74
75
76class OutputApi(object):
77 """This class (more like a module) gets passed to presubmit scripts so that
78 they can specify various types of results.
79 """
80
81 class PresubmitResult(object):
82 """Base class for result objects."""
83
84 def __init__(self, message, items=None, long_text=''):
85 """
86 message: A short one-line message to indicate errors.
87 items: A list of short strings to indicate where errors occurred.
88 long_text: multi-line text output, e.g. from another tool
89 """
90 self._message = message
91 self._items = []
92 if items:
93 self._items = items
94 self._long_text = long_text.rstrip()
95
96 def _Handle(self, output_stream, input_stream, may_prompt=True):
97 """Writes this result to the output stream.
98
99 Args:
100 output_stream: Where to write
101
102 Returns:
103 True if execution may continue, False otherwise.
104 """
105 output_stream.write(self._message)
106 output_stream.write('\n')
107 for item in self._items:
108 output_stream.write(' %s\n' % item)
109 if self._long_text:
110 output_stream.write('\n***************\n%s\n***************\n\n' %
111 self._long_text)
112
113 if self.ShouldPrompt() and may_prompt:
114 output_stream.write('Are you sure you want to continue? (y/N): ')
115 response = input_stream.readline()
116 if response.strip().lower() != 'y':
117 return False
118
119 return not self.IsFatal()
120
121 def IsFatal(self):
122 """An error that is fatal stops g4 mail/submit immediately, i.e. before
123 other presubmit scripts are run.
124 """
125 return False
126
127 def ShouldPrompt(self):
128 """Whether this presubmit result should result in a prompt warning."""
129 return False
130
131 class PresubmitError(PresubmitResult):
132 """A hard presubmit error."""
133 def IsFatal(self):
134 return True
135
136 class PresubmitPromptWarning(PresubmitResult):
137 """An warning that prompts the user if they want to continue."""
138 def ShouldPrompt(self):
139 return True
140
141 class PresubmitNotifyResult(PresubmitResult):
142 """Just print something to the screen -- but it's not even a warning."""
143 pass
144
145 class MailTextResult(PresubmitResult):
146 """A warning that should be included in the review request email."""
147 def __init__(self, *args, **kwargs):
148 raise NotImplementedException() # TODO(joi) Implement.
149
150
151class InputApi(object):
152 """An instance of this object is passed to presubmit scripts so they can
153 know stuff about the change they're looking at.
154 """
155
156 def __init__(self, change, presubmit_path):
157 """Builds an InputApi object.
158
159 Args:
160 change: A presubmit.GclChange object.
161 presubmit_path: The path to the presubmit script being processed.
162 """
163 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
176 self.urllib2 = urllib2
177
178 # InputApi.platform is the platform you're currently running on.
179 self.platform = sys.platform
180
181 # The local path of the currently-being-processed presubmit script.
182 self.current_presubmit_path = presubmit_path
183
184 # We carry the canned checks so presubmit scripts can easily use them.
185 self.canned_checks = presubmit_canned_checks
186
187 def PresubmitLocalPath(self):
188 """Returns the local path of the presubmit script currently being run.
189
190 This is useful if you don't want to hard-code absolute paths in the
191 presubmit script. For example, It can be used to find another file
192 relative to the PRESUBMIT.py script, so the whole tree can be branched and
193 the presubmit script still works, without editing its content.
194 """
195 return self.current_presubmit_path
196
197 @staticmethod
198 def DepotToLocalPath(depot_path):
199 """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 """
210 local_path = gcl.GetSVNFileInfo(depot_path).get('Path')
211 if not local_path:
212 return None
213 else:
214 return local_path
215
216 @staticmethod
217 def LocalToDepotPath(local_path):
218 """Translate a local path to a depot path.
219
220 Args:
221 Local path (relative to current directory, or absolute) as a string.
222
223 Returns:
224 The depot path (SVN URL) of the file if mapped, otherwise None.
225 """
226 depot_path = gcl.GetSVNFileInfo(local_path).get('URL')
227 if not depot_path:
228 return None
229 else:
230 return depot_path
231
232 @staticmethod
233 def FilterTextFiles(affected_files, include_deletes=True):
234 """Filters out all except text files and optionally also filters out
235 deleted files.
236
237 Args:
238 affected_files: List of AffectedFiles objects.
239 include_deletes: If false, deleted files will be filtered out.
240
241 Returns:
242 Filtered list of AffectedFiles objects.
243 """
244 output_files = []
245 for af in affected_files:
246 if include_deletes or af.Action() != 'D':
247 path = af.AbsoluteLocalPath()
248 mime_type = gcl.GetSVNFileProperty(path, 'svn:mime-type')
249 if not mime_type or mime_type.startswith('text/'):
250 output_files.append(af)
251 return output_files
252
253 def AffectedFiles(self, include_dirs=False, include_deletes=True):
254 """Same as input_api.change.AffectedFiles() except only lists files
255 (and optionally directories) in the same directory as the current presubmit
256 script, or subdirectories thereof.
257 """
258 output_files = []
259 dir_with_slash = normpath(
260 "%s/" % os.path.dirname(self.current_presubmit_path))
261 if len(dir_with_slash) == 1:
262 dir_with_slash = ''
263 for af in self.change.AffectedFiles(include_dirs, include_deletes):
264 af_path = normpath(af.LocalPath())
265 if af_path.startswith(dir_with_slash):
266 output_files.append(af)
267 return output_files
268
269 def LocalPaths(self, include_dirs=False):
270 """Returns local paths of input_api.AffectedFiles()."""
271 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
272
273 def AbsoluteLocalPaths(self, include_dirs=False):
274 """Returns absolute local paths of input_api.AffectedFiles()."""
275 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
276
277 def ServerPaths(self, include_dirs=False):
278 """Returns server paths of input_api.AffectedFiles()."""
279 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
280
281 def AffectedTextFiles(self, include_deletes=True):
282 """Same as input_api.change.AffectedTextFiles() except only lists files
283 in the same directory as the current presubmit script, or subdirectories
284 thereof.
285
286 Warning: This function retrieves the svn property on each file so it can be
287 slow for large change lists.
288 """
289 return InputApi.FilterTextFiles(self.AffectedFiles(include_dirs=False),
290 include_deletes)
291
292 def RightHandSideLines(self):
293 """An iterator over all text lines in "new" version of changed files.
294
295 Only lists lines from new or modified text files in the change that are
296 contained by the directory of the currently executing presubmit script.
297
298 This is useful for doing line-by-line regex checks, like checking for
299 trailing whitespace.
300
301 Yields:
302 a 3 tuple:
303 the AffectedFile instance of the current file;
304 integer line number (1-based); and
305 the contents of the line as a string.
306 """
307 return InputApi._RightHandSideLinesImpl(
308 self.AffectedTextFiles(include_deletes=False))
309
310 @staticmethod
311 def _RightHandSideLinesImpl(affected_files):
312 """Implements RightHandSideLines for InputApi and GclChange."""
313 for af in affected_files:
314 lines = af.NewContents()
315 line_number = 0
316 for line in lines:
317 line_number += 1
318 yield (af, line_number, line)
319
320
321class AffectedFile(object):
322 """Representation of a file in a change."""
323
324 def __init__(self, path, action, repository_root=''):
325 self.path = path
326 self.action = action.strip()
327 self.repository_root = repository_root
328
329 def ServerPath(self):
330 """Returns a path string that identifies the file in the SCM system.
331
332 Returns the empty string if the file does not exist in SCM.
333 """
334 return gcl.GetSVNFileInfo(self.AbsoluteLocalPath()).get('URL', '')
335
336 def LocalPath(self):
337 """Returns the path of this file on the local disk relative to client root.
338 """
339 return normpath(self.path)
340
341 def AbsoluteLocalPath(self):
342 """Returns the absolute path of this file on the local disk.
343 """
344 return normpath(os.path.join(self.repository_root, self.LocalPath()))
345
346 def IsDirectory(self):
347 """Returns true if this object is a directory."""
348 if os.path.exists(self.path):
349 # Retrieve directly from the file system; it is much faster than querying
350 # subversion, especially on Windows.
351 return os.path.isdir(self.path)
352 else:
353 return gcl.GetSVNFileInfo(self.path).get('Node Kind') == 'directory'
354
355 def SvnProperty(self, property_name):
356 """Returns the specified SVN property of this file, or the empty string
357 if no such property.
358 """
359 return gcl.GetSVNFileProperty(self.AbsoluteLocalPath(), property_name)
360
361 def Action(self):
362 """Returns the action on this opened file, e.g. A, M, D, etc."""
363 return self.action
364
365 def NewContents(self):
366 """Returns an iterator over the lines in the new version of file.
367
368 The new version is the file in the user's workspace, i.e. the "right hand
369 side".
370
371 Contents will be empty if the file is a directory or does not exist.
372 """
373 if self.IsDirectory():
374 return []
375 else:
376 return gcl.ReadFile(self.AbsoluteLocalPath()).splitlines()
377
378 def OldContents(self):
379 """Returns an iterator over the lines in the old version of file.
380
381 The old version is the file in depot, i.e. the "left hand side".
382 """
383 raise NotImplementedError() # Implement when needed
384
385 def OldFileTempPath(self):
386 """Returns the path on local disk where the old contents resides.
387
388 The old version is the file in depot, i.e. the "left hand side".
389 This is a read-only cached copy of the old contents. *DO NOT* try to
390 modify this file.
391 """
392 raise NotImplementedError() # Implement if/when needed.
393
394
395class GclChange(object):
396 """A gcl change. See gcl.ChangeInfo for more info."""
397
398 def __init__(self, change_info, repository_root=''):
399 self.name = change_info.name
400 self.full_description = change_info.description
401 self.repository_root = repository_root
402
403 # From the description text, build up a dictionary of key/value pairs
404 # plus the description minus all key/value or "tag" lines.
405 self.description_without_tags = []
406 self.tags = {}
407 for line in change_info.description.splitlines():
408 m = _tag_line_re.match(line)
409 if m:
410 self.tags[m.group('key')] = m.group('value')
411 else:
412 self.description_without_tags.append(line)
413
414 # Change back to text and remove whitespace at end.
415 self.description_without_tags = '\n'.join(self.description_without_tags)
416 self.description_without_tags = self.description_without_tags.rstrip()
417
418 self.affected_files = [AffectedFile(info[1], info[0], repository_root) for
419 info in change_info.files]
420
421 def Change(self):
422 """Returns the change name."""
423 return self.name
424
425 def Changelist(self):
426 """Synonym for Change()."""
427 return self.Change()
428
429 def DescriptionText(self):
430 """Returns the user-entered changelist description, minus tags.
431
432 Any line in the user-provided description starting with e.g. "FOO="
433 (whitespace permitted before and around) is considered a tag line. Such
434 lines are stripped out of the description this function returns.
435 """
436 return self.description_without_tags
437
438 def FullDescriptionText(self):
439 """Returns the complete changelist description including tags."""
440 return self.full_description
441
442 def RepositoryRoot(self):
443 """Returns the repository root for this change, as an absolute path."""
444 return self.repository_root
445
446 def __getattr__(self, attr):
447 """Return keys directly as attributes on the object.
448
449 You may use a friendly name (from SPECIAL_KEYS) or the actual name of
450 the key.
451 """
452 if attr in SPECIAL_KEYS:
453 key = SPECIAL_KEYS[attr]
454 if key in self.tags:
455 return self.tags[key]
456 if attr in self.tags:
457 return self.tags[attr]
458
459 def AffectedFiles(self, include_dirs=False, include_deletes=True):
460 """Returns a list of AffectedFile instances for all files in the change.
461
462 Args:
463 include_deletes: If false, deleted files will be filtered out.
464 include_dirs: True to include directories in the list
465
466 Returns:
467 [AffectedFile(path, action), AffectedFile(path, action)]
468 """
469 if include_dirs:
470 affected = self.affected_files
471 else:
472 affected = filter(lambda x: not x.IsDirectory(), self.affected_files)
473
474 if include_deletes:
475 return affected
476 else:
477 return filter(lambda x: x.Action() != 'D', affected)
478
479 def AffectedTextFiles(self, include_deletes=True):
480 """Return a list of the text files in a change.
481
482 It's common to want to iterate over only the text files.
483
484 Args:
485 include_deletes: Controls whether to return files with "delete" actions,
486 which commonly aren't relevant to presubmit scripts.
487 """
488 return InputApi.FilterTextFiles(self.AffectedFiles(include_dirs=False),
489 include_deletes)
490
491 def LocalPaths(self, include_dirs=False):
492 """Convenience function."""
493 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
494
495 def AbsoluteLocalPaths(self, include_dirs=False):
496 """Convenience function."""
497 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
498
499 def ServerPaths(self, include_dirs=False):
500 """Convenience function."""
501 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
502
503 def RightHandSideLines(self):
504 """An iterator over all text lines in "new" version of changed files.
505
506 Lists lines from new or modified text files in the change.
507
508 This is useful for doing line-by-line regex checks, like checking for
509 trailing whitespace.
510
511 Yields:
512 a 3 tuple:
513 the AffectedFile instance of the current file;
514 integer line number (1-based); and
515 the contents of the line as a string.
516 """
517 return InputApi._RightHandSideLinesImpl(
518 self.AffectedTextFiles(include_deletes=False))
519
520
521def ListRelevantPresubmitFiles(files):
522 """Finds all presubmit files that apply to a given set of source files.
523
524 Args:
525 files: An iterable container containing file paths.
526
527 Return:
528 ['foo/blat/PRESUBMIT.py', 'mat/gat/PRESUBMIT.py']
529 """
530 checked_dirs = {} # Keys are directory paths, values are ignored.
531 source_dirs = [os.path.dirname(f) for f in files]
532 presubmit_files = []
533 for dir in source_dirs:
534 while (True):
535 if dir in checked_dirs:
536 break # We've already walked up from this directory.
537
538 test_path = os.path.join(dir, 'PRESUBMIT.py')
539 if os.path.isfile(test_path):
540 presubmit_files.append(normpath(test_path))
541
542 checked_dirs[dir] = ''
543 if dir in ['', '.']:
544 break
545 else:
546 dir = os.path.dirname(dir)
547 return presubmit_files
548
549
550class PresubmitExecuter(object):
551
552 def __init__(self, change_info, committing):
553 """
554 Args:
555 change_info: The ChangeInfo object for the change.
556 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
557 """
558 self.change = GclChange(change_info, gcl.GetRepositoryRoot())
559 self.committing = committing
560
561 def ExecPresubmitScript(self, script_text, presubmit_path):
562 """Executes a single presubmit script.
563
564 Args:
565 script_text: The text of the presubmit script.
566 presubmit_path: The path to the presubmit file (this will be reported via
567 input_api.PresubmitLocalPath()).
568
569 Return:
570 A list of result objects, empty if no problems.
571 """
572 input_api = InputApi(self.change, presubmit_path)
573 context = {}
574 exec script_text in context
575
576 # These function names must change if we make substantial changes to
577 # the presubmit API that are not backwards compatible.
578 if self.committing:
579 function_name = 'CheckChangeOnCommit'
580 else:
581 function_name = 'CheckChangeOnUpload'
582 if function_name in context:
583 context['__args'] = (input_api, OutputApi())
584 result = eval(function_name + '(*__args)', context)
585 if not (isinstance(result, types.TupleType) or
586 isinstance(result, types.ListType)):
587 raise exceptions.RuntimeError(
588 'Presubmit functions must return a tuple or list')
589 for item in result:
590 if not isinstance(item, OutputApi.PresubmitResult):
591 raise exceptions.RuntimeError(
592 'All presubmit results must be of types derived from '
593 'output_api.PresubmitResult')
594 else:
595 result = () # no error since the script doesn't care about current event.
596
597 return result
598
599
600def DoPresubmitChecks(change_info,
601 committing,
602 verbose,
603 output_stream,
maruel@google.com9b613272009-04-24 01:28:28 +0000604 input_stream):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000605 """Runs all presubmit checks that apply to the files in the change.
606
607 This finds all PRESUBMIT.py files in directories enclosing the files in the
608 change (up to the repository root) and calls the relevant entrypoint function
609 depending on whether the change is being committed or uploaded.
610
611 Prints errors, warnings and notifications. Prompts the user for warnings
612 when needed.
613
614 Args:
615 change_info: The ChangeInfo object for the change.
616 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
617 verbose: Prints debug info.
618 output_stream: A stream to write output from presubmit tests to.
619 input_stream: A stream to read input from the user.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000620
621 Return:
622 True if execution can continue, False if not.
623 """
624 presubmit_files = ListRelevantPresubmitFiles(change_info.FileList())
625 if not presubmit_files and verbose:
626 print "Warning, no presubmit.py found."
627 results = []
628 executer = PresubmitExecuter(change_info, committing)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000629 for filename in presubmit_files:
630 if verbose:
631 print "Running %s" % filename
maruel@chromium.orgc1675e22009-04-27 20:30:48 +0000632 # Accept CRLF presubmit script.
633 presubmit_script = gcl.ReadFile(filename, 'Ur')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000634 results += executer.ExecPresubmitScript(presubmit_script, filename)
635
636 errors = []
637 notifications = []
638 warnings = []
639 for result in results:
640 if not result.IsFatal() and not result.ShouldPrompt():
641 notifications.append(result)
642 elif result.ShouldPrompt():
643 warnings.append(result)
644 else:
645 errors.append(result)
646
647 error_count = 0
648 for name, items in (('Messages', notifications),
649 ('Warnings', warnings),
650 ('ERRORS', errors)):
651 if items:
652 output_stream.write('\n** Presubmit %s **\n\n' % name)
653 for item in items:
654 if not item._Handle(output_stream, input_stream,
655 may_prompt=False):
656 error_count += 1
657 output_stream.write('\n')
658 if not errors and warnings:
659 output_stream.write(
660 'There were presubmit warnings. Sure you want to continue? (y/N): ')
661 response = input_stream.readline()
662 if response.strip().lower() != 'y':
663 error_count += 1
664 return (error_count == 0)
665
666
667def ScanSubDirs(mask, recursive):
668 if not recursive:
669 return [x for x in glob.glob(mask) if '.svn' not in x]
670 else:
671 results = []
672 for root, dirs, files in os.walk('.'):
673 if '.svn' in dirs:
674 dirs.remove('.svn')
675 for name in files:
676 if fnmatch.fnmatch(name, mask):
677 results.append(os.path.join(root, name))
678 return results
679
680
681def ParseFiles(args, recursive):
682 files = []
683 for arg in args:
684 files.extend([('M', file) for file in ScanSubDirs(arg, recursive)])
685 return files
686
687
688def Main(argv):
689 parser = optparse.OptionParser(usage="%prog [options]",
690 version="%prog " + str(__version__))
691 parser.add_option("-c", "--commit", action="store_true",
692 help="Use commit instead of upload checks")
693 parser.add_option("-r", "--recursive", action="store_true",
694 help="Act recursively")
695 parser.add_option("-v", "--verbose", action="store_true",
696 help="Verbose output")
697 options, args = parser.parse_args(argv[1:])
698 files = ParseFiles(args, options.recursive)
699 if options.verbose:
700 print "Found %d files." % len(files)
maruel@google.com9b613272009-04-24 01:28:28 +0000701 return DoPresubmitChecks(gcl.ChangeInfo(name='temp', files=files),
702 options.commit,
703 options.verbose,
704 sys.stdout,
705 sys.stdin)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000706
707
708if __name__ == '__main__':
709 sys.exit(Main(sys.argv))