mirror of
https://github.com/OpenAMP/libmetal.git
synced 2026-09-28 15:53:47 +08:00
Enable the use of codespell tool in checkpatch.pl For this the checkpatch.pl as been partially rebased on the Linux one. Signed-off-by: Arnaud Pouliquen <arnaud.pouliquen@foss.st.com>
464 lines
15 KiB
Python
Executable File
464 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
# Copyright (c) 2018,2020 Intel Corporation
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
import collections
|
|
import sys
|
|
import subprocess
|
|
import re
|
|
import os
|
|
from email.utils import parseaddr
|
|
import logging
|
|
import argparse
|
|
from junitparser import TestCase, TestSuite, JUnitXml, Skipped, Error, Failure, Attr
|
|
import tempfile
|
|
import traceback
|
|
from pathlib import Path
|
|
|
|
# '*' makes it italic
|
|
EDIT_TIP = "\n\n*Tip: The bot edits this comment instead of posting a new " \
|
|
"one, so you can check the comment's history to see earlier " \
|
|
"messages.*"
|
|
|
|
logger = None
|
|
|
|
|
|
def git(*args, cwd=None):
|
|
# Helper for running a Git command. Returns the rstrip()ed stdout output.
|
|
# Called like git("diff"). Exits with SystemError (raised by sys.exit()) on
|
|
# errors. 'cwd' is the working directory to use (default: current
|
|
# directory).
|
|
|
|
git_cmd = ("git",) + args
|
|
try:
|
|
git_process = subprocess.Popen(
|
|
git_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd)
|
|
except OSError as e:
|
|
err(f"failed to run '{cmd2str(git_cmd)}': {e}")
|
|
|
|
stdout, stderr = git_process.communicate()
|
|
stdout = stdout.decode("utf-8")
|
|
stderr = stderr.decode("utf-8")
|
|
if git_process.returncode or stderr:
|
|
err(f"""\
|
|
'{cmd2str(git_cmd)}' exited with status {git_process.returncode} and/or wrote
|
|
to stderr.
|
|
|
|
==stdout==
|
|
{stdout}
|
|
==stderr==
|
|
{stderr}""")
|
|
|
|
return stdout.rstrip()
|
|
|
|
|
|
def get_shas(refspec):
|
|
"""
|
|
Returns the list of Git SHAs for 'refspec'.
|
|
|
|
:param refspec:
|
|
:return:
|
|
"""
|
|
return git('rev-list',
|
|
'--max-count={}'.format(-1 if "." in refspec else 1),
|
|
refspec).split()
|
|
|
|
|
|
class MyCase(TestCase):
|
|
"""
|
|
Custom junitparser.TestCase for our tests that adds some extra <testcase>
|
|
XML attributes. These will be preserved when tests are saved and loaded.
|
|
"""
|
|
classname = Attr()
|
|
# Remembers informational messages. These can appear on successful tests
|
|
# too, where TestCase.result isn't set.
|
|
info_msg = Attr()
|
|
|
|
|
|
class ComplianceTest:
|
|
"""
|
|
Base class for tests. Inheriting classes should have a run() method and set
|
|
these class variables:
|
|
|
|
name:
|
|
Test name
|
|
|
|
path_hint:
|
|
The path the test runs itself in. This is just informative and used in
|
|
the message that gets printed when running the test.
|
|
|
|
The magic string "<git-top>" refers to the top-level repository
|
|
directory. This avoids running 'git' to find the top-level directory
|
|
before main() runs (class variable assignments run when the 'class ...'
|
|
statement runs). That avoids swallowing errors, because main() reports
|
|
them to GitHub.
|
|
"""
|
|
def __init__(self):
|
|
self.case = MyCase(self.name)
|
|
self.case.classname = "Guidelines"
|
|
|
|
def error(self, msg):
|
|
"""
|
|
Signals a problem with running the test, with message 'msg'.
|
|
|
|
Raises an exception internally, so you do not need to put a 'return'
|
|
after error().
|
|
|
|
Any failures generated prior to the error() are included automatically
|
|
in the message. Usually, any failures would indicate problems with the
|
|
test code.
|
|
"""
|
|
if self.case.result:
|
|
msg += "\n\nFailures before error: " + self.case.result._elem.text
|
|
|
|
self.case.result = Error(msg, "error")
|
|
|
|
raise EndTest
|
|
|
|
def skip(self, msg):
|
|
"""
|
|
Signals that the test should be skipped, with message 'msg'.
|
|
|
|
Raises an exception internally, so you do not need to put a 'return'
|
|
after skip().
|
|
|
|
Any failures generated prior to the skip() are included automatically
|
|
in the message. Usually, any failures would indicate problems with the
|
|
test code.
|
|
"""
|
|
if self.case.result:
|
|
msg += "\n\nFailures before skip: " + self.case.result._elem.text
|
|
|
|
self.case.result = Skipped(msg, "skipped")
|
|
|
|
raise EndTest
|
|
|
|
def add_failure(self, msg):
|
|
"""
|
|
Signals that the test failed, with message 'msg'. Can be called many
|
|
times within the same test to report multiple failures.
|
|
"""
|
|
if not self.case.result:
|
|
# First reported failure
|
|
self.case.result = Failure(self.name + " issues", "failure")
|
|
self.case.result._elem.text = msg.rstrip()
|
|
else:
|
|
# If there are multiple Failures, concatenate their messages
|
|
self.case.result._elem.text += "\n\n" + msg.rstrip()
|
|
|
|
def add_info(self, msg):
|
|
"""
|
|
Adds an informational message without failing the test. The message is
|
|
shown on GitHub, and is shown regardless of whether the test passes or
|
|
fails. If the test fails, then both the informational message and the
|
|
failure message are shown.
|
|
|
|
Can be called many times within the same test to add multiple messages.
|
|
"""
|
|
def escape(s):
|
|
# Hack to preserve e.g. newlines and tabs in the attribute when
|
|
# tests are saved to .xml and reloaded. junitparser doesn't seem to
|
|
# handle it correctly, though it does escape stuff like quotes.
|
|
# unicode-escape replaces newlines with \n (two characters), etc.
|
|
return s.encode("unicode-escape").decode("utf-8")
|
|
|
|
if not self.case.info_msg:
|
|
self.case.info_msg = escape(msg)
|
|
else:
|
|
self.case.info_msg += r"\n\n" + escape(msg)
|
|
|
|
class EndTest(Exception):
|
|
"""
|
|
Raised by ComplianceTest.error()/skip() to end the test.
|
|
|
|
Tests can raise EndTest themselves to immediately end the test, e.g. from
|
|
within a nested function call.
|
|
"""
|
|
|
|
class CheckPatch(ComplianceTest):
|
|
"""
|
|
Runs checkpatch and reports found issues
|
|
|
|
"""
|
|
name = "checkpatch"
|
|
path_hint = "<git-top>"
|
|
|
|
def run(self):
|
|
checkpatch = os.path.join(GIT_TOP, 'scripts',
|
|
'checkpatch.pl')
|
|
if not os.path.exists(checkpatch):
|
|
self.skip(checkpatch + " not found")
|
|
|
|
# git diff's output doesn't depend on the current (sub)directory
|
|
diff = subprocess.Popen(('git', 'diff', COMMIT_RANGE),
|
|
stdout=subprocess.PIPE)
|
|
try:
|
|
subprocess.check_output(checkpatch + ' --mailback' + ' --codespell' +
|
|
' --no-tree' + ' -',
|
|
stdin=diff.stdout,
|
|
stderr=subprocess.STDOUT,
|
|
shell=True, cwd=GIT_TOP)
|
|
|
|
except subprocess.CalledProcessError as ex:
|
|
output = ex.output.decode("utf-8")
|
|
self.add_failure(output)
|
|
|
|
|
|
class GitLint(ComplianceTest):
|
|
"""
|
|
Runs gitlint on the commits and finds issues with style and syntax
|
|
|
|
"""
|
|
name = "Gitlint"
|
|
path_hint = "<git-top>"
|
|
|
|
def run(self):
|
|
# By default gitlint looks for .gitlint configuration only in
|
|
# the current directory
|
|
proc = subprocess.Popen('gitlint --commits ' + COMMIT_RANGE,
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
shell=True, cwd=GIT_TOP)
|
|
|
|
msg = ""
|
|
if proc.wait() != 0:
|
|
msg = proc.stdout.read()
|
|
|
|
if msg != "":
|
|
self.add_failure(msg.decode("utf-8"))
|
|
|
|
|
|
|
|
|
|
class Identity(ComplianceTest):
|
|
"""
|
|
Checks if Emails of author and signed-off messages are consistent.
|
|
"""
|
|
name = "Identity"
|
|
# git rev-list and git log don't depend on the current (sub)directory
|
|
# unless explicit
|
|
path_hint = "<git-top>"
|
|
|
|
def run(self):
|
|
for shaidx in get_shas(COMMIT_RANGE):
|
|
commit = git("log", "--decorate=short", "-n 1", shaidx)
|
|
signed = []
|
|
author = ""
|
|
sha = ""
|
|
parsed_addr = None
|
|
for line in commit.split("\n"):
|
|
match = re.search(r"^commit\s([^\s]*)", line)
|
|
if match:
|
|
sha = match.group(1)
|
|
match = re.search(r"^Author:\s(.*)", line)
|
|
if match:
|
|
author = match.group(1)
|
|
parsed_addr = parseaddr(author)
|
|
match = re.search(r"signed-off-by:\s(.*)", line, re.IGNORECASE)
|
|
if match:
|
|
signed.append(match.group(1))
|
|
|
|
error1 = "%s: author email (%s) needs to match one of the signed-off-by entries." % (
|
|
sha, author)
|
|
error2 = "%s: author email (%s) does not follow the syntax: First Last <email>." % (
|
|
sha, author)
|
|
error3 = "%s: author email (%s) must be a real email and cannot end in @users.noreply.github.com" % (
|
|
sha, author)
|
|
failure = None
|
|
if author not in signed:
|
|
failure = error1
|
|
|
|
if not parsed_addr or len(parsed_addr[0].split(" ")) < 2:
|
|
if not failure:
|
|
|
|
failure = error2
|
|
else:
|
|
failure = failure + "\n" + error2
|
|
elif parsed_addr[1].endswith("@users.noreply.github.com"):
|
|
failure = error3
|
|
|
|
if failure:
|
|
self.add_failure(failure)
|
|
|
|
|
|
def init_logs(cli_arg):
|
|
# Initializes logging
|
|
|
|
# TODO: there may be a shorter version thanks to:
|
|
# logging.basicConfig(...)
|
|
|
|
global logger
|
|
|
|
level = os.environ.get('LOG_LEVEL', "WARN")
|
|
|
|
console = logging.StreamHandler()
|
|
console.setFormatter(logging.Formatter('%(levelname)-8s: %(message)s'))
|
|
|
|
logger = logging.getLogger('')
|
|
logger.addHandler(console)
|
|
logger.setLevel(cli_arg if cli_arg else level)
|
|
|
|
logging.info("Log init completed, level=%s",
|
|
logging.getLevelName(logger.getEffectiveLevel()))
|
|
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(
|
|
description="Check for coding style and documentation warnings.")
|
|
parser.add_argument('-c', '--commits', default="HEAD~1..",
|
|
help='''Commit range in the form: a..[b], default is
|
|
HEAD~1..HEAD''')
|
|
parser.add_argument('-r', '--repo', default=None,
|
|
help="GitHub repository")
|
|
parser.add_argument('-p', '--pull-request', default=0, type=int,
|
|
help="Pull request number")
|
|
parser.add_argument('-S', '--sha', default=None, help="Commit SHA")
|
|
parser.add_argument('-o', '--output', default="compliance.xml",
|
|
help='''Name of outfile in JUnit format,
|
|
default is ./compliance.xml''')
|
|
|
|
parser.add_argument('-l', '--list', action="store_true",
|
|
help="List all checks and exit")
|
|
|
|
parser.add_argument("-v", "--loglevel", help="python logging level")
|
|
|
|
parser.add_argument('-m', '--module', action="append", default=[],
|
|
help="Checks to run. All checks by default.")
|
|
|
|
parser.add_argument('-e', '--exclude-module', action="append", default=[],
|
|
help="Do not run the specified checks")
|
|
|
|
parser.add_argument('-j', '--previous-run', default=None,
|
|
help='''Pre-load JUnit results in XML format
|
|
from a previous run and combine with new results.''')
|
|
|
|
return parser.parse_args()
|
|
|
|
def _main(args):
|
|
# The "real" main(), which is wrapped to catch exceptions and report them
|
|
# to GitHub. Returns the number of test failures.
|
|
|
|
# The absolute path of the top-level git directory. Initialize it here so
|
|
# that issues running Git can be reported to GitHub.
|
|
global GIT_TOP
|
|
GIT_TOP = git("rev-parse", "--show-toplevel")
|
|
|
|
# The commit range passed in --commit, e.g. "HEAD~3"
|
|
global COMMIT_RANGE
|
|
COMMIT_RANGE = args.commits
|
|
|
|
init_logs(args.loglevel)
|
|
|
|
if args.list:
|
|
for testcase in ComplianceTest.__subclasses__():
|
|
print(testcase.name)
|
|
return 0
|
|
|
|
# Load saved test results from an earlier run, if requested
|
|
if args.previous_run:
|
|
if not os.path.exists(args.previous_run):
|
|
# This probably means that an earlier pass had an internal error
|
|
# (the script is currently run multiple times by the ci-pipelines
|
|
# repo). Since that earlier pass might've posted an error to
|
|
# GitHub, avoid generating a GitHub comment here, by avoiding
|
|
# sys.exit() (which gets caught in main()).
|
|
print("error: '{}' not found".format(args.previous_run),
|
|
file=sys.stderr)
|
|
return 1
|
|
|
|
logging.info("Loading previous results from " + args.previous_run)
|
|
for loaded_suite in JUnitXml.fromfile(args.previous_run):
|
|
suite = loaded_suite
|
|
break
|
|
else:
|
|
suite = TestSuite("Compliance")
|
|
|
|
for testcase in ComplianceTest.__subclasses__():
|
|
# "Modules" and "testcases" are the same thing. Better flags would have
|
|
# been --tests and --exclude-tests or the like, but it's awkward to
|
|
# change now.
|
|
|
|
if args.module and testcase.name not in args.module:
|
|
continue
|
|
|
|
if testcase.name in args.exclude_module:
|
|
print("Skipping " + testcase.name)
|
|
continue
|
|
|
|
test = testcase()
|
|
try:
|
|
print(f"Running {test.name:16} tests in "
|
|
f"{GIT_TOP if test.path_hint == '<git-top>' else test.path_hint} ...")
|
|
test.run()
|
|
except EndTest:
|
|
pass
|
|
|
|
suite.add_testcase(test.case)
|
|
|
|
xml = JUnitXml()
|
|
xml.add_testsuite(suite)
|
|
xml.update_statistics()
|
|
xml.write(args.output, pretty=True)
|
|
|
|
failed_cases = []
|
|
|
|
for case in suite:
|
|
if case.result:
|
|
if case.result.type == 'skipped':
|
|
logging.warning("Skipped %s, %s", case.name, case.result.message)
|
|
else:
|
|
failed_cases.append(case)
|
|
else:
|
|
# Some checks like codeowners can produce no .result
|
|
logging.info("No JUnit result for %s", case.name)
|
|
|
|
n_fails = len(failed_cases)
|
|
|
|
if n_fails:
|
|
print("{} checks failed".format(n_fails))
|
|
for case in failed_cases:
|
|
# not clear why junitxml doesn't clearly expose the most
|
|
# important part of its underlying etree.Element
|
|
errmsg = case.result._elem.text
|
|
logging.error("Test %s failed: %s", case.name,
|
|
errmsg.strip() if errmsg else case.result.message)
|
|
|
|
with open(f"{case.name}.txt", "w") as f:
|
|
f.write(errmsg.strip() if errmsg else case.result.message)
|
|
|
|
print("\nComplete results in " + args.output)
|
|
return n_fails
|
|
|
|
def main():
|
|
args = parse_args()
|
|
|
|
try:
|
|
n_fails = _main(args)
|
|
except BaseException:
|
|
# Catch BaseException instead of Exception to include stuff like
|
|
# SystemExit (raised by sys.exit())
|
|
print(format(__file__, traceback.format_exc()))
|
|
|
|
raise
|
|
|
|
sys.exit(n_fails)
|
|
|
|
|
|
def cmd2str(cmd):
|
|
# Formats the command-line arguments in the iterable 'cmd' into a string,
|
|
# for error messages and the like
|
|
|
|
return " ".join(shlex.quote(word) for word in cmd)
|
|
|
|
|
|
def err(msg):
|
|
cmd = sys.argv[0] # Empty if missing
|
|
if cmd:
|
|
cmd += ": "
|
|
sys.exit(cmd + "error: " + msg)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|