/edk2/BaseTools/Scripts/PatchCheck.py
Python | 613 lines | 582 code | 15 blank | 16 comment | 16 complexity | 929344e50af0498d71f918aaea82fddd MD5 | raw file
- ## @file
- # Check a patch for various format issues
- #
- # Copyright (c) 2015, Intel Corporation. All rights reserved.<BR>
- #
- # This program and the accompanying materials are licensed and made
- # available under the terms and conditions of the BSD License which
- # accompanies this distribution. The full text of the license may be
- # found at http://opensource.org/licenses/bsd-license.php
- #
- # THE PROGRAM IS DISTRIBUTED UNDER THE BSD LICENSE ON AN "AS IS"
- # BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF ANY KIND, EITHER
- # EXPRESS OR IMPLIED.
- #
-
- from __future__ import print_function
-
- VersionNumber = '0.1'
- __copyright__ = "Copyright (c) 2015, Intel Corporation All rights reserved."
-
- import email
- import argparse
- import os
- import re
- import subprocess
- import sys
-
- class Verbose:
- SILENT, ONELINE, NORMAL = range(3)
- level = NORMAL
-
- class CommitMessageCheck:
- """Checks the contents of a git commit message."""
-
- def __init__(self, subject, message):
- self.ok = True
-
- if subject is None and message is None:
- self.error('Commit message is missing!')
- return
-
- self.subject = subject
- self.msg = message
-
- self.check_contributed_under()
- self.check_signed_off_by()
- self.check_misc_signatures()
- self.check_overall_format()
- self.report_message_result()
-
- url = 'https://github.com/tianocore/tianocore.github.io/wiki/Commit-Message-Format'
-
- def report_message_result(self):
- if Verbose.level < Verbose.NORMAL:
- return
- if self.ok:
- # All checks passed
- return_code = 0
- print('The commit message format passed all checks.')
- else:
- return_code = 1
- if not self.ok:
- print(self.url)
-
- def error(self, *err):
- if self.ok and Verbose.level > Verbose.ONELINE:
- print('The commit message format is not valid:')
- self.ok = False
- if Verbose.level < Verbose.NORMAL:
- return
- count = 0
- for line in err:
- prefix = (' *', ' ')[count > 0]
- print(prefix, line)
- count += 1
-
- def check_contributed_under(self):
- cu_msg='Contributed-under: TianoCore Contribution Agreement 1.0'
- if self.msg.find(cu_msg) < 0:
- self.error('Missing Contributed-under! (Note: this must be ' +
- 'added by the code contributor!)')
-
- @staticmethod
- def make_signature_re(sig, re_input=False):
- if re_input:
- sub_re = sig
- else:
- sub_re = sig.replace('-', r'[-\s]+')
- re_str = (r'^(?P<tag>' + sub_re +
- r')(\s*):(\s*)(?P<value>\S.*?)(?:\s*)$')
- try:
- return re.compile(re_str, re.MULTILINE|re.IGNORECASE)
- except Exception:
- print("Tried to compile re:", re_str)
- raise
-
- sig_block_re = \
- re.compile(r'''^
- (?: (?P<tag>[^:]+) \s* : \s*
- (?P<value>\S.*?) )
- |
- (?: \[ (?P<updater>[^:]+) \s* : \s*
- (?P<note>.+?) \s* \] )
- \s* $''',
- re.VERBOSE | re.MULTILINE)
-
- def find_signatures(self, sig):
- if not sig.endswith('-by') and sig != 'Cc':
- sig += '-by'
- regex = self.make_signature_re(sig)
-
- sigs = regex.findall(self.msg)
-
- bad_case_sigs = filter(lambda m: m[0] != sig, sigs)
- for s in bad_case_sigs:
- self.error("'" +s[0] + "' should be '" + sig + "'")
-
- for s in sigs:
- if s[1] != '':
- self.error('There should be no spaces between ' + sig +
- " and the ':'")
- if s[2] != ' ':
- self.error("There should be a space after '" + sig + ":'")
-
- self.check_email_address(s[3])
-
- return sigs
-
- email_re1 = re.compile(r'(?:\s*)(.*?)(\s*)<(.+)>\s*$',
- re.MULTILINE|re.IGNORECASE)
-
- def check_email_address(self, email):
- email = email.strip()
- mo = self.email_re1.match(email)
- if mo is None:
- self.error("Email format is invalid: " + email.strip())
- return
-
- name = mo.group(1).strip()
- if name == '':
- self.error("Name is not provided with email address: " +
- email)
- else:
- quoted = len(name) > 2 and name[0] == '"' and name[-1] == '"'
- if name.find(',') >= 0 and not quoted:
- self.error('Add quotes (") around name with a comma: ' +
- name)
-
- if mo.group(2) == '':
- self.error("There should be a space between the name and " +
- "email address: " + email)
-
- if mo.group(3).find(' ') >= 0:
- self.error("The email address cannot contain a space: " +
- mo.group(3))
-
- def check_signed_off_by(self):
- sob='Signed-off-by'
- if self.msg.find(sob) < 0:
- self.error('Missing Signed-off-by! (Note: this must be ' +
- 'added by the code contributor!)')
- return
-
- sobs = self.find_signatures('Signed-off')
-
- if len(sobs) == 0:
- self.error('Invalid Signed-off-by format!')
- return
-
- sig_types = (
- 'Reviewed',
- 'Reported',
- 'Tested',
- 'Suggested',
- 'Acked',
- 'Cc'
- )
-
- def check_misc_signatures(self):
- for sig in self.sig_types:
- self.find_signatures(sig)
-
- def check_overall_format(self):
- lines = self.msg.splitlines()
-
- if len(lines) >= 1 and lines[0].endswith('\r\n'):
- empty_line = '\r\n'
- else:
- empty_line = '\n'
-
- lines.insert(0, empty_line)
- lines.insert(0, self.subject + empty_line)
-
- count = len(lines)
-
- if count <= 0:
- self.error('Empty commit message!')
- return
-
- if count >= 1 and len(lines[0]) > 76:
- self.error('First line of commit message (subject line) ' +
- 'is too long.')
-
- if count >= 1 and len(lines[0].strip()) == 0:
- self.error('First line of commit message (subject line) ' +
- 'is empty.')
-
- if count >= 2 and lines[1].strip() != '':
- self.error('Second line of commit message should be ' +
- 'empty.')
-
- for i in range(2, count):
- if (len(lines[i]) > 76 and
- len(lines[i].split()) > 1 and
- not lines[i].startswith('git-svn-id:')):
- self.error('Line %d of commit message is too long.' % (i + 1))
-
- last_sig_line = None
- for i in range(count - 1, 0, -1):
- line = lines[i]
- mo = self.sig_block_re.match(line)
- if mo is None:
- if line.strip() == '':
- break
- elif last_sig_line is not None:
- err2 = 'Add empty line before "%s"?' % last_sig_line
- self.error('The line before the signature block ' +
- 'should be empty', err2)
- else:
- self.error('The signature block was not found')
- break
- last_sig_line = line.strip()
-
- (START, PRE_PATCH, PATCH) = range(3)
-
- class GitDiffCheck:
- """Checks the contents of a git diff."""
-
- def __init__(self, diff):
- self.ok = True
- self.format_ok = True
- self.lines = diff.splitlines(True)
- self.count = len(self.lines)
- self.line_num = 0
- self.state = START
- while self.line_num < self.count and self.format_ok:
- line_num = self.line_num
- self.run()
- assert(self.line_num > line_num)
- self.report_message_result()
-
- def report_message_result(self):
- if Verbose.level < Verbose.NORMAL:
- return
- if self.ok:
- print('The code passed all checks.')
-
- def run(self):
- line = self.lines[self.line_num]
-
- if self.state in (PRE_PATCH, PATCH):
- if line.startswith('diff --git'):
- self.state = START
- if self.state == PATCH:
- if line.startswith('@@ '):
- self.state = PRE_PATCH
- elif len(line) >= 1 and line[0] not in ' -+' and \
- not line.startswith(r'\ No newline '):
- for line in self.lines[self.line_num + 1:]:
- if line.startswith('diff --git'):
- self.format_error('diff found after end of patch')
- break
- self.line_num = self.count
- return
-
- if self.state == START:
- if line.startswith('diff --git'):
- self.state = PRE_PATCH
- self.set_filename(None)
- elif len(line.rstrip()) != 0:
- self.format_error("didn't find diff command")
- self.line_num += 1
- elif self.state == PRE_PATCH:
- if line.startswith('+++ b/'):
- self.set_filename(line[6:].rstrip())
- if line.startswith('@@ '):
- self.state = PATCH
- self.binary = False
- elif line.startswith('GIT binary patch'):
- self.state = PATCH
- self.binary = True
- else:
- ok = False
- for pfx in self.pre_patch_prefixes:
- if line.startswith(pfx):
- ok = True
- if not ok:
- self.format_error("didn't find diff hunk marker (@@)")
- self.line_num += 1
- elif self.state == PATCH:
- if self.binary:
- pass
- if line.startswith('-'):
- pass
- elif line.startswith('+'):
- self.check_added_line(line[1:])
- elif line.startswith(r'\ No newline '):
- pass
- elif not line.startswith(' '):
- self.format_error("unexpected patch line")
- self.line_num += 1
-
- pre_patch_prefixes = (
- '--- ',
- '+++ ',
- 'index ',
- 'new file ',
- 'deleted file ',
- 'old mode ',
- 'new mode ',
- 'similarity index ',
- 'rename ',
- 'Binary files ',
- )
-
- line_endings = ('\r\n', '\n\r', '\n', '\r')
-
- def set_filename(self, filename):
- self.hunk_filename = filename
- if filename:
- self.force_crlf = not filename.endswith('.sh')
- else:
- self.force_crlf = True
-
- def added_line_error(self, msg, line):
- lines = [ msg ]
- if self.hunk_filename is not None:
- lines.append('File: ' + self.hunk_filename)
- lines.append('Line: ' + line)
-
- self.error(*lines)
-
- def check_added_line(self, line):
- eol = ''
- for an_eol in self.line_endings:
- if line.endswith(an_eol):
- eol = an_eol
- line = line[:-len(eol)]
-
- stripped = line.rstrip()
-
- if self.force_crlf and eol != '\r\n':
- self.added_line_error('Line ending (%s) is not CRLF' % repr(eol),
- line)
- if '\t' in line:
- self.added_line_error('Tab character used', line)
- if len(stripped) < len(line):
- self.added_line_error('Trailing whitespace found', line)
-
- split_diff_re = re.compile(r'''
- (?P<cmd>
- ^ diff \s+ --git \s+ a/.+ \s+ b/.+ $
- )
- (?P<index>
- ^ index \s+ .+ $
- )
- ''',
- re.IGNORECASE | re.VERBOSE | re.MULTILINE)
-
- def format_error(self, err):
- self.format_ok = False
- err = 'Patch format error: ' + err
- err2 = 'Line: ' + self.lines[self.line_num].rstrip()
- self.error(err, err2)
-
- def error(self, *err):
- if self.ok and Verbose.level > Verbose.ONELINE:
- print('Code format is not valid:')
- self.ok = False
- if Verbose.level < Verbose.NORMAL:
- return
- count = 0
- for line in err:
- prefix = (' *', ' ')[count > 0]
- print(prefix, line)
- count += 1
-
- class CheckOnePatch:
- """Checks the contents of a git email formatted patch.
-
- Various checks are performed on both the commit message and the
- patch content.
- """
-
- def __init__(self, name, patch):
- self.patch = patch
- self.find_patch_pieces()
-
- msg_check = CommitMessageCheck(self.commit_subject, self.commit_msg)
- msg_ok = msg_check.ok
-
- diff_ok = True
- if self.diff is not None:
- diff_check = GitDiffCheck(self.diff)
- diff_ok = diff_check.ok
-
- self.ok = msg_ok and diff_ok
-
- if Verbose.level == Verbose.ONELINE:
- if self.ok:
- result = 'ok'
- else:
- result = list()
- if not msg_ok:
- result.append('commit message')
- if not diff_ok:
- result.append('diff content')
- result = 'bad ' + ' and '.join(result)
- print(name, result)
-
-
- git_diff_re = re.compile(r'''
- ^ diff \s+ --git \s+ a/.+ \s+ b/.+ $
- ''',
- re.IGNORECASE | re.VERBOSE | re.MULTILINE)
-
- stat_re = \
- re.compile(r'''
- (?P<commit_message> [\s\S\r\n]* )
- (?P<stat>
- ^ --- $ [\r\n]+
- (?: ^ \s+ .+ \s+ \| \s+ \d+ \s+ \+* \-*
- $ [\r\n]+ )+
- [\s\S\r\n]+
- )
- ''',
- re.IGNORECASE | re.VERBOSE | re.MULTILINE)
-
- def find_patch_pieces(self):
- if sys.version_info < (3, 0):
- patch = self.patch.encode('ascii', 'ignore')
- else:
- patch = self.patch
-
- self.commit_msg = None
- self.stat = None
- self.commit_subject = None
- self.commit_prefix = None
- self.diff = None
-
- if patch.startswith('diff --git'):
- self.diff = patch
- return
-
- pmail = email.message_from_string(patch)
- parts = list(pmail.walk())
- assert(len(parts) == 1)
- assert(parts[0].get_content_type() == 'text/plain')
- content = parts[0].get_payload(decode=True).decode('utf-8', 'ignore')
-
- mo = self.git_diff_re.search(content)
- if mo is not None:
- self.diff = content[mo.start():]
- content = content[:mo.start()]
-
- mo = self.stat_re.search(content)
- if mo is None:
- self.commit_msg = content
- else:
- self.stat = mo.group('stat')
- self.commit_msg = mo.group('commit_message')
-
- self.commit_subject = pmail['subject'].replace('\r\n', '')
- self.commit_subject = self.commit_subject.replace('\n', '')
-
- pfx_start = self.commit_subject.find('[')
- if pfx_start >= 0:
- pfx_end = self.commit_subject.find(']')
- if pfx_end > pfx_start:
- self.commit_prefix = self.commit_subject[pfx_start + 1 : pfx_end]
- self.commit_subject = self.commit_subject[pfx_end + 1 :].lstrip()
-
-
- class CheckGitCommits:
- """Reads patches from git based on the specified git revision range.
-
- The patches are read from git, and then checked.
- """
-
- def __init__(self, rev_spec, max_count):
- commits = self.read_commit_list_from_git(rev_spec, max_count)
- if len(commits) == 1 and Verbose.level > Verbose.ONELINE:
- commits = [ rev_spec ]
- self.ok = True
- blank_line = False
- for commit in commits:
- if Verbose.level > Verbose.ONELINE:
- if blank_line:
- print()
- else:
- blank_line = True
- print('Checking git commit:', commit)
- patch = self.read_patch_from_git(commit)
- self.ok &= CheckOnePatch(commit, patch).ok
-
- def read_commit_list_from_git(self, rev_spec, max_count):
- # Run git to get the commit patch
- cmd = [ 'rev-list', '--abbrev-commit', '--no-walk' ]
- if max_count is not None:
- cmd.append('--max-count=' + str(max_count))
- cmd.append(rev_spec)
- out = self.run_git(*cmd)
- return out.split()
-
- def read_patch_from_git(self, commit):
- # Run git to get the commit patch
- return self.run_git('show', '--pretty=email', commit)
-
- def run_git(self, *args):
- cmd = [ 'git' ]
- cmd += args
- p = subprocess.Popen(cmd,
- stdout=subprocess.PIPE,
- stderr=subprocess.STDOUT)
- return p.communicate()[0].decode('utf-8', 'ignore')
-
- class CheckOnePatchFile:
- """Performs a patch check for a single file.
-
- stdin is used when the filename is '-'.
- """
-
- def __init__(self, patch_filename):
- if patch_filename == '-':
- patch = sys.stdin.read()
- patch_filename = 'stdin'
- else:
- f = open(patch_filename, 'rb')
- patch = f.read().decode('utf-8', 'ignore')
- f.close()
- if Verbose.level > Verbose.ONELINE:
- print('Checking patch file:', patch_filename)
- self.ok = CheckOnePatch(patch_filename, patch).ok
-
- class CheckOneArg:
- """Performs a patch check for a single command line argument.
-
- The argument will be handed off to a file or git-commit based
- checker.
- """
-
- def __init__(self, param, max_count=None):
- self.ok = True
- if param == '-' or os.path.exists(param):
- checker = CheckOnePatchFile(param)
- else:
- checker = CheckGitCommits(param, max_count)
- self.ok = checker.ok
-
- class PatchCheckApp:
- """Checks patches based on the command line arguments."""
-
- def __init__(self):
- self.parse_options()
- patches = self.args.patches
-
- if len(patches) == 0:
- patches = [ 'HEAD' ]
-
- self.ok = True
- self.count = None
- for patch in patches:
- self.process_one_arg(patch)
-
- if self.count is not None:
- self.process_one_arg('HEAD')
-
- if self.ok:
- self.retval = 0
- else:
- self.retval = -1
-
- def process_one_arg(self, arg):
- if len(arg) >= 2 and arg[0] == '-':
- try:
- self.count = int(arg[1:])
- return
- except ValueError:
- pass
- self.ok &= CheckOneArg(arg, self.count).ok
- self.count = None
-
- def parse_options(self):
- parser = argparse.ArgumentParser(description=__copyright__)
- parser.add_argument('--version', action='version',
- version='%(prog)s ' + VersionNumber)
- parser.add_argument('patches', nargs='*',
- help='[patch file | git rev list]')
- group = parser.add_mutually_exclusive_group()
- group.add_argument("--oneline",
- action="store_true",
- help="Print one result per line")
- group.add_argument("--silent",
- action="store_true",
- help="Print nothing")
- self.args = parser.parse_args()
- if self.args.oneline:
- Verbose.level = Verbose.ONELINE
- if self.args.silent:
- Verbose.level = Verbose.SILENT
-
- if __name__ == "__main__":
- sys.exit(PatchCheckApp().retval)