| #!/usr/bin/env python3 |
| |
| # Copyright 2020 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. |
| """Runs the local presubmit checks for the Pigweed repository.""" |
| |
| import argparse |
| import itertools |
| import logging |
| import os |
| from pathlib import Path |
| import re |
| import shutil |
| import subprocess |
| import sys |
| from typing import Callable, Dict, Sequence, Tuple |
| |
| try: |
| import pw_presubmit |
| except ImportError: |
| # Append the pw_presubmit package path to the module search path to allow |
| # running this module without installing the pw_presubmit package. |
| sys.path.append(os.path.dirname(os.path.dirname( |
| os.path.abspath(__file__)))) |
| import pw_presubmit |
| |
| from pw_presubmit import python_checks |
| from pw_presubmit import format_code, PresubmitContext |
| from pw_presubmit.install_hook import install_hook |
| from pw_presubmit import call, filter_paths, log_run, plural, PresubmitFailure |
| |
| _LOG = logging.getLogger(__name__) |
| |
| |
| # |
| # Initialization |
| # |
| def init_cipd(ctx: PresubmitContext): |
| # TODO(mohrr) invoke by importing rather than by subprocess. |
| |
| # TODO(pwbug/138) find way to support dependent project package files. |
| |
| cmd = [ |
| sys.executable, |
| ctx.repository_root.joinpath('pw_env_setup', 'py', 'pw_env_setup', |
| 'cipd_setup', 'update.py'), |
| '--install-dir', ctx.output_directory, |
| ] # yapf: disable |
| |
| package_files = ctx.repository_root.joinpath('pw_env_setup', 'py', |
| 'pw_env_setup', |
| 'cipd_setup').glob('*.json') |
| |
| for package_file in package_files: |
| cmd.extend(('--package-file', package_file)) |
| |
| call(*cmd) |
| |
| paths = [ctx.output_directory, ctx.output_directory.joinpath('bin')] |
| for base in ctx.output_directory.glob('*'): |
| paths.append(base) |
| paths.append(base.joinpath('bin')) |
| |
| paths.append(Path(os.environ['PATH'])) |
| |
| os.environ['PATH'] = os.pathsep.join(str(x) for x in paths) |
| _LOG.debug('PATH %s', os.environ['PATH']) |
| |
| |
| def init_virtualenv(ctx: PresubmitContext): |
| """Set up virtualenv, assumes recent Python 3 is already installed.""" |
| virtualenv_source = ctx.repository_root.joinpath('pw_env_setup', 'py', |
| 'pw_env_setup', |
| 'virtualenv_setup') |
| |
| # TODO(pwbug/138) find way to support dependent project requirements. |
| |
| # For speed, don't build the venv if it exists. Use --clean to recreate it. |
| if not ctx.output_directory.joinpath('pyvenv.cfg').is_file(): |
| call( |
| 'python3', |
| virtualenv_source, |
| f'--venv_path={ctx.output_directory}', |
| '--requirements={}'.format( |
| virtualenv_source.joinpath('requirements.txt')), |
| '--setup-py-roots={}'.format(ctx.repository_root), |
| ) |
| |
| os.environ['PATH'] = os.pathsep.join(( |
| str(ctx.output_directory.joinpath('bin')), |
| os.environ['PATH'], |
| )) |
| |
| |
| INIT: Tuple[Callable, ...] = ( |
| init_cipd, |
| init_virtualenv, |
| ) |
| |
| |
| # |
| # GN presubmit checks |
| # |
| def gn_args(**kwargs) -> str: |
| return '--args=' + ' '.join(f'{arg}={val}' for arg, val in kwargs.items()) |
| |
| |
| def gn_gen(*args, ctx: PresubmitContext, path=None, **kwargs) -> None: |
| call('gn', |
| 'gen', |
| path or ctx.output_directory, |
| '--color=always', |
| '--check', |
| *args, |
| cwd=ctx.repository_root, |
| **kwargs) |
| |
| |
| def ninja(*args, ctx: PresubmitContext, **kwargs): |
| call('ninja', |
| '-C', |
| ctx.output_directory, |
| *args, |
| cwd=ctx.repository_root, |
| **kwargs) |
| |
| |
| _CLANG_GEN_ARGS = gn_args( |
| pw_target_config='"//targets/host/target_config.gni"', |
| pw_target_toolchain='"//pw_toolchain:host_clang_os"') |
| |
| _DOCS_GEN_ARGS = gn_args(pw_target_config='"//targets/docs/target_config.gni"') |
| |
| |
| def gn_clang_build(ctx: PresubmitContext): |
| gn_gen(_CLANG_GEN_ARGS, ctx=ctx) |
| ninja(ctx=ctx) |
| |
| |
| @filter_paths(endswith=format_code.C_FORMAT.extensions) |
| def gn_gcc_build(ctx: PresubmitContext): |
| gn_gen(gn_args(pw_target_config='"//targets/host/target_config.gni"', |
| pw_target_toolchain='"//pw_toolchain:host_gcc_os"'), |
| ctx=ctx) |
| ninja(ctx=ctx) |
| |
| |
| _ARM_GEN_ARGS = gn_args( |
| pw_target_config='"//targets/stm32f429i-disc1/target_config.gni"') |
| |
| |
| @filter_paths(endswith=format_code.C_FORMAT.extensions) |
| def gn_arm_build(ctx: PresubmitContext): |
| gn_gen(_ARM_GEN_ARGS, ctx=ctx) |
| ninja(ctx=ctx) |
| |
| |
| def gn_docs_build(ctx: PresubmitContext): |
| gn_gen(_DOCS_GEN_ARGS, ctx=ctx) |
| ninja('docs:docs', ctx=ctx) |
| |
| |
| _QEMU_GEN_ARGS = gn_args( |
| pw_target_config='"//targets/lm3s6965evb-qemu/target_config.gni"') |
| |
| GN: Tuple[Callable, ...] = ( |
| gn_clang_build, |
| gn_arm_build, |
| gn_docs_build, |
| ) |
| |
| # On Mac OS, 'gcc' is a symlink to 'clang', so skip GCC host builds on Mac. |
| if sys.platform != 'darwin': |
| GN = GN + (gn_gcc_build, ) |
| |
| |
| def gn_host_tools(ctx: PresubmitContext): |
| gn_gen(gn_args(pw_target_config='"//targets/host/target_config.gni"', |
| pw_target_toolchain='"//pw_toolchain:host_clang_os"', |
| pw_build_host_tools='true'), |
| ctx=ctx) |
| ninja('host_tools', ctx=ctx) |
| |
| |
| # |
| # C++ presubmit checks |
| # |
| # TODO(pwbug/45) Probably want additional checks. |
| _CLANG_TIDY_CHECKS = ('modernize-use-override', ) |
| |
| |
| @filter_paths(endswith=format_code.C_FORMAT.extensions) |
| def clang_tidy(ctx: PresubmitContext): |
| gn_gen('--export-compile-commands', _CLANG_GEN_ARGS, ctx=ctx) |
| ninja(ctx=ctx) |
| ninja('-t', 'compdb', 'objcxx', 'cxx', ctx=ctx) |
| |
| run_clang_tidy = None |
| for var in ('PW_PIGWEED_CIPD_INSTALL_DIR', 'PW_CIPD_INSTALL_DIR'): |
| if var in os.environ: |
| possibility = os.path.join(os.environ[var], |
| 'share/clang/run-clang-tidy.py') |
| if os.path.isfile(possibility): |
| run_clang_tidy = possibility |
| break |
| |
| checks = ','.join(_CLANG_TIDY_CHECKS) |
| call( |
| run_clang_tidy, |
| f'-p={ctx.output_directory}', |
| f'-checks={checks}', |
| # TODO(pwbug/45) not sure if this is needed. |
| # f'-extra-arg-before=-warnings-as-errors={checks}', |
| *ctx.paths) |
| |
| |
| CC: Tuple[Callable, ...] = ( |
| pw_presubmit.pragma_once, |
| # TODO(pwbug/45): Enable clang-tidy when it passes. |
| # clang_tidy, |
| ) |
| |
| |
| # |
| # CMake presubmit checks |
| # |
| def _env_with_clang_cc_vars(): |
| env = os.environ.copy() |
| env['CC'] = env['LD'] = env['AS'] = 'clang' |
| env['CXX'] = 'clang++' |
| return env |
| |
| |
| @filter_paths(endswith=(*format_code.C_FORMAT.extensions, '.cmake', |
| 'CMakeLists.txt')) |
| def cmake_tests(ctx: PresubmitContext): |
| env = _env_with_clang_cc_vars() |
| call('cmake', |
| '-B', ctx.output_directory, |
| '-S', ctx.repository_root, |
| '-G', 'Ninja', |
| env=env) # yapf: disable |
| ninja('pw_run_tests.modules', ctx=ctx) |
| |
| |
| CMAKE: Tuple[Callable, ...] = () |
| |
| # TODO(pwbug/141): Re-enable this after we have fixed the CMake toolchain issue |
| # on Mac. The problem is that all clang++ invocations need the two extra |
| # flags: "-nostdc++" and "${clang_prefix}../lib/libc++.a". |
| if sys.platform != 'darwin': |
| CMAKE = (cmake_tests, ) |
| |
| |
| # |
| # Bazel presubmit checks |
| # |
| @filter_paths(endswith=(*format_code.C_FORMAT.extensions, '.bzl', 'BUILD')) |
| def bazel_test(ctx: PresubmitContext): |
| try: |
| call('bazel', |
| 'test', |
| '//...', |
| '--verbose_failures', |
| '--verbose_explanations', |
| '--worker_verbose', |
| '--symlink_prefix', |
| ctx.output_directory.joinpath('bazel-'), |
| cwd=ctx.repository_root) |
| except: |
| _LOG.info('If the Bazel build inexplicably fails while the ' |
| 'other builds are passing, try deleting the Bazel cache:\n' |
| ' rm -rf ~/.cache/bazel') |
| raise |
| |
| |
| BAZEL: Tuple[Callable, ...] = () |
| |
| # TODO(pwbug/141): Re-enable this after we have fixed the Bazel toolchain issue |
| # on Mac. The problem is that all clang++ invocations need the two extra flags: |
| # "-nostdc++" and "${clang_prefix}../lib/libc++.a". |
| if sys.platform != 'darwin': |
| BAZEL = (bazel_test, ) |
| |
| # |
| # Code format presubmit checks |
| # |
| COPYRIGHT_FIRST_LINE = re.compile( |
| r'^(#|//| \*|REM|::) Copyright 20\d\d The Pigweed Authors$') |
| |
| COPYRIGHT_LINES = tuple("""\ |
| |
| 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. |
| """.splitlines(True)) |
| |
| _EXCLUDE_FROM_COPYRIGHT_NOTICE: Sequence[str] = ( |
| r'(?:.+/)?\..+', |
| r'AUTHORS', |
| r'LICENSE', |
| r'OWNERS', |
| r'.*\.elf', |
| r'.*\.gif', |
| r'.*\.jpg', |
| r'.*\.json', |
| r'.*\.md', |
| r'.*\.png', |
| r'.*\.rst', |
| r'(?:.+/)?requirements.txt', |
| r'(.+/)?go.(mod|sum)', |
| ) |
| |
| |
| @filter_paths(exclude=_EXCLUDE_FROM_COPYRIGHT_NOTICE) |
| def copyright_notice(ctx: PresubmitContext): |
| """Checks that the copyright notice is present.""" |
| |
| errors = [] |
| |
| for path in ctx.paths: |
| _LOG.debug('Checking %s', path) |
| with open(path) as file: |
| # Skip shebang and blank lines |
| line = file.readline() |
| while line and (line.startswith( |
| ('#!', '/*', '@echo off', '# -*-')) or not line.strip()): |
| line = file.readline() |
| |
| first_line = COPYRIGHT_FIRST_LINE.match(line) |
| if not first_line: |
| _LOG.debug('%s: invalid first line %r', path, line) |
| errors.append(path) |
| continue |
| |
| comment = first_line.group(1) |
| |
| for expected, actual in zip(COPYRIGHT_LINES, file): |
| if comment + expected != actual: |
| _LOG.debug(' bad line: %r', actual) |
| _LOG.debug(' expected: %r', comment + expected) |
| errors.append(path) |
| break |
| |
| if errors: |
| _LOG.warning('%s with a missing or incorrect copyright notice:\n%s', |
| pw_presubmit.plural(errors, 'file'), |
| '\n'.join(str(e) for e in errors)) |
| raise PresubmitFailure |
| |
| |
| CODE_FORMAT: Tuple[Callable, ...] = ( |
| copyright_notice, *format_code.PRESUBMIT_CHECKS) # yapf: disable |
| |
| # |
| # General presubmit checks |
| # |
| |
| |
| def _get_paths_from_command(*args, ctx: PresubmitContext, **kwargs): |
| """Runs a command and reads Bazel or GN //-style paths from it.""" |
| process = log_run(*args, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.DEVNULL, |
| cwd=ctx.repository_root, |
| **kwargs) |
| files = set() |
| |
| for line in process.stdout.splitlines(): |
| path = line.strip().lstrip(b'/').replace(b':', b'/').decode() |
| path = ctx.repository_root.joinpath(path) |
| if path.is_file(): |
| _LOG.debug('Found file %s', path) |
| files.add(path) |
| |
| return files |
| |
| |
| _SOURCES_IN_BUILD = '.rst', *format_code.C_FORMAT.extensions |
| |
| |
| @filter_paths(endswith=(*_SOURCES_IN_BUILD, 'BUILD', '.bzl', '.gn', '.gni')) |
| def source_is_in_build_files(ctx: PresubmitContext): |
| """Checks that source files are in the GN and Bazel builds.""" |
| |
| # Collect all paths in the Bazel build. |
| build_bazel = _get_paths_from_command('bazel', |
| 'query', |
| 'kind("source file", //...:*)', |
| ctx=ctx) |
| |
| # Collect all paths in the ARM and Clang GN builds. |
| arm_dir = ctx.output_directory.joinpath('arm') |
| gn_gen(_ARM_GEN_ARGS, ctx=ctx, path=arm_dir) |
| build_gn = _get_paths_from_command('gn', 'desc', arm_dir, '*', ctx=ctx) |
| |
| clang_dir = ctx.output_directory.joinpath('clang') |
| gn_gen(_CLANG_GEN_ARGS, ctx=ctx, path=clang_dir) |
| build_gn.update( |
| _get_paths_from_command('gn', 'desc', clang_dir, '*', ctx=ctx)) |
| |
| docs_dir = ctx.output_directory.joinpath('docs') |
| gn_gen(_DOCS_GEN_ARGS, ctx=ctx, path=docs_dir) |
| build_gn.update( |
| _get_paths_from_command('gn', 'desc', docs_dir, '*', ctx=ctx)) |
| |
| qemu_dir = ctx.output_directory.joinpath('qemu') |
| gn_gen(_QEMU_GEN_ARGS, ctx=ctx, path=qemu_dir) |
| build_gn.update( |
| _get_paths_from_command('gn', 'desc', qemu_dir, '*', ctx=ctx)) |
| |
| missing_bazel = [] |
| missing_gn = [] |
| |
| for path in (p for p in ctx.paths if p.suffix in _SOURCES_IN_BUILD): |
| if path.suffix != '.rst' and path not in build_bazel: |
| missing_bazel.append(path) |
| if path not in build_gn: |
| missing_gn.append(path) |
| |
| # TODO(pwbug/176) Replace this workaround for fuzzers. |
| missing_bazel = [p for p in missing_bazel if 'fuzzer' not in p.name] |
| |
| if missing_bazel or missing_gn: |
| for build, files in [('Bazel', missing_bazel), ('GN', missing_gn)]: |
| if files: |
| _LOG.warning('%s are missing from the %s build:\n%s', |
| plural(files, 'file'), build, |
| '\n'.join(str(x) for x in files)) |
| |
| _LOG.warning( |
| 'All source files must appear in BUILD and BUILD.gn files') |
| raise PresubmitFailure |
| |
| |
| GENERAL: Tuple[Callable, ...] = (source_is_in_build_files, ) |
| |
| |
| def build_env_setup(ctx: PresubmitContext): |
| if 'PW_CARGO_SETUP' not in os.environ: |
| _LOG.warning( |
| 'Skipping build_env_setup since PW_CARGO_SETUP is not set') |
| return |
| |
| tmpl = ctx.repository_root.joinpath('pw_env_setup', 'py', 'pyoxidizer.bzl') |
| out = ctx.output_directory.joinpath('pyoxidizer.bzl') |
| |
| with open(tmpl, 'r') as ins: |
| cfg = ins.read().replace('${PW_ROOT}', str(ctx.repository_root)) |
| with open(out, 'w') as outs: |
| outs.write(cfg) |
| |
| call('pyoxidizer', 'build', cwd=ctx.output_directory) |
| |
| |
| BUILD_ENV_SETUP = (build_env_setup, ) |
| |
| |
| BROKEN: Tuple[Callable, ...] = ( |
| # TODO(pwbug/45): Remove clang-tidy from BROKEN when it passes. |
| clang_tidy, |
| # Host tools are not broken but take long on slow internet connections. |
| # They're still run in CQ, but not in 'pw presubmit'. |
| gn_host_tools, |
| ) # yapf: disable |
| |
| # |
| # Presubmit check programs |
| # |
| QUICK_PRESUBMIT: Tuple[Callable, ...] = ( |
| *INIT, |
| *CODE_FORMAT, |
| *GENERAL, |
| *CC, |
| gn_clang_build, |
| gn_arm_build, |
| *python_checks.ALL, |
| ) |
| |
| FULL_PRESUBMIT: Tuple[Callable, ...] = ( |
| INIT + CODE_FORMAT + GENERAL + CC + GN + |
| python_checks.ALL + CMAKE + BAZEL + BUILD_ENV_SETUP) # yapf: disable |
| |
| PROGRAMS: Dict[str, Tuple] = { |
| 'broken': BROKEN, |
| 'full': FULL_PRESUBMIT, |
| 'quick': QUICK_PRESUBMIT, |
| } |
| |
| ALL_STEPS = {c.__name__: c for c in itertools.chain(*PROGRAMS.values())} |
| |
| |
| def argument_parser(parser=None) -> argparse.ArgumentParser: |
| """Create argument parser.""" |
| |
| if parser is None: |
| parser = argparse.ArgumentParser(description=__doc__) |
| |
| parser.add_argument( |
| '-o', |
| '--output-directory', |
| type=Path, |
| help='Output directory (default: <repo root>/.presubmit)', |
| ) |
| |
| parser.add_argument( |
| '--clean', |
| action='store_true', |
| help='Delete the entire output directory before starting.', |
| ) |
| parser.add_argument( |
| '--clean-py', |
| action='store_true', |
| help=('Delete the Python virtualenv in the output directory before ' |
| 'starting.'), |
| ) |
| |
| exclusive = parser.add_mutually_exclusive_group() |
| exclusive.add_argument( |
| '--install', |
| action='store_true', |
| help='Install the presubmit as a Git pre-push hook and exits.') |
| exclusive.add_argument('-p', |
| '--program', |
| dest='program_name', |
| choices=[x for x in PROGRAMS if x != 'broken'], |
| default='full', |
| help='Which presubmit program to run') |
| |
| exclusive.add_argument( |
| '--step', |
| dest='steps', |
| choices=sorted(ALL_STEPS), |
| action='append', |
| help='Provide explicit steps instead of running a predefined program.', |
| ) |
| |
| pw_presubmit.add_arguments(parser) |
| |
| return parser |
| |
| |
| def main( |
| program_name: str, |
| clean: bool, |
| clean_py: bool, |
| install: bool, |
| repository: Path, |
| output_directory: Path, |
| steps: Sequence[str], |
| **presubmit_args, |
| ) -> int: |
| """Entry point for presubmit.""" |
| |
| os.environ.setdefault('PW_ROOT', |
| str(pw_presubmit.git_repo_path(repo=repository))) |
| |
| if not output_directory: |
| output_directory = pw_presubmit.git_repo_path('.presubmit', |
| repo=repository) |
| environment = output_directory |
| _LOG.debug('Using environment at %s', environment) |
| |
| if clean and environment.exists(): |
| shutil.rmtree(environment) |
| elif clean_py and environment.joinpath('init_virtualenv').exists(): |
| shutil.rmtree(environment.joinpath('init_virtualenv')) |
| |
| if install: |
| install_hook(__file__, 'pre-push', |
| ['--base', 'origin/master..HEAD', '--program', 'quick'], |
| repository) |
| return 0 |
| |
| program: Sequence = PROGRAMS[program_name] |
| if steps: |
| program = [ALL_STEPS[name] for name in steps] |
| |
| if pw_presubmit.run_presubmit(program, |
| repository=repository, |
| output_directory=output_directory, |
| **presubmit_args): |
| return 0 |
| |
| return 1 |
| |
| |
| if __name__ == '__main__': |
| # By default, display log messages like a simple print statement. |
| logging.basicConfig(format='%(message)s', level=logging.INFO) |
| sys.exit(main(**vars(argument_parser().parse_args()))) |