OLD | NEW |
1 #!/usr/bin/python | 1 #!/usr/bin/python |
2 # Copyright (c) 2006-2009 The Chromium Authors. All rights reserved. | 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 | 3 # Use of this source code is governed by a BSD-style license that can be |
4 # found in the LICENSE file. | 4 # found in the LICENSE file. |
5 | 5 |
6 """Enables directory-specific presubmit checks to run at upload and/or commit. | 6 """Enables directory-specific presubmit checks to run at upload and/or commit. |
7 """ | 7 """ |
8 | 8 |
9 __version__ = '1.3.2' | 9 __version__ = '1.3.2' |
10 | 10 |
(...skipping 155 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
166 r".*\b[A-Z0-9_]+$", | 166 r".*\b[A-Z0-9_]+$", |
167 # SCM (can happen in dual SCM configuration). (Slightly over aggressive) | 167 # SCM (can happen in dual SCM configuration). (Slightly over aggressive) |
168 r".*\.git[\\\/].*", | 168 r".*\.git[\\\/].*", |
169 r".*\.svn[\\\/].*", | 169 r".*\.svn[\\\/].*", |
170 ) | 170 ) |
171 | 171 |
172 def __init__(self, change, presubmit_path, is_committing): | 172 def __init__(self, change, presubmit_path, is_committing): |
173 """Builds an InputApi object. | 173 """Builds an InputApi object. |
174 | 174 |
175 Args: | 175 Args: |
176 change: A presubmit.GclChange object. | 176 change: A presubmit.Change object. |
177 presubmit_path: The path to the presubmit script being processed. | 177 presubmit_path: The path to the presubmit script being processed. |
178 is_committing: True if the change is about to be committed. | 178 is_committing: True if the change is about to be committed. |
179 """ | 179 """ |
180 # Version number of the presubmit_support script. | 180 # Version number of the presubmit_support script. |
181 self.version = [int(x) for x in __version__.split('.')] | 181 self.version = [int(x) for x in __version__.split('.')] |
182 self.change = change | 182 self.change = change |
183 self.is_committing = is_committing | 183 self.is_committing = is_committing |
184 | 184 |
185 # We expose various modules and functions as attributes of the input_api | 185 # We expose various modules and functions as attributes of the input_api |
186 # so that presubmit scripts don't have to import them. | 186 # so that presubmit scripts don't have to import them. |
(...skipping 166 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
353 line_number += 1 | 353 line_number += 1 |
354 yield (af, line_number, line) | 354 yield (af, line_number, line) |
355 | 355 |
356 | 356 |
357 class AffectedFile(object): | 357 class AffectedFile(object): |
358 """Representation of a file in a change.""" | 358 """Representation of a file in a change.""" |
359 | 359 |
360 def __init__(self, path, action, repository_root=''): | 360 def __init__(self, path, action, repository_root=''): |
361 self._path = path | 361 self._path = path |
362 self._action = action | 362 self._action = action |
363 self._repository_root = repository_root | 363 self._local_root = repository_root |
364 self._is_directory = None | 364 self._is_directory = None |
365 self._properties = {} | 365 self._properties = {} |
366 self.scm = '' | 366 self.scm = '' |
367 | 367 |
368 def ServerPath(self): | 368 def ServerPath(self): |
369 """Returns a path string that identifies the file in the SCM system. | 369 """Returns a path string that identifies the file in the SCM system. |
370 | 370 |
371 Returns the empty string if the file does not exist in SCM. | 371 Returns the empty string if the file does not exist in SCM. |
372 """ | 372 """ |
373 return "" | 373 return "" |
374 | 374 |
375 def LocalPath(self): | 375 def LocalPath(self): |
376 """Returns the path of this file on the local disk relative to client root. | 376 """Returns the path of this file on the local disk relative to client root. |
377 """ | 377 """ |
378 return normpath(self._path) | 378 return normpath(self._path) |
379 | 379 |
380 def AbsoluteLocalPath(self): | 380 def AbsoluteLocalPath(self): |
381 """Returns the absolute path of this file on the local disk. | 381 """Returns the absolute path of this file on the local disk. |
382 """ | 382 """ |
383 return normpath(os.path.join(self._repository_root, self.LocalPath())) | 383 return normpath(os.path.join(self._local_root, self.LocalPath())) |
384 | 384 |
385 def IsDirectory(self): | 385 def IsDirectory(self): |
386 """Returns true if this object is a directory.""" | 386 """Returns true if this object is a directory.""" |
387 if self._is_directory is None: | 387 if self._is_directory is None: |
388 path = self.AbsoluteLocalPath() | 388 path = self.AbsoluteLocalPath() |
389 self._is_directory = (os.path.exists(path) and | 389 self._is_directory = (os.path.exists(path) and |
390 os.path.isdir(path)) | 390 os.path.isdir(path)) |
391 return self._is_directory | 391 return self._is_directory |
392 | 392 |
393 def Action(self): | 393 def Action(self): |
(...skipping 88 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
482 self._is_text_file = False | 482 self._is_text_file = False |
483 elif self.IsDirectory(): | 483 elif self.IsDirectory(): |
484 self._is_text_file = False | 484 self._is_text_file = False |
485 else: | 485 else: |
486 mime_type = gcl.GetSVNFileProperty(self.AbsoluteLocalPath(), | 486 mime_type = gcl.GetSVNFileProperty(self.AbsoluteLocalPath(), |
487 'svn:mime-type') | 487 'svn:mime-type') |
488 self._is_text_file = (not mime_type or mime_type.startswith('text/')) | 488 self._is_text_file = (not mime_type or mime_type.startswith('text/')) |
489 return self._is_text_file | 489 return self._is_text_file |
490 | 490 |
491 | 491 |
492 class GclChange(object): | 492 class Change(object): |
493 """Describe a change. | 493 """Describe a change. |
494 | 494 |
495 Used directly by the presubmit scripts to query the current change being | 495 Used directly by the presubmit scripts to query the current change being |
496 tested. | 496 tested. |
497 | 497 |
498 Instance members: | 498 Instance members: |
499 tags: Dictionnary of KEY=VALUE pairs found in the change description. | 499 tags: Dictionnary of KEY=VALUE pairs found in the change description. |
500 self.KEY: equivalent to tags['KEY'] | 500 self.KEY: equivalent to tags['KEY'] |
501 """ | 501 """ |
502 | 502 |
| 503 _AFFECTED_FILES = AffectedFile |
| 504 |
503 # Matches key/value (or "tag") lines in changelist descriptions. | 505 # Matches key/value (or "tag") lines in changelist descriptions. |
504 _tag_line_re = re.compile( | 506 _TAG_LINE_RE = re.compile( |
505 '^\s*(?P<key>[A-Z][A-Z_0-9]*)\s*=\s*(?P<value>.*?)\s*$') | 507 '^\s*(?P<key>[A-Z][A-Z_0-9]*)\s*=\s*(?P<value>.*?)\s*$') |
506 | 508 |
507 def __init__(self, change_info): | 509 def __init__(self, name, description, local_root, files, issue, patchset): |
508 # Do not keep a reference to the original change_info. | 510 if files is None: |
509 self._name = change_info.name | 511 files = [] |
510 self._full_description = change_info.description | 512 self._name = name |
511 self._repository_root = change_info.GetLocalRoot() | 513 self._full_description = description |
512 self.issue = change_info.issue | 514 self._local_root = local_root |
513 self.patchset = change_info.patchset | 515 self.issue = issue |
| 516 self.patchset = patchset |
514 | 517 |
515 # From the description text, build up a dictionary of key/value pairs | 518 # From the description text, build up a dictionary of key/value pairs |
516 # plus the description minus all key/value or "tag" lines. | 519 # plus the description minus all key/value or "tag" lines. |
517 self._description_without_tags = [] | 520 self._description_without_tags = [] |
518 self.tags = {} | 521 self.tags = {} |
519 for line in self._full_description.splitlines(): | 522 for line in self._full_description.splitlines(): |
520 m = self._tag_line_re.match(line) | 523 m = self._TAG_LINE_RE.match(line) |
521 if m: | 524 if m: |
522 self.tags[m.group('key')] = m.group('value') | 525 self.tags[m.group('key')] = m.group('value') |
523 else: | 526 else: |
524 self._description_without_tags.append(line) | 527 self._description_without_tags.append(line) |
525 | 528 |
526 # Change back to text and remove whitespace at end. | 529 # Change back to text and remove whitespace at end. |
527 self._description_without_tags = '\n'.join(self._description_without_tags) | 530 self._description_without_tags = '\n'.join(self._description_without_tags) |
528 self._description_without_tags = self._description_without_tags.rstrip() | 531 self._description_without_tags = self._description_without_tags.rstrip() |
529 | 532 |
530 self._affected_files = [ | 533 self._affected_files = [ |
531 SvnAffectedFile(info[1], info[0].strip(), self._repository_root) | 534 self._AFFECTED_FILES(info[1], info[0].strip(), self._local_root) |
532 for info in change_info.GetFiles() | 535 for info in files |
533 ] | 536 ] |
534 | 537 |
535 def Name(self): | 538 def Name(self): |
536 """Returns the change name.""" | 539 """Returns the change name.""" |
537 return self._name | 540 return self._name |
538 | 541 |
539 def DescriptionText(self): | 542 def DescriptionText(self): |
540 """Returns the user-entered changelist description, minus tags. | 543 """Returns the user-entered changelist description, minus tags. |
541 | 544 |
542 Any line in the user-provided description starting with e.g. "FOO=" | 545 Any line in the user-provided description starting with e.g. "FOO=" |
543 (whitespace permitted before and around) is considered a tag line. Such | 546 (whitespace permitted before and around) is considered a tag line. Such |
544 lines are stripped out of the description this function returns. | 547 lines are stripped out of the description this function returns. |
545 """ | 548 """ |
546 return self._description_without_tags | 549 return self._description_without_tags |
547 | 550 |
548 def FullDescriptionText(self): | 551 def FullDescriptionText(self): |
549 """Returns the complete changelist description including tags.""" | 552 """Returns the complete changelist description including tags.""" |
550 return self._full_description | 553 return self._full_description |
551 | 554 |
552 def RepositoryRoot(self): | 555 def RepositoryRoot(self): |
553 """Returns the repository (checkout) root directory for this change, | 556 """Returns the repository (checkout) root directory for this change, |
554 as an absolute path. | 557 as an absolute path. |
555 """ | 558 """ |
556 return self._repository_root | 559 return self._local_root |
557 | 560 |
558 def __getattr__(self, attr): | 561 def __getattr__(self, attr): |
559 """Return tags directly as attributes on the object.""" | 562 """Return tags directly as attributes on the object.""" |
560 if not re.match(r"^[A-Z_]*$", attr): | 563 if not re.match(r"^[A-Z_]*$", attr): |
561 raise AttributeError(self, attr) | 564 raise AttributeError(self, attr) |
562 return self.tags.get(attr) | 565 return self.tags.get(attr) |
563 | 566 |
564 def AffectedFiles(self, include_dirs=False, include_deletes=True): | 567 def AffectedFiles(self, include_dirs=False, include_deletes=True): |
565 """Returns a list of AffectedFile instances for all files in the change. | 568 """Returns a list of AffectedFile instances for all files in the change. |
566 | 569 |
(...skipping 48 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
615 a 3 tuple: | 618 a 3 tuple: |
616 the AffectedFile instance of the current file; | 619 the AffectedFile instance of the current file; |
617 integer line number (1-based); and | 620 integer line number (1-based); and |
618 the contents of the line as a string. | 621 the contents of the line as a string. |
619 """ | 622 """ |
620 return InputApi._RightHandSideLinesImpl( | 623 return InputApi._RightHandSideLinesImpl( |
621 filter(lambda x: x.IsTextFile(), | 624 filter(lambda x: x.IsTextFile(), |
622 self.AffectedFiles(include_deletes=False))) | 625 self.AffectedFiles(include_deletes=False))) |
623 | 626 |
624 | 627 |
| 628 class SvnChange(Change): |
| 629 _AFFECTED_FILES = SvnAffectedFile |
| 630 |
| 631 |
625 def ListRelevantPresubmitFiles(files, root): | 632 def ListRelevantPresubmitFiles(files, root): |
626 """Finds all presubmit files that apply to a given set of source files. | 633 """Finds all presubmit files that apply to a given set of source files. |
627 | 634 |
628 Args: | 635 Args: |
629 files: An iterable container containing file paths. | 636 files: An iterable container containing file paths. |
630 root: Path where to stop searching. | 637 root: Path where to stop searching. |
631 | 638 |
632 Return: | 639 Return: |
633 List of absolute paths of the existing PRESUBMIT.py scripts. | 640 List of absolute paths of the existing PRESUBMIT.py scripts. |
634 """ | 641 """ |
635 entries = [] | 642 entries = [] |
636 for f in files: | 643 for f in files: |
637 f = normpath(os.path.join(root, f)) | 644 f = normpath(os.path.join(root, f)) |
638 while f: | 645 while f: |
639 f = os.path.dirname(f) | 646 f = os.path.dirname(f) |
640 if f in entries: | 647 if f in entries: |
641 break | 648 break |
642 entries.append(f) | 649 entries.append(f) |
643 if f == root: | 650 if f == root: |
644 break | 651 break |
645 entries.sort() | 652 entries.sort() |
646 entries = map(lambda x: os.path.join(x, 'PRESUBMIT.py'), entries) | 653 entries = map(lambda x: os.path.join(x, 'PRESUBMIT.py'), entries) |
647 return filter(lambda x: os.path.isfile(x), entries) | 654 return filter(lambda x: os.path.isfile(x), entries) |
648 | 655 |
649 | 656 |
650 class PresubmitExecuter(object): | 657 class PresubmitExecuter(object): |
651 def __init__(self, change_info, committing): | 658 def __init__(self, change, committing): |
652 """ | 659 """ |
653 Args: | 660 Args: |
654 change_info: The gcl.ChangeInfo object for the change. | 661 change: The Change object. |
655 committing: True if 'gcl commit' is running, False if 'gcl upload' is. | 662 committing: True if 'gcl commit' is running, False if 'gcl upload' is. |
656 """ | 663 """ |
657 # TODO(maruel): Determine the SCM. | 664 self.change = change |
658 self.change = GclChange(change_info) | |
659 self.committing = committing | 665 self.committing = committing |
660 | 666 |
661 def ExecPresubmitScript(self, script_text, presubmit_path): | 667 def ExecPresubmitScript(self, script_text, presubmit_path): |
662 """Executes a single presubmit script. | 668 """Executes a single presubmit script. |
663 | 669 |
664 Args: | 670 Args: |
665 script_text: The text of the presubmit script. | 671 script_text: The text of the presubmit script. |
666 presubmit_path: The path to the presubmit file (this will be reported via | 672 presubmit_path: The path to the presubmit file (this will be reported via |
667 input_api.PresubmitLocalPath()). | 673 input_api.PresubmitLocalPath()). |
668 | 674 |
(...skipping 21 matching lines...) Expand all Loading... |
690 if not isinstance(item, OutputApi.PresubmitResult): | 696 if not isinstance(item, OutputApi.PresubmitResult): |
691 raise exceptions.RuntimeError( | 697 raise exceptions.RuntimeError( |
692 'All presubmit results must be of types derived from ' | 698 'All presubmit results must be of types derived from ' |
693 'output_api.PresubmitResult') | 699 'output_api.PresubmitResult') |
694 else: | 700 else: |
695 result = () # no error since the script doesn't care about current event. | 701 result = () # no error since the script doesn't care about current event. |
696 | 702 |
697 return result | 703 return result |
698 | 704 |
699 | 705 |
700 def DoPresubmitChecks(change_info, | 706 def DoPresubmitChecks(change, |
701 committing, | 707 committing, |
702 verbose, | 708 verbose, |
703 output_stream, | 709 output_stream, |
704 input_stream, | 710 input_stream, |
705 default_presubmit, | 711 default_presubmit, |
706 may_prompt): | 712 may_prompt): |
707 """Runs all presubmit checks that apply to the files in the change. | 713 """Runs all presubmit checks that apply to the files in the change. |
708 | 714 |
709 This finds all PRESUBMIT.py files in directories enclosing the files in the | 715 This finds all PRESUBMIT.py files in directories enclosing the files in the |
710 change (up to the repository root) and calls the relevant entrypoint function | 716 change (up to the repository root) and calls the relevant entrypoint function |
711 depending on whether the change is being committed or uploaded. | 717 depending on whether the change is being committed or uploaded. |
712 | 718 |
713 Prints errors, warnings and notifications. Prompts the user for warnings | 719 Prints errors, warnings and notifications. Prompts the user for warnings |
714 when needed. | 720 when needed. |
715 | 721 |
716 Args: | 722 Args: |
717 change_info: The gcl.ChangeInfo object for the change. | 723 change: The Change object. |
718 committing: True if 'gcl commit' is running, False if 'gcl upload' is. | 724 committing: True if 'gcl commit' is running, False if 'gcl upload' is. |
719 verbose: Prints debug info. | 725 verbose: Prints debug info. |
720 output_stream: A stream to write output from presubmit tests to. | 726 output_stream: A stream to write output from presubmit tests to. |
721 input_stream: A stream to read input from the user. | 727 input_stream: A stream to read input from the user. |
722 default_presubmit: A default presubmit script to execute in any case. | 728 default_presubmit: A default presubmit script to execute in any case. |
723 may_prompt: Enable (y/n) questions on warning or error. | 729 may_prompt: Enable (y/n) questions on warning or error. |
724 | 730 |
725 Return: | 731 Return: |
726 True if execution can continue, False if not. | 732 True if execution can continue, False if not. |
727 """ | 733 """ |
728 presubmit_files = ListRelevantPresubmitFiles(change_info.GetFileNames(), | 734 presubmit_files = ListRelevantPresubmitFiles(change.AbsoluteLocalPaths(True), |
729 change_info.GetLocalRoot()) | 735 change.RepositoryRoot()) |
730 if not presubmit_files and verbose: | 736 if not presubmit_files and verbose: |
731 output_stream.write("Warning, no presubmit.py found.\n") | 737 output_stream.write("Warning, no presubmit.py found.\n") |
732 results = [] | 738 results = [] |
733 executer = PresubmitExecuter(change_info, committing) | 739 executer = PresubmitExecuter(change, committing) |
734 if default_presubmit: | 740 if default_presubmit: |
735 if verbose: | 741 if verbose: |
736 output_stream.write("Running default presubmit script.\n") | 742 output_stream.write("Running default presubmit script.\n") |
737 fake_path = os.path.join(change_info.GetLocalRoot(), 'PRESUBMIT.py') | 743 fake_path = os.path.join(change.RepositoryRoot(), 'PRESUBMIT.py') |
738 results += executer.ExecPresubmitScript(default_presubmit, fake_path) | 744 results += executer.ExecPresubmitScript(default_presubmit, fake_path) |
739 for filename in presubmit_files: | 745 for filename in presubmit_files: |
740 filename = os.path.abspath(filename) | 746 filename = os.path.abspath(filename) |
741 if verbose: | 747 if verbose: |
742 output_stream.write("Running %s\n" % filename) | 748 output_stream.write("Running %s\n" % filename) |
743 # Accept CRLF presubmit script. | 749 # Accept CRLF presubmit script. |
744 presubmit_script = gcl.ReadFile(filename, 'rU') | 750 presubmit_script = gcl.ReadFile(filename, 'rU') |
745 results += executer.ExecPresubmitScript(presubmit_script, filename) | 751 results += executer.ExecPresubmitScript(presubmit_script, filename) |
746 | 752 |
747 errors = [] | 753 errors = [] |
(...skipping 50 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
798 | 804 |
799 def Main(argv): | 805 def Main(argv): |
800 parser = optparse.OptionParser(usage="%prog [options]", | 806 parser = optparse.OptionParser(usage="%prog [options]", |
801 version="%prog " + str(__version__)) | 807 version="%prog " + str(__version__)) |
802 parser.add_option("-c", "--commit", action="store_true", | 808 parser.add_option("-c", "--commit", action="store_true", |
803 help="Use commit instead of upload checks") | 809 help="Use commit instead of upload checks") |
804 parser.add_option("-r", "--recursive", action="store_true", | 810 parser.add_option("-r", "--recursive", action="store_true", |
805 help="Act recursively") | 811 help="Act recursively") |
806 parser.add_option("-v", "--verbose", action="store_true", | 812 parser.add_option("-v", "--verbose", action="store_true", |
807 help="Verbose output") | 813 help="Verbose output") |
| 814 parser.add_option("--files", default='') |
| 815 parser.add_option("--name", default='no name') |
| 816 parser.add_option("--description", default='') |
| 817 parser.add_option("--issue", type='int', default=0) |
| 818 parser.add_option("--patchset", type='int', default=0) |
| 819 parser.add_options("--root", default='') |
| 820 parser.add_options("--default_presubmit", default='') |
| 821 parser.add_options("--may_prompt", action='store_true') |
808 options, args = parser.parse_args(argv[1:]) | 822 options, args = parser.parse_args(argv[1:]) |
809 files = ParseFiles(args, options.recursive) | 823 if not options.files: |
| 824 options.files = ParseFiles(args, options.recursive) |
| 825 if not options.root: |
| 826 options.root = gcl.GetRepositoryRoot() |
810 if options.verbose: | 827 if options.verbose: |
811 print "Found %d files." % len(files) | 828 print "Found %d files." % len(files) |
812 return not DoPresubmitChecks(gcl.ChangeInfo('No name', 0, 0, '', files, | 829 return not DoPresubmitChecks(SvnChange(options.name, |
813 gcl.GetRepositoryRoot()), | 830 options.description, |
| 831 options.root, |
| 832 options.files, |
| 833 options.issue, |
| 834 options.patchset), |
814 options.commit, | 835 options.commit, |
815 options.verbose, | 836 options.verbose, |
816 sys.stdout, | 837 sys.stdout, |
817 sys.stdin, | 838 sys.stdin, |
818 None, | 839 options.default_presubmit, |
819 False) | 840 options.may_prompt) |
820 | 841 |
821 | 842 |
822 if __name__ == '__main__': | 843 if __name__ == '__main__': |
823 sys.exit(Main(sys.argv)) | 844 sys.exit(Main(sys.argv)) |
OLD | NEW |