pw_presubmit: clang-format wrapper; misc
- Add format_cc.py, which simplifies using clang-format in presubmits by
checking for changes and printing a colorized diff.
- Add --continue option to presubmit_tools.py to continue running checks
after a failure.
- Add exclude option to filter_paths.
Change-Id: If17cf84b3e33550054a10f36df6403dada9005b4
diff --git a/pw_presubmit/format_cc.py b/pw_presubmit/format_cc.py
new file mode 100755
index 0000000..625a2c6
--- /dev/null
+++ b/pw_presubmit/format_cc.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+
+# 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.
+"""Checks that C and C++ source files match clang-format's formatting."""
+
+import argparse
+import difflib
+import os
+import subprocess
+import sys
+from typing import Container, Iterable, List, Optional
+
+SOURCE_EXTENSIONS = frozenset(['.h', '.hh', '.hpp', '.c', '.cc', '.cpp'])
+DEFAULT_FORMATTER = 'clang-format'
+
+
+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_green = _make_color(32)
+color_red = _make_color(31)
+
+
+def _find_extensions(directory, extensions) -> Iterable[str]:
+ for root, _, files in os.walk(directory):
+ for file in files:
+ if os.path.splitext(file)[1] in extensions:
+ yield os.path.join(root, file)
+
+
+def list_files(paths: Iterable[str], extensions: Container[str]) -> List[str]:
+ """Lists files with C or C++ extensions."""
+ files = set()
+
+ for path in paths:
+ if os.path.isfile(path):
+ files.add(path)
+ else:
+ files.update(_find_extensions(path, extensions))
+
+ return sorted(files)
+
+
+def clang_format(*args: str, formatter='clang-format') -> bytes:
+ """Returns the output of clang-format with the provided arguments."""
+ return subprocess.run([formatter, *args],
+ stdout=subprocess.PIPE,
+ check=True).stdout
+
+
+def _colorize_diff_line(line: str) -> str:
+ if line.startswith('-') and not line.startswith('--- '):
+ return color_red(line)
+ if line.startswith('+') and not line.startswith('+++ '):
+ return color_green(line)
+ return line
+
+
+def colorize_diff(lines: Iterable[str]) -> str:
+ """Takes a diff str or list of str lines and returns a colorized version."""
+ if isinstance(lines, str):
+ lines = lines.splitlines(True)
+
+ return ''.join(_colorize_diff_line(line) for line in lines)
+
+
+def clang_format_diff(path, formatter=DEFAULT_FORMATTER) -> Optional[str]:
+ """Returns a diff comparing clang-format's output to the path's contents."""
+ with open(path, 'rb') as fd:
+ current = fd.read()
+
+ formatted = clang_format(path, formatter=formatter)
+
+ if formatted != current:
+ diff = difflib.unified_diff(
+ current.decode(errors='replace').splitlines(True),
+ formatted.decode(errors='replace').splitlines(True),
+ f'{path} (original)', f'{path} (reformatted)')
+
+ return colorize_diff(diff)
+
+ return None
+
+
+def check_format(files, formatter=DEFAULT_FORMATTER) -> List[str]:
+ """Diffs files against clang-format; returns paths that did not match."""
+ errors = []
+
+ for path in files:
+ difference = clang_format_diff(path, formatter)
+ if difference:
+ errors.append(path)
+ print(difference)
+
+ if errors:
+ print(f'--> Files with formatting errors: {len(errors)}',
+ file=sys.stderr)
+ print(' ', '\n '.join(errors), file=sys.stderr)
+
+ return errors
+
+
+def _main(paths, fix, formatter) -> int:
+ """Checks or fixes formatting."""
+ files = list_files(paths, SOURCE_EXTENSIONS)
+
+ if fix:
+ for path in files:
+ clang_format(path, '-i', formatter=formatter)
+ return 0
+
+ errors = check_format(files, formatter)
+ return 1 if errors else 0
+
+
+def _parse_args() -> argparse.Namespace:
+ """Parse and return command line arguments."""
+ parser = argparse.ArgumentParser(description=__doc__)
+
+ parser.add_argument(
+ 'paths',
+ default=[os.getcwd()],
+ nargs='*',
+ help=('Files or directories to check. '
+ 'Within a directory, only C or C++ files are checked.'))
+ parser.add_argument('--fix',
+ action='store_true',
+ help='Apply clang-format fixes in place.')
+ parser.add_argument('--formatter',
+ default=DEFAULT_FORMATTER,
+ help='The clang-format binary to use.')
+
+ return parser.parse_args()
+
+
+if __name__ == '__main__':
+ sys.exit(_main(**vars(_parse_args())))
diff --git a/pw_presubmit/presubmit_tools.py b/pw_presubmit/presubmit_tools.py
index a5380e0..9cb56e3 100755
--- a/pw_presubmit/presubmit_tools.py
+++ b/pw_presubmit/presubmit_tools.py
@@ -1,3 +1,16 @@
+# 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.
A presubmit checks are defined as a function or other callable. The function may
@@ -37,7 +50,7 @@
import subprocess
import sys
import time
-from typing import Callable, Iterable, Optional, Sequence
+from typing import Callable, Iterable, List, Optional, Sequence
from inspect import signature
@@ -93,11 +106,12 @@
return f'{start}{{}}\033[0m'.format if os.name == 'posix' else str
-color_red = _make_color(31, 1)
-color_bold_red = _make_color(30, 41)
+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_bold_green = _make_color(30, 42)
+color_black_on_green = _make_color(30, 42)
def _make_box(section_alignments: Sequence[str]) -> str:
@@ -139,7 +153,7 @@
def _result_box(style, result, color, title, time_s, box=_make_box('^<^')):
minutes, seconds = divmod(time_s, 60)
- time_str = ' ' * 8 if time_s is None else f'{int(minutes)}:{seconds:04.1f}'
+ time_str = f' {int(minutes)}:{seconds:04.1f} '
info = f' {title} '.ljust(WIDTH - 14 - len(time_str))
return box.format(*style,
@@ -148,7 +162,7 @@
section2=info,
width2=len(info),
section3=time_str,
- width3=len(time_str) + 2)
+ width3=len(time_str))
class PresubmitFailure(Exception):
@@ -167,7 +181,7 @@
if self is _Result.PASS:
return color_green
if self is _Result.FAIL:
- return color_red
+ return color_bold_red
if self is _Result.CANCEL:
return color_yellow
@@ -179,13 +193,16 @@
self.paths = paths
self.log = print
- def run(self, program: Sequence[Callable]) -> bool:
+ def run(self, program: Sequence[Callable],
+ continue_on_error: bool = False) -> bool:
"""Executes a series of presubmit checks on the paths."""
self.log(_title(f'Presubmit checks for {os.path.basename(self.root)}'))
self._log_presubmit_start(program)
- start_time = time.time()
+ passed, failed = [], []
+
+ start_time: float = time.time()
for i, check in enumerate(program):
self.log(_start_box(_TOP, f'{i+1}/{len(program)}', check.__name__))
@@ -198,11 +215,13 @@
_result_box(_BOTTOM, result.value, result.color(),
check.__name__, time_s))
- if result is not _Result.PASS:
- passed, failed = program[:i], program[i:]
+ if result is _Result.PASS:
+ passed.append(check)
+ elif continue_on_error:
+ failed.append(check)
+ else:
+ failed = list(program[i:])
break
- else:
- passed, failed = program, []
self._log_summary(time.time() - start_time, len(passed), len(failed))
return not failed
@@ -212,7 +231,8 @@
check(*([self.paths] if signature(check).parameters else []))
return _Result.PASS
except Exception as failure:
- self.log(failure)
+ if str(failure):
+ self.log(failure)
return _Result.FAIL
except KeyboardInterrupt:
self.log()
@@ -236,10 +256,10 @@
def _log_summary(self, time_s: float, passed: int, failed: int) -> None:
text = 'FAILED' if failed else 'PASSED'
- color = color_bold_red if failed else color_bold_green
+ color = color_black_on_red if failed else color_black_on_green
self.log(
_result_box(_DOUBLE, text, color,
- f'Finished {passed} of {passed + failed} checks',
+ f'Passed {passed} of {passed + failed} checks',
time_s))
@@ -271,18 +291,26 @@
action='append',
type=re.compile,
help='Exclude paths matching any of these regular expressions.')
+ parser.add_argument('--continue',
+ dest='continue_on_error',
+ action='store_true',
+ help='Continue instead of aborting when errors occur.')
-def run_presubmit(program: Sequence[Callable],
- base: Optional[str] = None,
- paths: Sequence[str] = (),
- exclude: Sequence = (),
- repository: str = '.') -> bool:
+def run_presubmit(
+ program: Sequence[Callable],
+ base: Optional[str] = None,
+ paths: Sequence[str] = (),
+ exclude: Sequence = (),
+ repository: str = '.',
+ continue_on_error: bool = False,
+) -> bool:
"""Lists files in the current Git repo and runs a Presubmit with them."""
root = git_repo_root(repository)
os.chdir(root)
- return Presubmit(root, list_files(base, paths, exclude)).run(program)
+ return Presubmit(root, list_files(base, paths,
+ exclude)).run(program, continue_on_error)
def parse_args_and_run_presubmit(
@@ -299,16 +327,29 @@
return run_presubmit(program, **vars(arg_parser.parse_args()))
-def filter_paths(endswith: Iterable[str] = (), skip_if_empty: bool = True):
+def _wrap_if_str(value: Iterable[str]) -> List[str]:
+ return [value] if isinstance(value, str) else value
+
+
+def filter_paths(endswith: Iterable[str] = (''),
+ exclude: Iterable = (),
+ skip_if_empty: bool = True):
"""Decorator for filtering the files list for a presubmit check function."""
- endswith = frozenset([endswith] if isinstance(endswith, str) else endswith)
+ endswith = frozenset(_wrap_if_str(endswith))
+ exclude = [re.compile(exp) for exp in _wrap_if_str(exclude)]
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)}.')
+
@functools.wraps(function)
def wrapped_function(paths: Iterable[str]):
paths = [
- path for path in paths if any(
- path.endswith(end) for end in endswith)
+ path for path in paths
+ if any(path.endswith(end) for end in endswith) and not any(
+ exp.fullmatch(path) for exp in exclude)
]
if not paths and skip_if_empty:
@@ -334,7 +375,7 @@
if process.returncode:
print(f'[RESULT] Failed with return code {process.returncode}.')
print('[OUTPUT]')
- print(process.stdout.decode(errors="backslashreplace"))
+ print(process.stdout.decode(errors='backslashreplace'))
raise PresubmitFailure