blob: bc586f4002a9b6a27b2764928a87a81d09119856 [file] [log] [blame]
# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Run lint checks on the specified files."""
import fnmatch
import functools
import itertools
import json
import logging
import os
from pathlib import Path
import re
from typing import Union
from chromite.cli import command
from chromite.lib import commandline
from chromite.lib import constants
from chromite.lib import cros_build_lib
from chromite.lib import git
from chromite.lib import osutils
from chromite.lib import parallel
from chromite.lint.linters import owners
from chromite.lint.linters import upstart
from chromite.lint.linters import whitespace
from chromite.utils import timer
# Extract a script's shebang.
SHEBANG_RE = re.compile(br'^#!\s*([^\s]+)(\s+([^\s]+))?')
def _GetProjectPath(path: Path) -> Path:
"""Find the absolute path of the git checkout that contains |path|."""
ret = git.FindGitTopLevel(path)
if ret:
return Path(ret)
else:
# Maybe they're running on a file outside of a checkout.
# e.g. cros lint ~/foo.py /tmp/test.py
return path.parent
def _GetPylintrc(path: Union[str, os.PathLike]) -> Path:
"""Locate pylintrc or .pylintrc file that applies to |path|.
If not found - use the default.
"""
def _test_func(pylintrc):
dotpylintrc = pylintrc.with_name('.pylintrc')
# Only allow one of these to exist to avoid confusing which one is used.
if pylintrc.exists() and dotpylintrc.exists():
cros_build_lib.Die('%s: Only one of "pylintrc" or ".pylintrc" is allowed',
pylintrc.parent)
return pylintrc.exists() or dotpylintrc.exists()
path = Path(path)
end_path = _GetProjectPath(path.parent).parent
ret = osutils.FindInPathParents(
'pylintrc', path.parent, test_func=_test_func, end_path=end_path)
if ret:
return ret if ret.exists() else ret.with_name('.pylintrc')
return Path(constants.CHROMITE_DIR) / 'pylintrc'
def _GetPylintGroups(paths):
"""Return a dictionary mapping pylintrc files to lists of paths."""
groups = {}
for path in paths:
pylintrc = _GetPylintrc(path)
if pylintrc:
groups.setdefault(pylintrc, []).append(path)
return groups
def _GetIsortCfg(path: Union[str, os.PathLike]) -> Path:
"""Locate isort.cfg file that applies to |path|.
If not found - use the default.
"""
path = Path(path)
end_path = _GetProjectPath(path.parent).parent
ret = osutils.FindInPathParents('.isort.cfg', path.parent, end_path=end_path)
return ret if ret else Path(constants.CHROMITE_DIR) / '.isort.cfg'
def _GetPythonPath():
"""Return the set of Python library paths to use."""
# Carry through custom PYTHONPATH that the host env has set.
return os.environ.get('PYTHONPATH', '').split(os.pathsep) + [
# Ideally we'd modify meta_path in pylint to handle our virtual chromite
# module, but that's not possible currently. We'll have to deal with
# that at some point if we want `cros lint` to work when the dir is not
# named 'chromite'.
constants.SOURCE_ROOT,
]
# The mapping between the "cros lint" --output-format flag and cpplint.py
# --output flag.
CPPLINT_OUTPUT_FORMAT_MAP = {
'colorized': 'emacs',
'msvs': 'vs7',
'parseable': 'emacs',
}
# Default category filters to pass to cpplint.py when invoked via `cros lint`.
#
# `-foo/bar` means "don't show any lints from category foo/bar".
# See `cpplint.py --help` for more explanation of category filters.
CPPLINT_DEFAULT_FILTERS = (
'-runtime/references',
)
# The mapping between the "cros lint" --output-format flag and shellcheck
# flags.
# Note that the msvs mapping here isn't quite VS format, but it's closer than
# the default output.
SHLINT_OUTPUT_FORMAT_MAP = {
'colorized': ['--color=always'],
'msvs': ['--format=gcc'],
'parseable': ['--format=gcc'],
}
def _ToolRunCommand(cmd, debug, **kwargs):
"""Run the linter with common run args set as higher levels expect."""
return cros_build_lib.run(cmd, check=False, print_cmd=debug,
debug_level=logging.NOTICE, **kwargs)
def _ConfLintFile(path, output_format, debug, relaxed: bool):
"""Determine the applicable .conf syntax and call the appropriate handler."""
ret = cros_build_lib.CompletedProcess(f'cros lint "{path}"', returncode=0)
if not os.path.isfile(path):
return ret
# .conf files are used by more than upstart, so use the parent dirname
# to filter them.
parent_name = os.path.basename(os.path.dirname(os.path.realpath(path)))
if parent_name in {'init', 'upstart'}:
return _UpstartLintFile(path, output_format, debug, relaxed)
# Check for the description and author lines present in upstart configs.
with open(path, 'rb') as file:
tokens_to_find = {b'author', b'description'}
for line in file:
try:
token = line.split()[0]
except IndexError:
continue
try:
tokens_to_find.remove(token)
except KeyError:
continue
if not tokens_to_find:
logging.warning(
'Found upstart .conf in a directory other than init or upstart.')
return _UpstartLintFile(path, output_format, debug, relaxed)
return ret
def _CpplintFile(path, output_format, debug, _relaxed: bool):
"""Returns result of running cpplint on |path|."""
cmd = [os.path.join(constants.DEPOT_TOOLS_DIR, 'cpplint.py')]
cmd.append('--filter=%s' % ','.join(CPPLINT_DEFAULT_FILTERS))
if output_format != 'default':
cmd.append('--output=%s' % CPPLINT_OUTPUT_FORMAT_MAP[output_format])
cmd.append(path)
return _ToolRunCommand(cmd, debug)
def _PylintFile(path, output_format, debug, _relaxed: bool):
"""Returns result of running pylint on |path|."""
pylint = os.path.join(constants.CHROMITE_SCRIPTS_DIR, 'pylint')
pylintrc = _GetPylintrc(path)
cmd = [pylint, '--rcfile=%s' % pylintrc]
if output_format != 'default':
cmd.append('--output-format=%s' % output_format)
cmd.append(path)
extra_env = {
'PYTHONPATH': ':'.join(_GetPythonPath()),
}
return _ToolRunCommand(cmd, debug, extra_env=extra_env)
def _PyisortFile(path, _output_format, debug, _relaxed: bool):
"""Returns result of running isort on |path|."""
isort = os.path.join(constants.CHROMITE_SCRIPTS_DIR, 'isort')
cfg = _GetIsortCfg(path)
base_cmd = [isort, f'--settings-file={cfg}']
cmd = base_cmd + ['--diff', '--check']
cmd.append(path)
base_cmd.append(path)
result = _ToolRunCommand(cmd, debug)
if result.returncode:
logging.notice('To fix, run:\n%s', cros_build_lib.CmdToStr(base_cmd))
return result
def _GolintFile(path, _, debug, _relaxed: bool):
"""Returns result of running golint on |path|."""
# Try using golint if it exists.
try:
cmd = ['golint', '-set_exit_status', path]
return _ToolRunCommand(cmd, debug)
except cros_build_lib.RunCommandError:
logging.notice('Install golint for additional go linting.')
return cros_build_lib.CompletedProcess(f'gofmt "{path}"', returncode=0)
def _JsonLintFile(path, _output_format, _debug, _relaxed: bool):
"""Returns result of running json lint checks on |path|."""
result = cros_build_lib.CompletedProcess(
f'python -mjson.tool "{path}"', returncode=0)
data = osutils.ReadFile(path)
# Strip off leading UTF-8 BOM if it exists.
if data.startswith(u'\ufeff'):
data = data[1:]
# Strip out comments for JSON parsing.
stripped_data = re.sub(r'^\s*#.*', '', data, flags=re.M)
# See if it validates.
try:
json.loads(stripped_data)
except ValueError as e:
result.returncode = 1
logging.notice('%s: %s', path, e)
# Check whitespace.
if not whitespace.LintData(path, data):
result.returncode = 1
return result
def _MarkdownLintFile(path, _output_format, _debug, _relaxed: bool):
"""Returns result of running lint checks on |path|."""
result = cros_build_lib.CompletedProcess(
f'mdlint(internal) "{path}"', returncode=0)
data = osutils.ReadFile(path)
# Check whitespace.
if not whitespace.LintData(path, data):
result.returncode = 1
return result
def _ShellLintFile(path, output_format, debug, _relaxed: bool,
gentoo_format=False):
"""Returns result of running lint checks on |path|.
Args:
path: The path to the script on which to run the linter.
output_format: The format of the output that the linter should emit. See
|SHLINT_OUTPUT_FORMAT_MAP|.
debug: Whether to print out the linter command.
gentoo_format: Whether to treat this file as an ebuild style script.
Returns:
A CompletedProcess object.
"""
# TODO: Try using `checkbashisms`.
syntax_check = _ToolRunCommand(['bash', '-n', path], debug)
if syntax_check.returncode != 0:
return syntax_check
# Try using shellcheck if it exists, with a preference towards finding it
# inside the chroot. This is OK as it is statically linked.
shellcheck = (
osutils.Which('shellcheck', path='/usr/bin',
root=os.path.join(constants.SOURCE_ROOT, 'chroot'))
or osutils.Which('shellcheck'))
if not shellcheck:
logging.notice('Install shellcheck for additional shell linting.')
return syntax_check
# Instruct shellcheck to run itself from the shell script's dir. Note that
# 'SCRIPTDIR' is a special string that shellcheck rewrites to the dirname of
# the given path.
extra_checks = [
'avoid-nullary-conditions', # SC2244
'check-unassigned-uppercase', # Include uppercase in SC2154
'require-variable-braces', # SC2250
]
if not gentoo_format:
extra_checks.append('quote-safe-variables') # SC2248
cmd = [shellcheck, '--source-path=SCRIPTDIR',
'--enable=%s' % ','.join(extra_checks)]
if output_format != 'default':
cmd.extend(SHLINT_OUTPUT_FORMAT_MAP[output_format])
cmd.append('-x')
# No warning for using local with /bin/sh.
cmd.append('--exclude=SC3043')
if gentoo_format:
# ebuilds don't explicitly export variables or contain a shebang.
cmd.append('--exclude=SC2148')
# ebuilds always use bash.
cmd.append('--shell=bash')
cmd.append(path)
lint_result = _ToolRunCommand(cmd, debug)
# Check whitespace.
if not whitespace.LintData(path, osutils.ReadFile(path)):
lint_result.returncode = 1
return lint_result
def _GentooShellLintFile(path, output_format, debug, relaxed: bool):
"""Run shell checks with Gentoo rules."""
return _ShellLintFile(path, output_format, debug, relaxed,
gentoo_format=True)
def _SeccompPolicyLintFile(path, _output_format, debug, _relaxed: bool):
"""Run the seccomp policy linter."""
dangerous_syscalls = {'bpf', 'setns', 'execveat', 'ptrace', 'swapoff',
'swapon'}
return _ToolRunCommand(
[os.path.join(constants.SOURCE_ROOT, 'src', 'aosp', 'external',
'minijail', 'tools', 'seccomp_policy_lint.py'),
'--dangerous-syscalls', ','.join(dangerous_syscalls),
path],
debug)
def _UpstartLintFile(path, _output_format, _debug, relaxed: bool):
"""Run lints on upstart configs."""
# Skip .conf files that aren't in an init parent directory.
ret = cros_build_lib.CompletedProcess(f'cros lint "{path}"', returncode=0)
if not upstart.CheckInitConf(Path(path), relaxed):
ret.returncode = 1
return ret
def _DirMdLintFile(path, _output_format, debug, _relaxed: bool):
"""Run the dirmd linter."""
return _ToolRunCommand(
[os.path.join(constants.DEPOT_TOOLS_DIR, 'dirmd'), 'validate', path],
debug, capture_output=not debug)
def _OwnersLintFile(path, _output_format, _debug, _relaxed: bool):
"""Run lints on OWNERS files."""
ret = cros_build_lib.CompletedProcess(f'cros lint "{path}"', returncode=0)
if not owners.lint_path(Path(path)):
ret.returncode = 1
return ret
def _WhitespaceLintFile(path, _output_format, _debug, _relaxed: bool):
"""Returns result of running basic whitespace checks on |path|."""
result = cros_build_lib.CompletedProcess(
f'whitespace(internal) "{path}"', returncode=0)
data = osutils.ReadFile(path)
# Check whitespace.
if not whitespace.LintData(path, data):
result.returncode = 1
return result
def _BreakoutDataByTool(map_to_return, path):
"""Maps a tool method to the content of the |path|."""
# Detect by content of the file itself.
try:
with open(path, 'rb') as fp:
# We read 128 bytes because that's the Linux kernel's current limit.
# Look for BINPRM_BUF_SIZE in fs/binfmt_script.c.
data = fp.read(128)
if not data.startswith(b'#!'):
# If the file doesn't have a shebang, nothing to do.
return
m = SHEBANG_RE.match(data)
if m:
prog = m.group(1)
if prog == b'/usr/bin/env':
prog = m.group(3)
basename = os.path.basename(prog)
if basename.startswith(b'python') or basename.startswith(b'vpython'):
for tool in _EXT_TOOL_MAP[frozenset({'.py'})]:
map_to_return.setdefault(tool, []).append(path)
elif basename in (b'sh', b'dash', b'bash'):
for tool in _EXT_TOOL_MAP[frozenset({'.sh'})]:
map_to_return.setdefault(tool, []).append(path)
except IOError as e:
logging.debug('%s: reading initial data failed: %s', path, e)
# Map file extensions to a tool function.
_EXT_TOOL_MAP = {
# Note these are defined to keep in line with cpplint.py. Technically, we
# could include additional ones, but cpplint.py would just filter them out.
frozenset({'.cc', '.cpp', '.h'}): (_CpplintFile,),
frozenset({'.conf', '.conf.in'}): (_ConfLintFile,),
frozenset({'.json'}): (_JsonLintFile,),
frozenset({'.py'}): (_PylintFile, _PyisortFile),
frozenset({'.go'}): (_GolintFile,),
frozenset({'.sh'}): (_ShellLintFile,),
frozenset({'.ebuild', '.eclass', '.bashrc'}): (_GentooShellLintFile,),
frozenset({'.md'}): (_MarkdownLintFile,),
frozenset({'.policy'}): (_SeccompPolicyLintFile, _WhitespaceLintFile),
frozenset({'.te'}): (_WhitespaceLintFile,),
}
# Map known filenames to a tool function.
_FILENAME_PATTERNS_TOOL_MAP = {
frozenset({'DIR_METADATA'}): (_DirMdLintFile,),
frozenset({'OWNERS*'}): (_OwnersLintFile,),
}
def _BreakoutFilesByTool(files):
"""Maps a tool method to the list of files to process."""
map_to_return = {}
for f in files:
extension = os.path.splitext(f)[1]
for extensions, tools in _EXT_TOOL_MAP.items():
if extension in extensions:
for tool in tools:
map_to_return.setdefault(tool, []).append(f)
break
else:
name = os.path.basename(f)
for patterns, tools in _FILENAME_PATTERNS_TOOL_MAP.items():
if any(fnmatch.fnmatch(name, x) for x in patterns):
for tool in tools:
map_to_return.setdefault(tool, []).append(f)
break
else:
if os.path.isfile(f):
_BreakoutDataByTool(map_to_return, f)
return map_to_return
def _Dispatcher(output_format, debug, relaxed: bool, tool, path):
"""Call |tool| on |path| and take care of coalescing exit codes/output."""
result = tool(path, output_format, debug, relaxed)
return 1 if result.returncode else 0
@command.command_decorator('lint')
class LintCommand(command.CliCommand):
"""Run lint checks on the specified files."""
EPILOG = """
Supported file formats: %s
Supported file names: %s
""" % (' '.join(sorted(itertools.chain(*_EXT_TOOL_MAP))),
' '.join(sorted(itertools.chain(*_FILENAME_PATTERNS_TOOL_MAP))))
# The output formats supported by cros lint.
OUTPUT_FORMATS = ('default', 'colorized', 'msvs', 'parseable')
@classmethod
def AddParser(cls, parser: commandline.ArgumentParser):
super().AddParser(parser)
parser.add_argument('files', help='Files to lint', nargs='*')
parser.add_argument('--output', default='default',
choices=LintCommand.OUTPUT_FORMATS,
help='Output format to pass to the linters. Supported '
'formats are: default (no option is passed to the '
'linter), colorized, msvs (Visual Studio) and '
'parseable.')
parser.add_argument('--relaxed', default=False, action='store_true',
help='Disable some strict checks. This is used for '
'cases like builds where a more permissive '
'behavior is desired.')
def _Run(self):
files = self.options.files
if not files:
# Running with no arguments is allowed to make the repo upload hook
# simple, but print a warning so that if someone runs this manually
# they are aware that nothing was linted.
logging.warning('No files provided to lint. Doing nothing.')
return 0
# Ignore generated files. Some tools can do this for us, but not all, and
# it'd be faster if we just never spawned the tools in the first place.
files = [x for x in self.options.files if not x.endswith('_pb2.py')]
tool_map = _BreakoutFilesByTool(files)
dispatcher = functools.partial(_Dispatcher,
self.options.output, self.options.debug,
self.options.relaxed)
# If we filtered out all files, do nothing.
# Special case one file (or fewer) as it's common -- faster to avoid the
# parallel startup penalty.
tasks = []
for tool, files in tool_map.items():
tasks.extend([tool, x] for x in files)
if not tasks:
return 0
elif len(tasks) == 1:
tool, files = next(iter(tool_map.items()))
return dispatcher(tool, files[0])
else:
# Run the tool in parallel on the files.
return sum(parallel.RunTasksInProcessPool(dispatcher, tasks))
def Run(self):
with timer.Timer() as t:
ret = self._Run()
if ret:
logging.error('Found lint errors in %i files in %s.', ret, t)
return 1 if ret else 0