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

Side by Side Diff: depot_tools/presubmit.py

Issue 92087: Create the Next Generation of depot_tools. Eh. (Closed) Base URL: svn://chrome-svn.corp.google.com/chrome/trunk/tools/
Patch Set: Created 11 years, 8 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
« no previous file with comments | « depot_tools/hammer.bat ('k') | depot_tools/presubmit_canned_checks.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
Property Changes:
Added: svn:executable
+ *
Added: svn:eol-style
+ LF
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'
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 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.
50 SPECIAL_KEYS = {
51 'Reviewers' : 'R',
52 'BugIDs' : 'BUG',
53 'Tested': 'TESTED'
54 }
55
56
57 class 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
65 def 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
76 class 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
151 class 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
321 class 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
395 class 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
521 def 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
550 class 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
600 def DoPresubmitChecks(change_info,
601 committing,
602 verbose,
603 output_stream,
604 input_stream,
605 default_presubmit):
606 """Runs all presubmit checks that apply to the files in the change.
607
608 This finds all PRESUBMIT.py files in directories enclosing the files in the
609 change (up to the repository root) and calls the relevant entrypoint function
610 depending on whether the change is being committed or uploaded.
611
612 Prints errors, warnings and notifications. Prompts the user for warnings
613 when needed.
614
615 Args:
616 change_info: The ChangeInfo object for the change.
617 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
618 verbose: Prints debug info.
619 output_stream: A stream to write output from presubmit tests to.
620 input_stream: A stream to read input from the user.
621 default_presubmit: A default presubmit script to execute in any case.
622
623 Return:
624 True if execution can continue, False if not.
625 """
626 presubmit_files = ListRelevantPresubmitFiles(change_info.FileList())
627 if not presubmit_files and verbose:
628 print "Warning, no presubmit.py found."
629 results = []
630 executer = PresubmitExecuter(change_info, committing)
631 if default_presubmit:
632 if verbose:
633 print "Running default presubmit script"
634 results += executer.ExecPresubmitScript(default_presubmit, 'PRESUBMIT.py')
635 for filename in presubmit_files:
636 if verbose:
637 print "Running %s" % filename
638 presubmit_script = gcl.ReadFile(filename)
639 results += executer.ExecPresubmitScript(presubmit_script, filename)
640
641 errors = []
642 notifications = []
643 warnings = []
644 for result in results:
645 if not result.IsFatal() and not result.ShouldPrompt():
646 notifications.append(result)
647 elif result.ShouldPrompt():
648 warnings.append(result)
649 else:
650 errors.append(result)
651
652 error_count = 0
653 for name, items in (('Messages', notifications),
654 ('Warnings', warnings),
655 ('ERRORS', errors)):
656 if items:
657 output_stream.write('\n** Presubmit %s **\n\n' % name)
658 for item in items:
659 if not item._Handle(output_stream, input_stream,
660 may_prompt=False):
661 error_count += 1
662 output_stream.write('\n')
663 if not errors and warnings:
664 output_stream.write(
665 'There were presubmit warnings. Sure you want to continue? (y/N): ')
666 response = input_stream.readline()
667 if response.strip().lower() != 'y':
668 error_count += 1
669 return (error_count == 0)
670
671
672 def ScanSubDirs(mask, recursive):
673 if not recursive:
674 return [x for x in glob.glob(mask) if '.svn' not in x]
675 else:
676 results = []
677 for root, dirs, files in os.walk('.'):
678 if '.svn' in dirs:
679 dirs.remove('.svn')
680 for name in files:
681 if fnmatch.fnmatch(name, mask):
682 results.append(os.path.join(root, name))
683 return results
684
685
686 def ParseFiles(args, recursive):
687 files = []
688 for arg in args:
689 files.extend([('M', file) for file in ScanSubDirs(arg, recursive)])
690 return files
691
692
693 def Main(argv):
694 parser = optparse.OptionParser(usage="%prog [options]",
695 version="%prog " + str(__version__))
696 parser.add_option("-c", "--commit", action="store_true",
697 help="Use commit instead of upload checks")
698 parser.add_option("-r", "--recursive", action="store_true",
699 help="Act recursively")
700 parser.add_option("-v", "--verbose", action="store_true",
701 help="Verbose output")
702 options, args = parser.parse_args(argv[1:])
703 files = ParseFiles(args, options.recursive)
704 if options.verbose:
705 print "Found %d files." % len(files)
706 return not DoPresubmitChecks(gcl.ChangeInfo(name='temp', files=files),
707 options.commit,
708 options.verbose,
709 sys.stdout,
710 sys.stdin,
711 default_presubmit=None)
712
713
714 if __name__ == '__main__':
715 sys.exit(Main(sys.argv))
OLDNEW
« no previous file with comments | « depot_tools/hammer.bat ('k') | depot_tools/presubmit_canned_checks.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698