blob: 644cabb958a8563cf68320e3145c02c0e0e05083 [file] [log] [blame]
# Copyright 2019 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Tools for running presubmit checks in a Git repository.
Presubmit checks are defined as a function or other callable. The function may
take either no arguments or a list of the paths on which to run. Presubmit
checks communicate failure by raising any exception.
For example, either of these functions may be used as presubmit checks:
@pw_presubmit.filter_paths(endswith='.py')
def file_contains_ni(ctx: PresubmitContext):
for path in ctx.paths:
with open(path) as file:
contents = file.read()
if 'ni' not in contents and 'nee' not in contents:
raise PresumitFailure('Files must say "ni"!', path=path)
def run_the_build():
subprocess.run(['make', 'release'], check=True)
Presubmit checks are provided to the parse_args_and_run_presubmit or
run_presubmit function as a list. For example,
PRESUBMIT_CHECKS = [file_contains_ni, run_the_build]
sys.exit(0 if parse_args_and_run_presubmit(PRESUBMIT_CHECKS) else 1)
Presubmit checks that accept a list of paths may use the filter_paths decorator
to automatically filter the paths list for file types they care about. See the
pragma_once function for an example.
"""
import argparse
from collections import Counter, defaultdict
import contextlib
import dataclasses
import enum
import logging
import re
import os
from pathlib import Path
import shlex
import subprocess
import sys
import time
from typing import Callable, Dict, Iterable, List, NamedTuple, Optional
from typing import Sequence, Tuple, Union
from inspect import signature
_LOG: logging.Logger = logging.getLogger(__name__)
PathOrStr = Union[Path, str]
def plural(items_or_count, singular: str, count_format='') -> str:
"""Returns the singular or plural form of a word based on a count."""
try:
count = len(items_or_count)
except TypeError:
count = items_or_count
num = f'{count:{count_format}}'
if singular.endswith('y'):
return f'{num} {singular[:-1]}{"y" if count == 1 else "ies"}'
if singular.endswith('s'):
return f'{num} {singular}{"" if count == 1 else "es"}'
return f'{num} {singular}{"" if count == 1 else "s"}'
def git_stdout(*args: PathOrStr, repo: PathOrStr = '.') -> str:
return subprocess.run(('git', '-C', repo, *args),
stdout=subprocess.PIPE,
check=True).stdout.decode().strip()
def _git_ls_files(*args: PathOrStr, repo: PathOrStr = '.') -> List[str]:
return [
os.path.abspath(os.path.join(repo, path))
for path in git_stdout('ls-files', '--', *args, repo=repo).split()
]
def git_diff_names(commit: str = 'HEAD',
paths: Sequence[PathOrStr] = (),
repo: PathOrStr = '.') -> List[str]:
"""Returns absolute paths of files changed since the specified commit."""
root = git_repo_path(repo=repo)
return [
os.path.abspath(os.path.join(root, path))
for path in git_stdout('diff',
'--name-only',
'--diff-filter=d',
commit,
'--',
*paths,
repo=repo).split()
]
def list_git_files(
commit: Optional[str] = None,
paths: Sequence[PathOrStr] = (),
exclude: Sequence = (),
repo: PathOrStr = '.',
) -> List[Path]:
"""Lists files with git ls-files or git diff --name-only.
This function may only be called if repo is or is in a Git repository.
"""
if commit:
files = git_diff_names(commit, paths, repo=repo)
else:
files = _git_ls_files(*paths, repo=repo)
return sorted(
set(
Path(path) for path in files
if not any(exp.search(path) for exp in exclude)))
def is_git_repo(path='.') -> bool:
return not subprocess.run(['git', '-C', path, 'rev-parse'],
stderr=subprocess.DEVNULL).returncode
def git_repo_path(*paths, repo: PathOrStr = '.') -> Path:
"""Returns a path relative to a Git repository's root."""
return Path(git_stdout('rev-parse', '--show-toplevel',
repo=repo)).joinpath(*paths)
def _make_color(*codes: int):
start = ''.join(f'\033[{code}m' for code in codes)
return f'{start}{{}}\033[0m'.format if os.name == 'posix' else str
color_red = _make_color(31)
color_bold_red = _make_color(31, 1)
color_black_on_red = _make_color(30, 41)
color_yellow = _make_color(33, 1)
color_green = _make_color(32)
color_black_on_green = _make_color(30, 42)
color_aqua = _make_color(36)
color_bold_white = _make_color(37, 1)
def _make_box(section_alignments: Sequence[str]) -> str:
indices = [i + 1 for i in range(len(section_alignments))]
top_sections = '{2}'.join('{1:{1}^{width%d}}' % i for i in indices)
mid_sections = '{5}'.join('{section%d:%s{width%d}}' %
(i, section_alignments[i - 1], i)
for i in indices)
bot_sections = '{9}'.join('{8:{8}^{width%d}}' % i for i in indices)
return ''.join(['{0}', *top_sections, '{3}\n',
'{4}', *mid_sections, '{6}\n',
'{7}', *bot_sections, '{10}']) # yapf: disable
_DOUBLE = '╔═╦╗║║║╚═╩╝'
_TOP = '┏━┯┓┃│┃┃ │┃'
_BOTTOM = '┃ │┃┃│┃┗━┷┛'
WIDTH = 80
_LEFT = 8
_RIGHT = 11
def _title(msg, style=_DOUBLE) -> str:
msg = f' {msg} '.center(WIDTH - 2)
return _make_box('^').format(*style, section1=msg, width1=len(msg))
def _format_time(time_s: float) -> str:
minutes, seconds = divmod(time_s, 60)
return f' {int(minutes)}:{seconds:04.1f}'
def _box(style, left, middle, right, box=_make_box('><>')) -> str:
return box.format(*style,
section1=left + ('' if left.endswith(' ') else ' '),
width1=_LEFT,
section2=' ' + middle,
width2=WIDTH - _LEFT - _RIGHT - 4,
section3=right + ' ',
width3=_RIGHT)
def file_summary(paths: Sequence[Path]) -> str:
files = Counter(path.suffix or path.name for path in paths)
if not files:
return ''
width = max(len(f) for f in files) + 2
max_count_width = len(str(max(files.values())))
return '\n'.join(
f'{ext:>{width}}: {plural(count, "file", max_count_width)}'
for ext, count in sorted(files.items()))
class PresubmitFailure(Exception):
"""Optional exception to use for presubmit failures."""
def __init__(self, description: str = '', path=None):
super().__init__(f'{path}: {description}' if path else description)
class _Result(enum.Enum):
PASS = 'PASSED' # Check completed successfully.
FAIL = 'FAILED' # Check failed.
CANCEL = 'CANCEL' # Check didn't complete.
def colorized(self, width: int, invert: bool = False) -> str:
if self is _Result.PASS:
color = color_black_on_green if invert else color_green
elif self is _Result.FAIL:
color = color_black_on_red if invert else color_red
elif self is _Result.CANCEL:
color = color_yellow
else:
color = lambda value: value
padding = (width - len(self.value)) // 2 * ' '
return padding + color(self.value) + padding
@dataclasses.dataclass(frozen=True)
class PresubmitContext:
"""Context passed into presubmit checks."""
repository_root: Path
output_directory: Path
paths: Sequence[Path]
class Presubmit:
"""Runs a series of presubmit checks on a list of files."""
def __init__(self, repository_root: Path, output_directory: Path,
paths: Sequence[Path]):
self._repository_root = repository_root
self._output_directory = output_directory
self._paths = paths
def run(self, full_program: Sequence, keep_going: bool = False) -> bool:
"""Executes a series of presubmit checks on the paths."""
program = _apply_filters(full_program, self._paths)
print(_title(f'Presubmit checks for {self._repository_root.name}'))
_LOG.info('Running %d of %s on %s in %s', len(program),
plural(full_program, 'check'), plural(self._paths, 'file'),
self._repository_root)
_LOG.debug('Paths:\n%s', '\n'.join(str(path) for path in self._paths))
print(
file_summary(self._paths)
or color_yellow('No files are being checked!'))
_LOG.debug('Checks:\n%s', '\n'.join(c.name for c, _ in program))
start_time: float = time.time()
passed, failed, skipped = self._execute_checks(program, keep_going)
_log_summary(time.time() - start_time, passed, failed, skipped)
return not failed and not skipped
@contextlib.contextmanager
def _context(self, name: str, paths: Sequence[Path]):
# There are many characters banned from filenames on Windows. To
# simplify things, just strip everything that's not a letter, digit,
# or underscore.
sanitized_name = re.sub(r'[\W_]+', '_', name).lower()
output_directory = self._output_directory.joinpath(sanitized_name)
os.makedirs(output_directory, exist_ok=True)
handler = logging.FileHandler(output_directory.joinpath(f'step.log'),
mode='w')
handler.setLevel(logging.DEBUG)
try:
_LOG.addHandler(handler)
yield PresubmitContext(
repository_root=self._repository_root.absolute(),
output_directory=output_directory.absolute(),
paths=paths,
)
finally:
_LOG.removeHandler(handler)
def _execute_checks(self, program,
keep_going: bool) -> Tuple[int, int, int]:
"""Runs presubmit checks; returns (passed, failed, skipped) lists."""
passed = failed = 0
for i, (check, paths) in enumerate(program, 1):
paths = [self._repository_root.joinpath(p) for p in paths]
with self._context(check.name, paths) as ctx:
result = check.run(ctx, i, len(program))
if result is _Result.PASS:
passed += 1
elif result is _Result.CANCEL:
break
else:
failed += 1
if not keep_going:
break
return passed, failed, len(program) - passed - failed
def _apply_filters(
program: Sequence,
paths: Sequence[Path]) -> List[Tuple['_Check', Sequence[Path]]]:
"""Returns a list of (check, paths_to_check) for checks that should run."""
checks = [c if isinstance(c, _Check) else _Check(c) for c in program]
filter_to_checks: Dict[_PathFilter, List[_Check]] = defaultdict(list)
for check in checks:
filter_to_checks[check.filter].append(check)
check_to_paths = _map_checks_to_paths(filter_to_checks, paths)
return [(c, check_to_paths[c]) for c in checks if c in check_to_paths]
def _map_checks_to_paths(
filter_to_checks: Dict['_PathFilter', List['_Check']],
paths: Sequence[Path]) -> Dict['_Check', Sequence[Path]]:
checks_to_paths: Dict[_Check, Sequence[Path]] = {}
for filt, checks in filter_to_checks.items():
exclude = [re.compile(exp) for exp in filt.exclude]
filtered_paths = tuple(
path for path in paths
if any(str(path).endswith(end)
for end in filt.endswith) and not any(
exp.fullmatch(str(path)) for exp in exclude))
for check in checks:
if filtered_paths or check.always_run:
checks_to_paths[check] = filtered_paths
else:
_LOG.debug('Skipping "%s": no relevant files', check.name)
return checks_to_paths
def _log_summary(time_s: float, passed: int, failed: int,
skipped: int) -> None:
summary = []
if passed:
summary.append(f'{passed} passed')
if failed:
summary.append(f'{failed} failed')
if skipped:
summary.append(f'{skipped} did not run')
summary_str = ', '.join(summary) or 'nothing was done'
if failed or skipped:
result_text = _Result.FAIL.colorized(_LEFT, invert=True)
else:
result_text = _Result.PASS.colorized(_LEFT, invert=True)
print(
_box(_DOUBLE, result_text,
f'{passed + failed + skipped} checks: {summary_str}',
_format_time(time_s)))
def add_path_arguments(parser) -> None:
"""Adds common presubmit check options to an argument parser."""
parser.add_argument(
'paths',
nargs='*',
type=Path,
help=(
'Paths to which to restrict the presubmit checks. '
'Directories are expanded with git ls-files. '
'If --base is provided, all paths are interpreted as Git paths.'))
parser.add_argument(
'-b',
'--base',
metavar='COMMIT',
help=('Git revision against which to diff for changed files. '
'If none is provided, the entire repository is used.'))
parser.add_argument(
'-e',
'--exclude',
metavar='REGULAR_EXPRESSION',
default=[],
action='append',
type=re.compile,
help='Exclude paths matching any of these regular expressions.')
def add_arguments(parser: argparse.ArgumentParser) -> None:
"""Adds common presubmit check options to an argument parser."""
add_path_arguments(parser)
parser.add_argument(
'-r',
'--repository',
default='.',
type=Path,
help=(
'Change to this directory before resolving paths or running the '
'presubmit. Presubmit checks must be run from a Git repository.'))
parser.add_argument('-k',
'--keep-going',
action='store_true',
help='Continue instead of aborting when errors occur.')
def run_presubmit(program: Sequence[Callable],
base: Optional[str] = None,
paths: Sequence[PathOrStr] = (),
exclude: Sequence = (),
repository: PathOrStr = '.',
output_directory: Optional[PathOrStr] = None,
keep_going: bool = False) -> bool:
"""Lists files in the current Git repo and runs a Presubmit with them.
This changes the directory to the root of the Git repository after listing
paths, so all presubmit checks can assume they run from there.
Args:
program: list of presubmit check functions to run
base: optional base Git commit to list files against
paths: optional list of paths to run the presubmit checks against
exclude: regular expressions of paths to exclude from checks
repository: git repository to check
output_directory: where to place output files
keep_going: whether to continue running checks if an error occurs
Returns:
True if all presubmit checks succeeded
"""
if not is_git_repo(repository):
_LOG.critical('Presubmit checks must be run from a Git repo')
return False
files = list_git_files(base, paths, exclude, repository)
root = git_repo_path(repo=repository)
if not root.samefile(repository):
_LOG.info('Checking files in the %s subdirectory of the %s repository',
Path.cwd().relative_to(root), root)
files = [path.relative_to(root) for path in files]
if not output_directory:
output_directory = root.joinpath('.presubmit')
presubmit = Presubmit(
repository_root=root,
output_directory=Path(output_directory),
paths=files,
)
return presubmit.run(program, keep_going)
def parse_args_and_run_presubmit(
program: Sequence[Callable],
arg_parser: Optional[argparse.ArgumentParser] = None) -> bool:
"""Parses the command line arguments and calls run_presubmit with them."""
if arg_parser is None:
arg_parser = argparse.ArgumentParser(
description='Runs presubmit checks on a Git repository.',
formatter_class=argparse.RawDescriptionHelpFormatter)
add_arguments(arg_parser)
return run_presubmit(program, **vars(arg_parser.parse_args()))
def find_python_packages(python_paths, repo='.') -> Dict[str, List[str]]:
"""Returns Python package directories for the files in python_paths."""
setup_pys = [
os.path.dirname(file)
for file in _git_ls_files('setup.py', '*/setup.py', repo=repo)
]
package_dirs: Dict[str, List[str]] = defaultdict(list)
for path in (os.path.abspath(p) for p in python_paths):
try:
setup_dir = max(setup for setup in setup_pys
if path.startswith(setup))
package_dirs[os.path.abspath(setup_dir)].append(path)
except ValueError:
continue
return package_dirs
class _PathFilter(NamedTuple):
endswith: Tuple[str, ...] = ('', )
exclude: Tuple[str, ...] = ()
class _Check:
"""Wraps a presubmit check function.
This class consolidates the logic for running and logging a presubmit check.
It also supports filtering the paths passed to the presubmit check.
"""
def __init__(self,
check_function: Callable[[PresubmitContext], None],
path_filter: _PathFilter = _PathFilter(),
always_run: bool = True):
self._check: Callable = check_function
self.filter: _PathFilter = path_filter
self.always_run: bool = always_run
# Since _Check wraps a presubmit function, adopt that function's name.
self.__name__ = self._check.__name__
@property
def name(self):
return self.__name__
def run(self, ctx: PresubmitContext, count: int, total: int) -> _Result:
"""Runs the presubmit check on the provided paths."""
print('\n'.join(
_box(_TOP, f'{count}/{total}', self.name,
plural(ctx.paths, "file")).splitlines()[:-1]))
_LOG.debug('[%d/%d] Running %s on %s', count, total, self.name,
plural(ctx.paths, "file"))
start_time_s = time.time()
result = self._call_function(ctx)
time_str = _format_time(time.time() - start_time_s)
_LOG.debug('%s %s', self.name, result.value)
print(_box(_BOTTOM, result.colorized(_LEFT), self.name, time_str))
_LOG.debug('%s duration:%s', self.name, time_str)
return result
def _call_function(self, ctx: PresubmitContext) -> _Result:
try:
self._check(ctx)
except PresubmitFailure as failure:
if str(failure):
_LOG.warning('%s', failure)
return _Result.FAIL
except Exception as failure: # pylint: disable=broad-except
_LOG.exception('Presubmit check %s failed!', self.name)
return _Result.FAIL
except KeyboardInterrupt:
print()
return _Result.CANCEL
return _Result.PASS
def _make_tuple(value: Iterable[str]) -> Tuple[str, ...]:
return tuple([value] if isinstance(value, str) else value)
def filter_paths(endswith: Iterable[str] = (''),
exclude: Iterable[str] = (),
always_run: bool = False):
"""Decorator for filtering the paths list for a presubmit check function.
Args:
endswith: str or iterable of path endings to include
exclude: regular expressions of paths to exclude
Returns:
a wrapped version of the presubmit function
"""
def filter_paths_for_function(function: Callable):
if len(signature(function).parameters) != 1:
raise TypeError('Functions wrapped with @filter_paths must take '
f'exactly one argument: {function.__name__} takes '
f'{len(signature(function).parameters)}.')
return _Check(function,
_PathFilter(_make_tuple(endswith), _make_tuple(exclude)),
always_run=always_run)
return filter_paths_for_function
def log_run(*args, **kwargs) -> subprocess.CompletedProcess:
"""Logs a command then runs it with subprocess.run."""
_LOG.debug('[COMMAND] %s\n%s',
', '.join(f'{k}={v}' for k, v in sorted(kwargs.items())),
' '.join(shlex.quote(str(arg)) for arg in args))
return subprocess.run(args, **kwargs)
def call(*args, **kwargs) -> None:
"""Optional subprocess wrapper that causes a PresubmitFailure on errors."""
attributes = ', '.join(f'{k}={v}' for k, v in sorted(kwargs.items()))
command = ' '.join(shlex.quote(str(arg)) for arg in args)
_LOG.debug('[RUN] %s\n%s', attributes, command)
process = subprocess.run(args,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
**kwargs)
logfunc = _LOG.warning if process.returncode else _LOG.debug
logfunc('[FINISHED] %s\n%s', attributes, command)
logfunc('[RESULT] %s with return code %d',
'Failed' if process.returncode else 'Passed', process.returncode)
output = process.stdout.decode(errors='backslashreplace')
if output:
logfunc('[OUTPUT]\n%s', output)
if process.returncode:
raise PresubmitFailure
@filter_paths(endswith='.h')
def pragma_once(ctx: PresubmitContext) -> None:
"""Presubmit check that ensures all header files contain '#pragma once'."""
for path in ctx.paths:
_LOG.debug('Checking %s', path)
with open(path) as file:
for line in file:
if line.startswith('#pragma once'):
break
else:
raise PresubmitFailure('#pragma once is missing!', path=path)
if __name__ == '__main__':
# As an example, run a presubmit with the pragma_once check.
sys.exit(0 if parse_args_and_run_presubmit([pragma_once]) else 1)