Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(31)

Side by Side Diff: presubmit.py

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

Powered by Google App Engine
This is Rietveld 408576698