3 # Copyright 2020 The Pigweed Authors
5 # Licensed under the Apache License, Version 2.0 (the "License"); you may not
6 # use this file except in compliance with the License. You may obtain a copy of
9 # https://www.apache.org/licenses/LICENSE-2.0
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14 # License for the specific language governing permissions and limitations under
16 """Runs the local presubmit checks for the Pigweed repository."""
21 from pathlib import Path
24 from typing import Sequence, IO, Tuple, Optional
29 # Append the pw_presubmit package path to the module search path to allow
30 # running this module without installing the pw_presubmit package.
31 sys.path.append(os.path.dirname(os.path.dirname(
32 os.path.abspath(__file__))))
35 import pw_package.pigweed_packages
37 from pw_presubmit import build, cli, environment, format_code, git_repo
38 from pw_presubmit import call, filter_paths, plural, PresubmitContext
39 from pw_presubmit import PresubmitFailure, Programs
40 from pw_presubmit.install_hook import install_hook
42 _LOG = logging.getLogger(__name__)
44 pw_package.pigweed_packages.initialize()
50 def init_cipd(ctx: PresubmitContext):
51 environment.init_cipd(ctx.root, ctx.output_dir)
54 def init_virtualenv(ctx: PresubmitContext):
55 environment.init_virtualenv(
59 f'{ctx.root}#:python.install',
60 f'{ctx.root}#:target_support_packages.install',
65 # Trigger builds if files with these extensions change.
66 _BUILD_EXTENSIONS = ('.py', '.rst', '.gn', '.gni',
67 *format_code.C_FORMAT.extensions)
70 def _at_all_optimization_levels(target):
71 for level in ['debug', 'size_optimized', 'speed_optimized']:
72 yield f'{target}_{level}'
76 # Build presubmit checks
78 def gn_clang_build(ctx: PresubmitContext):
79 build.gn_gen(ctx.root, ctx.output_dir)
80 build.ninja(ctx.output_dir, *_at_all_optimization_levels('host_clang'))
83 @filter_paths(endswith=_BUILD_EXTENSIONS)
84 def gn_quick_build_check(ctx: PresubmitContext):
85 build.gn_gen(ctx.root, ctx.output_dir)
86 build.ninja(ctx.output_dir, 'host_clang_size_optimized',
87 'stm32f429i_size_optimized', 'python.tests', 'python.lint')
90 @filter_paths(endswith=_BUILD_EXTENSIONS)
91 def gn_gcc_build(ctx: PresubmitContext):
92 build.gn_gen(ctx.root, ctx.output_dir)
94 # Skip optimized host GCC builds for now, since GCC sometimes emits spurious
97 # -02: GCC 9.3 emits spurious maybe-uninitialized warnings
98 # -0s: GCC 8.1 (Mingw-w64) emits a spurious nonnull warning
100 # TODO(pwbug/255): Enable optimized GCC builds when this is fixed.
101 build.ninja(ctx.output_dir, 'host_gcc_debug')
104 @filter_paths(endswith=_BUILD_EXTENSIONS)
105 def gn_arm_build(ctx: PresubmitContext):
106 build.gn_gen(ctx.root, ctx.output_dir)
107 build.ninja(ctx.output_dir, *_at_all_optimization_levels('stm32f429i'))
110 @filter_paths(endswith=_BUILD_EXTENSIONS)
111 def gn_nanopb_build(ctx: PresubmitContext):
112 build.install_package(ctx.package_root, 'nanopb')
113 build.gn_gen(ctx.root,
115 dir_pw_third_party_nanopb='"{}"'.format(ctx.package_root /
119 *_at_all_optimization_levels('stm32f429i'),
120 *_at_all_optimization_levels('host_clang'),
124 @filter_paths(endswith=_BUILD_EXTENSIONS)
125 def gn_teensy_build(ctx: PresubmitContext):
126 build.install_package(ctx.package_root, 'teensy')
127 build.gn_gen(ctx.root,
129 pw_arduino_build_CORE_PATH='"{}"'.format(str(
131 pw_arduino_build_CORE_NAME='teensy',
132 pw_arduino_build_PACKAGE_NAME='teensy/avr',
133 pw_arduino_build_BOARD='teensy40')
134 build.ninja(ctx.output_dir, *_at_all_optimization_levels('arduino'))
137 @filter_paths(endswith=_BUILD_EXTENSIONS)
138 def gn_qemu_build(ctx: PresubmitContext):
139 build.gn_gen(ctx.root, ctx.output_dir)
140 build.ninja(ctx.output_dir, *_at_all_optimization_levels('qemu_gcc'))
143 @filter_paths(endswith=_BUILD_EXTENSIONS)
144 def gn_qemu_clang_build(ctx: PresubmitContext):
145 build.gn_gen(ctx.root, ctx.output_dir)
146 build.ninja(ctx.output_dir, *_at_all_optimization_levels('qemu_clang'))
149 def gn_docs_build(ctx: PresubmitContext):
150 build.gn_gen(ctx.root, ctx.output_dir)
151 build.ninja(ctx.output_dir, 'docs')
154 def gn_host_tools(ctx: PresubmitContext):
155 build.gn_gen(ctx.root, ctx.output_dir, pw_build_HOST_TOOLS=True)
156 build.ninja(ctx.output_dir, 'host')
159 @filter_paths(endswith=format_code.C_FORMAT.extensions)
160 def oss_fuzz_build(ctx: PresubmitContext):
161 build.gn_gen(ctx.root, ctx.output_dir, pw_toolchain_OSS_FUZZ_ENABLED=True)
162 build.ninja(ctx.output_dir, "fuzzers")
165 @filter_paths(endswith='.py')
166 def python_checks(ctx: PresubmitContext):
167 build.gn_gen(ctx.root, ctx.output_dir)
172 ':target_support_packages.lint',
173 ':target_support_packages.tests',
177 def _run_cmake(ctx: PresubmitContext) -> None:
178 build.install_package(ctx.package_root, 'nanopb')
180 toolchain = ctx.root / 'pw_toolchain' / 'host_clang' / 'toolchain.cmake'
181 build.cmake(ctx.root,
183 f'-DCMAKE_TOOLCHAIN_FILE={toolchain}',
184 '-DCMAKE_EXPORT_COMPILE_COMMANDS=1',
185 f'-Ddir_pw_third_party_nanopb={ctx.package_root / "nanopb"}',
186 env=build.env_with_clang_vars())
189 @filter_paths(endswith=(*format_code.C_FORMAT.extensions, '.cmake',
191 def cmake_tests(ctx: PresubmitContext):
193 build.ninja(ctx.output_dir, 'pw_apps', 'pw_run_tests.modules')
196 @filter_paths(endswith=(*format_code.C_FORMAT.extensions, '.bzl', 'BUILD'))
197 def bazel_test(ctx: PresubmitContext):
202 '--verbose_failures',
203 '--verbose_explanations',
206 ctx.output_dir.joinpath('bazel-'),
208 env=build.env_with_clang_vars())
210 _LOG.info('If the Bazel build inexplicably fails while the '
211 'other builds are passing, try deleting the Bazel cache:\n'
212 ' rm -rf ~/.cache/bazel')
217 # General presubmit checks
220 # TODO(pwbug/45) Probably want additional checks.
221 _CLANG_TIDY_CHECKS = ('modernize-use-override', )
224 @filter_paths(endswith=format_code.C_FORMAT.extensions)
225 def clang_tidy(ctx: PresubmitContext):
226 build.gn_gen(ctx.root, ctx.output_dir, '--export-compile-commands')
227 build.ninja(ctx.output_dir)
228 build.ninja(ctx.output_dir, '-t', 'compdb', 'objcxx', 'cxx')
230 run_clang_tidy = None
231 for var in ('PW_PIGWEED_CIPD_INSTALL_DIR', 'PW_CIPD_INSTALL_DIR'):
232 if var in os.environ:
233 possibility = os.path.join(os.environ[var],
234 'share/clang/run-clang-tidy.py')
235 if os.path.isfile(possibility):
236 run_clang_tidy = possibility
239 checks = ','.join(_CLANG_TIDY_CHECKS)
242 f'-p={ctx.output_dir}',
244 # TODO(pwbug/45) not sure if this is needed.
245 # f'-extra-arg-before=-warnings-as-errors={checks}',
249 # The first line must be regex because of the '20\d\d' date
250 COPYRIGHT_FIRST_LINE = r'Copyright 20\d\d The Pigweed Authors'
251 COPYRIGHT_COMMENTS = r'(#|//| \*|REM|::)'
252 COPYRIGHT_BLOCK_COMMENTS = (
256 COPYRIGHT_FIRST_LINE_EXCEPTIONS = (
264 COPYRIGHT_LINES = tuple("""\
266 Licensed under the Apache License, Version 2.0 (the "License"); you may not
267 use this file except in compliance with the License. You may obtain a copy of
270 https://www.apache.org/licenses/LICENSE-2.0
272 Unless required by applicable law or agreed to in writing, software
273 distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
274 WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
275 License for the specific language governing permissions and limitations under
279 _EXCLUDE_FROM_COPYRIGHT_NOTICE: Sequence[str] = (
288 r'\brequirements.txt$',
301 # Generated protobuf files
311 def match_block_comment_start(line: str) -> Optional[str]:
312 """Matches the start of a block comment and returns the end."""
313 for block_comment in COPYRIGHT_BLOCK_COMMENTS:
314 if re.match(block_comment[0], line):
315 # Return the end of the block comment
316 return block_comment[1]
320 def copyright_read_first_line(
321 file: IO) -> Tuple[Optional[str], Optional[str], Optional[str]]:
322 """Reads the file until it reads a valid first copyright line.
324 Returns (comment, block_comment, line). comment and block_comment are
325 mutually exclusive and refer to the comment character sequence and whether
326 they form a block comment or a line comment. line is the first line of
327 the copyright, and is used for error reporting.
329 line = file.readline()
330 first_line_matcher = re.compile(COPYRIGHT_COMMENTS + ' ' +
331 COPYRIGHT_FIRST_LINE)
333 end_block_comment = match_block_comment_start(line)
334 if end_block_comment:
335 next_line = file.readline()
336 copyright_line = re.match(COPYRIGHT_FIRST_LINE, next_line)
337 if not copyright_line:
338 return (None, None, line)
339 return (None, end_block_comment, line)
341 first_line = first_line_matcher.match(line)
343 return (first_line.group(1), None, line)
346 and not line.startswith(COPYRIGHT_FIRST_LINE_EXCEPTIONS)):
347 return (None, None, line)
349 line = file.readline()
350 return (None, None, None)
353 @filter_paths(exclude=_EXCLUDE_FROM_COPYRIGHT_NOTICE)
354 def copyright_notice(ctx: PresubmitContext):
355 """Checks that the Pigweed copyright notice is present."""
358 for path in ctx.paths:
360 if path.stat().st_size == 0:
361 continue # Skip empty files
363 with path.open() as file:
364 (comment, end_block_comment,
365 line) = copyright_read_first_line(file)
368 _LOG.warning('%s: invalid first line', path)
372 if not (comment or end_block_comment):
373 _LOG.warning('%s: invalid first line %r', path, line)
377 if end_block_comment:
378 expected_lines = COPYRIGHT_LINES + (end_block_comment, )
380 expected_lines = COPYRIGHT_LINES
382 for expected, actual in zip(expected_lines, file):
383 if end_block_comment:
384 expected_line = expected + '\n'
386 expected_line = (comment + ' ' + expected).rstrip() + '\n'
388 if expected_line != actual:
389 _LOG.warning(' bad line: %r', actual)
390 _LOG.warning(' expected: %r', expected_line)
395 _LOG.warning('%s with a missing or incorrect copyright notice:\n%s',
396 plural(errors, 'file'), '\n'.join(str(e) for e in errors))
397 raise PresubmitFailure
400 _BAZEL_SOURCES_IN_BUILD = tuple(format_code.C_FORMAT.extensions)
401 _GN_SOURCES_IN_BUILD = '.rst', '.py', *_BAZEL_SOURCES_IN_BUILD
404 @filter_paths(endswith=(*_GN_SOURCES_IN_BUILD, 'BUILD', '.bzl', '.gn', '.gni'))
405 def source_is_in_build_files(ctx: PresubmitContext):
406 """Checks that source files are in the GN and Bazel builds."""
407 missing = build.check_builds_for_files(
408 _BAZEL_SOURCES_IN_BUILD,
409 _GN_SOURCES_IN_BUILD,
411 bazel_dirs=[ctx.root],
412 gn_build_files=git_repo.list_files(
413 pathspecs=['BUILD.gn', '*BUILD.gn']))
417 'All source files must appear in BUILD and BUILD.gn files')
418 raise PresubmitFailure
421 cmake_missing = build.check_compile_commands_for_files(
422 ctx.output_dir / 'compile_commands.json',
423 (f for f in ctx.paths if f.suffix in ('.c', '.cc')))
425 _LOG.warning('The CMake build is missing %d files', len(cmake_missing))
426 _LOG.warning('Files missing from CMake:\n%s',
427 '\n'.join(str(f) for f in cmake_missing))
428 # TODO(hepler): Many files are missing from the CMake build. Make this
429 # check an error when the missing files are fixed.
430 # raise PresubmitFailure
433 def build_env_setup(ctx: PresubmitContext):
434 if 'PW_CARGO_SETUP' not in os.environ:
436 'Skipping build_env_setup since PW_CARGO_SETUP is not set')
439 tmpl = ctx.root.joinpath('pw_env_setup', 'py', 'pyoxidizer.bzl.tmpl')
440 out = ctx.output_dir.joinpath('pyoxidizer.bzl')
442 with open(tmpl, 'r') as ins:
443 cfg = ins.read().replace('${PW_ROOT}', str(ctx.root))
444 with open(out, 'w') as outs:
447 call('pyoxidizer', 'build', cwd=ctx.output_dir)
450 def commit_message_format(_: PresubmitContext):
451 """Checks that the top commit's message is correctly formatted."""
452 lines = git_repo.commit_message().splitlines()
454 # Show limits and current commit message in log.
455 _LOG.debug('%-25s%+25s%+22s', 'Line limits', '72|', '72|')
460 _LOG.error('The commit message is too short!')
461 raise PresubmitFailure
465 if len(lines[0]) > 72:
466 _LOG.warning("The commit message's first line must be no longer than "
468 _LOG.warning('The first line is %d characters:\n %s', len(lines[0]),
472 if lines[0].endswith('.'):
474 "The commit message's first line must not end with a period:\n %s",
478 if len(lines) > 1 and lines[1]:
479 _LOG.warning("The commit message's second line must be blank.")
480 _LOG.warning('The second line has %d characters:\n %s', len(lines[1]),
484 # Check that the lines are 72 characters or less, but skip any lines that
485 # might possibly have a URL, path, or metadata in them. Also skip any lines
486 # with non-ASCII characters.
487 for i, line in enumerate(lines[2:], 3):
488 if any(c in line for c in ':/>') or not line.isascii():
493 'Commit message lines must be no longer than 72 characters.')
494 _LOG.warning('Line %d has %d characters:\n %s', i, len(line),
499 _LOG.error('Found %s in the commit message', plural(errors, 'error'))
500 raise PresubmitFailure
504 # Presubmit check programs
508 # TODO(pwbug/45): Remove clang-tidy from BROKEN when it passes.
510 # Build that attempts to duplicate the build OSS-Fuzz does. Currently
519 commit_message_format,
520 source_is_in_build_files,
522 format_code.presubmit_checks(),
523 pw_presubmit.pragma_once,
524 gn_quick_build_check,
525 # TODO(pwbug/141): Re-enable CMake and Bazel for Mac after we have fixed the
526 # the clang issues. The problem is that all clang++ invocations need the
527 # two extra flags: "-nostdc++" and "${clang_prefix}/../lib/libc++.a".
528 cmake_tests if sys.platform != 'darwin' else (),
532 commit_message_format,
536 format_code.presubmit_checks(),
537 pw_presubmit.pragma_once,
542 # On Mac OS, system 'gcc' is a symlink to 'clang' by default, so skip GCC
543 # host builds on Mac for now.
544 gn_gcc_build if sys.platform != 'darwin' else (),
545 # Windows doesn't support QEMU yet.
546 gn_qemu_build if sys.platform != 'win32' else (),
547 gn_qemu_clang_build if sys.platform != 'win32' else (),
548 source_is_in_build_files,
551 # Skip gn_teensy_build if running on Windows. The Teensycore installer is
552 # an exe that requires an admin role.
553 gn_teensy_build if sys.platform in ['linux', 'darwin'] else (),
556 PROGRAMS = Programs(broken=BROKEN, quick=QUICK, full=FULL)
559 def parse_args() -> argparse.Namespace:
560 """Creates an argument parser and parses arguments."""
562 parser = argparse.ArgumentParser(description=__doc__)
563 cli.add_arguments(parser, PROGRAMS, 'quick')
567 help='Install the presubmit as a Git pre-push hook and exit.')
569 return parser.parse_args()
572 def run(install: bool, **presubmit_args) -> int:
573 """Entry point for presubmit."""
576 install_hook(__file__, 'pre-push',
577 ['--base', 'origin/master..HEAD', '--program', 'quick'],
581 return cli.run(**presubmit_args)
585 """Run the presubmit for the Pigweed repository."""
586 return run(**vars(parse_args()))
589 if __name__ == '__main__':
591 # If pw_cli is available, use it to initialize logs.
592 from pw_cli import log
594 log.install(logging.INFO)
596 # If pw_cli isn't available, display log messages like a simple print.
597 logging.basicConfig(format='%(message)s', level=logging.INFO)