1#!/usr/bin/env python3 2 3# Copyright 2020 The Pigweed Authors 4# 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 7# the License at 8# 9# https://www.apache.org/licenses/LICENSE-2.0 10# 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 15# the License. 16"""Runs the local presubmit checks for the Pigweed repository.""" 17 18import argparse 19import json 20import logging 21import os 22from pathlib import Path 23import re 24import shutil 25import subprocess 26import sys 27from typing import Callable, Iterable, List, Sequence, TextIO 28 29try: 30 import pw_presubmit 31except ImportError: 32 # Append the pw_presubmit package path to the module search path to allow 33 # running this module without installing the pw_presubmit package. 34 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 35 import pw_presubmit 36 37import pw_package.pigweed_packages 38 39from pw_presubmit import ( 40 build, 41 cli, 42 cpp_checks, 43 format_code, 44 git_repo, 45 gitmodules, 46 call, 47 filter_paths, 48 inclusive_language, 49 keep_sorted, 50 module_owners, 51 npm_presubmit, 52 owners_checks, 53 plural, 54 presubmit, 55 PresubmitContext, 56 PresubmitFailure, 57 Programs, 58 python_checks, 59 shell_checks, 60 source_in_build, 61 todo_check, 62) 63from pw_presubmit.install_hook import install_git_hook 64 65_LOG = logging.getLogger(__name__) 66 67pw_package.pigweed_packages.initialize() 68 69# Trigger builds if files with these extensions change. 70_BUILD_FILE_FILTER = presubmit.FileFilter( 71 suffix=( 72 *format_code.C_FORMAT.extensions, 73 '.cfg', 74 '.py', 75 '.rst', 76 '.gn', 77 '.gni', 78 '.emb', 79 ) 80) 81 82_OPTIMIZATION_LEVELS = 'debug', 'size_optimized', 'speed_optimized' 83 84 85def _at_all_optimization_levels(target): 86 for level in _OPTIMIZATION_LEVELS: 87 yield f'{target}_{level}' 88 89 90# 91# Build presubmit checks 92# 93def gn_clang_build(ctx: PresubmitContext): 94 """Checks all compile targets that rely on LLVM tooling.""" 95 build_targets = [ 96 *_at_all_optimization_levels('host_clang'), 97 'cpp14_compatibility', 98 'cpp20_compatibility', 99 'asan', 100 'tsan', 101 'ubsan', 102 'runtime_sanitizers', 103 # TODO(b/234876100): msan will not work until the C++ standard library 104 # included in the sysroot has a variant built with msan. 105 ] 106 107 # clang-tidy doesn't run on Windows. 108 if sys.platform != 'win32': 109 build_targets.append('static_analysis') 110 111 # QEMU doesn't run on Windows. 112 if sys.platform != 'win32': 113 # TODO(b/244604080): For the pw::InlineString tests, qemu_clang_debug 114 # and qemu_clang_speed_optimized produce a binary too large for the 115 # QEMU target's 256KB flash. Restore debug and speed optimized 116 # builds when this is fixed. 117 build_targets.append('qemu_clang_size_optimized') 118 119 # TODO(b/240982565): SocketStream currently requires Linux. 120 if sys.platform.startswith('linux'): 121 build_targets.append('integration_tests') 122 123 build.gn_gen(ctx, pw_C_OPTIMIZATION_LEVELS=_OPTIMIZATION_LEVELS) 124 build.ninja(ctx, *build_targets) 125 126 127_HOST_COMPILER = 'gcc' if sys.platform == 'win32' else 'clang' 128 129 130@_BUILD_FILE_FILTER.apply_to_check() 131def gn_quick_build_check(ctx: PresubmitContext): 132 """Checks the state of the GN build by running gn gen and gn check.""" 133 build.gn_gen(ctx) 134 135 136@_BUILD_FILE_FILTER.apply_to_check() 137def gn_full_qemu_check(ctx: PresubmitContext): 138 build.gn_gen(ctx, pw_C_OPTIMIZATION_LEVELS=_OPTIMIZATION_LEVELS) 139 build.ninja( 140 ctx, 141 *_at_all_optimization_levels('qemu_gcc'), 142 *_at_all_optimization_levels('qemu_clang'), 143 ) 144 145 146def _gn_combined_build_check_targets() -> Sequence[str]: 147 build_targets = [ 148 'check_modules', 149 *_at_all_optimization_levels('stm32f429i'), 150 *_at_all_optimization_levels(f'host_{_HOST_COMPILER}'), 151 'python.tests', 152 'python.lint', 153 'docs', 154 'fuzzers', 155 'pigweed_pypi_distribution', 156 ] 157 158 # TODO(b/234645359): Re-enable on Windows when compatibility tests build. 159 if sys.platform != 'win32': 160 build_targets.append('cpp14_compatibility') 161 build_targets.append('cpp20_compatibility') 162 163 # clang-tidy doesn't run on Windows. 164 if sys.platform != 'win32': 165 build_targets.append('static_analysis') 166 167 # QEMU doesn't run on Windows. 168 if sys.platform != 'win32': 169 build_targets.extend(_at_all_optimization_levels('qemu_gcc')) 170 171 # TODO(b/244604080): For the pw::InlineString tests, qemu_clang_debug 172 # and qemu_clang_speed_optimized produce a binary too large for the 173 # QEMU target's 256KB flash. Restore debug and speed optimized 174 # builds when this is fixed. 175 build_targets.append('qemu_clang_size_optimized') 176 177 # TODO(b/240982565): SocketStream currently requires Linux. 178 if sys.platform.startswith('linux'): 179 build_targets.append('integration_tests') 180 181 return build_targets 182 183 184gn_combined_build_check = build.GnGenNinja( 185 name='gn_combined_build_check', 186 doc='Run most host and device (QEMU) tests.', 187 path_filter=_BUILD_FILE_FILTER, 188 gn_args=dict(pw_C_OPTIMIZATION_LEVELS=_OPTIMIZATION_LEVELS), 189 ninja_targets=_gn_combined_build_check_targets(), 190) 191 192 193@_BUILD_FILE_FILTER.apply_to_check() 194def gn_arm_build(ctx: PresubmitContext): 195 build.gn_gen(ctx, pw_C_OPTIMIZATION_LEVELS=_OPTIMIZATION_LEVELS) 196 build.ninja(ctx, *_at_all_optimization_levels('stm32f429i')) 197 198 199stm32f429i = build.GnGenNinja( 200 name='stm32f429i', 201 path_filter=_BUILD_FILE_FILTER, 202 gn_args={ 203 'pw_use_test_server': True, 204 'pw_C_OPTIMIZATION_LEVELS': _OPTIMIZATION_LEVELS, 205 }, 206 ninja_contexts=( 207 lambda ctx: build.test_server( 208 'stm32f429i_disc1_test_server', 209 ctx.output_dir, 210 ), 211 ), 212 ninja_targets=_at_all_optimization_levels('stm32f429i'), 213) 214 215 216gn_emboss_build = build.GnGenNinja( 217 name='gn_emboss_build', 218 packages=('emboss',), 219 gn_args=dict( 220 dir_pw_third_party_emboss=lambda ctx: '"{}"'.format( 221 ctx.package_root / 'emboss' 222 ), 223 pw_C_OPTIMIZATION_LEVELS=_OPTIMIZATION_LEVELS, 224 ), 225 ninja_targets=(*_at_all_optimization_levels(f'host_{_HOST_COMPILER}'),), 226) 227 228gn_nanopb_build = build.GnGenNinja( 229 name='gn_nanopb_build', 230 path_filter=_BUILD_FILE_FILTER, 231 packages=('nanopb',), 232 gn_args=dict( 233 dir_pw_third_party_nanopb=lambda ctx: '"{}"'.format( 234 ctx.package_root / 'nanopb' 235 ), 236 pw_C_OPTIMIZATION_LEVELS=_OPTIMIZATION_LEVELS, 237 ), 238 ninja_targets=( 239 *_at_all_optimization_levels('stm32f429i'), 240 *_at_all_optimization_levels('host_clang'), 241 ), 242) 243 244gn_crypto_mbedtls_build = build.GnGenNinja( 245 name='gn_crypto_mbedtls_build', 246 path_filter=_BUILD_FILE_FILTER, 247 packages=('mbedtls',), 248 gn_args={ 249 'dir_pw_third_party_mbedtls': lambda ctx: '"{}"'.format( 250 ctx.package_root / 'mbedtls' 251 ), 252 'pw_crypto_SHA256_BACKEND': lambda ctx: '"{}"'.format( 253 ctx.root / 'pw_crypto:sha256_mbedtls' 254 ), 255 'pw_crypto_ECDSA_BACKEND': lambda ctx: '"{}"'.format( 256 ctx.root / 'pw_crypto:ecdsa_mbedtls' 257 ), 258 'pw_C_OPTIMIZATION_LEVELS': _OPTIMIZATION_LEVELS, 259 }, 260 ninja_targets=( 261 *_at_all_optimization_levels(f'host_{_HOST_COMPILER}'), 262 # TODO(b/240982565): SocketStream currently requires Linux. 263 *(('integration_tests',) if sys.platform.startswith('linux') else ()), 264 ), 265) 266 267gn_crypto_boringssl_build = build.GnGenNinja( 268 name='gn_crypto_boringssl_build', 269 path_filter=_BUILD_FILE_FILTER, 270 packages=('boringssl',), 271 gn_args={ 272 'dir_pw_third_party_boringssl': lambda ctx: '"{}"'.format( 273 ctx.package_root / 'boringssl' 274 ), 275 'pw_crypto_SHA256_BACKEND': lambda ctx: '"{}"'.format( 276 ctx.root / 'pw_crypto:sha256_boringssl' 277 ), 278 'pw_crypto_ECDSA_BACKEND': lambda ctx: '"{}"'.format( 279 ctx.root / 'pw_crypto:ecdsa_boringssl' 280 ), 281 'pw_C_OPTIMIZATION_LEVELS': _OPTIMIZATION_LEVELS, 282 }, 283 ninja_targets=( 284 *_at_all_optimization_levels(f'host_{_HOST_COMPILER}'), 285 # TODO(b/240982565): SocketStream currently requires Linux. 286 *(('integration_tests',) if sys.platform.startswith('linux') else ()), 287 ), 288) 289 290gn_crypto_micro_ecc_build = build.GnGenNinja( 291 name='gn_crypto_micro_ecc_build', 292 path_filter=_BUILD_FILE_FILTER, 293 packages=('micro-ecc',), 294 gn_args={ 295 'dir_pw_third_party_micro_ecc': lambda ctx: '"{}"'.format( 296 ctx.package_root / 'micro-ecc' 297 ), 298 'pw_crypto_ECDSA_BACKEND': lambda ctx: '"{}"'.format( 299 ctx.root / 'pw_crypto:ecdsa_uecc' 300 ), 301 'pw_C_OPTIMIZATION_LEVELS': _OPTIMIZATION_LEVELS, 302 }, 303 ninja_targets=( 304 *_at_all_optimization_levels(f'host_{_HOST_COMPILER}'), 305 # TODO(b/240982565): SocketStream currently requires Linux. 306 *(('integration_tests',) if sys.platform.startswith('linux') else ()), 307 ), 308) 309 310gn_teensy_build = build.GnGenNinja( 311 name='gn_teensy_build', 312 path_filter=_BUILD_FILE_FILTER, 313 packages=('teensy',), 314 gn_args={ 315 'pw_arduino_build_CORE_PATH': lambda ctx: '"{}"'.format( 316 str(ctx.package_root) 317 ), 318 'pw_arduino_build_CORE_NAME': 'teensy', 319 'pw_arduino_build_PACKAGE_NAME': 'teensy/avr', 320 'pw_arduino_build_BOARD': 'teensy40', 321 'pw_C_OPTIMIZATION_LEVELS': _OPTIMIZATION_LEVELS, 322 }, 323 ninja_targets=_at_all_optimization_levels('arduino'), 324) 325 326gn_pico_build = build.GnGenNinja( 327 name='gn_pico_build', 328 path_filter=_BUILD_FILE_FILTER, 329 packages=('pico_sdk',), 330 gn_args={ 331 'PICO_SRC_DIR': lambda ctx: '"{}"'.format( 332 str(ctx.package_root / 'pico_sdk') 333 ), 334 'pw_C_OPTIMIZATION_LEVELS': _OPTIMIZATION_LEVELS, 335 }, 336 ninja_targets=('pi_pico',), 337) 338 339 340gn_software_update_build = build.GnGenNinja( 341 name='gn_software_update_build', 342 path_filter=_BUILD_FILE_FILTER, 343 packages=('nanopb', 'protobuf', 'mbedtls', 'micro-ecc'), 344 gn_args={ 345 'dir_pw_third_party_protobuf': lambda ctx: '"{}"'.format( 346 ctx.package_root / 'protobuf' 347 ), 348 'dir_pw_third_party_nanopb': lambda ctx: '"{}"'.format( 349 ctx.package_root / 'nanopb' 350 ), 351 'dir_pw_third_party_micro_ecc': lambda ctx: '"{}"'.format( 352 ctx.package_root / 'micro-ecc' 353 ), 354 'pw_crypto_ECDSA_BACKEND': lambda ctx: '"{}"'.format( 355 ctx.root / 'pw_crypto:ecdsa_uecc' 356 ), 357 'dir_pw_third_party_mbedtls': lambda ctx: '"{}"'.format( 358 ctx.package_root / 'mbedtls' 359 ), 360 'pw_crypto_SHA256_BACKEND': lambda ctx: '"{}"'.format( 361 ctx.root / 'pw_crypto:sha256_mbedtls' 362 ), 363 'pw_C_OPTIMIZATION_LEVELS': _OPTIMIZATION_LEVELS, 364 }, 365 ninja_targets=_at_all_optimization_levels('host_clang'), 366) 367 368gn_pw_system_demo_build = build.GnGenNinja( 369 name='gn_pw_system_demo_build', 370 path_filter=_BUILD_FILE_FILTER, 371 packages=('freertos', 'nanopb', 'stm32cube_f4', 'pico_sdk'), 372 gn_args={ 373 'dir_pw_third_party_freertos': lambda ctx: '"{}"'.format( 374 ctx.package_root / 'freertos' 375 ), 376 'dir_pw_third_party_nanopb': lambda ctx: '"{}"'.format( 377 ctx.package_root / 'nanopb' 378 ), 379 'dir_pw_third_party_stm32cube_f4': lambda ctx: '"{}"'.format( 380 ctx.package_root / 'stm32cube_f4' 381 ), 382 'PICO_SRC_DIR': lambda ctx: '"{}"'.format( 383 str(ctx.package_root / 'pico_sdk') 384 ), 385 }, 386 ninja_targets=('pw_system_demo',), 387) 388 389gn_docs_build = build.GnGenNinja(name='gn_docs_build', ninja_targets=('docs',)) 390 391gn_host_tools = build.GnGenNinja( 392 name='gn_host_tools', 393 ninja_targets=('host_tools',), 394) 395 396 397def _run_cmake(ctx: PresubmitContext, toolchain='host_clang') -> None: 398 build.install_package(ctx, 'nanopb') 399 400 env = None 401 if 'clang' in toolchain: 402 env = build.env_with_clang_vars() 403 404 toolchain_path = ctx.root / 'pw_toolchain' / toolchain / 'toolchain.cmake' 405 build.cmake( 406 ctx, 407 f'-DCMAKE_TOOLCHAIN_FILE={toolchain_path}', 408 '-DCMAKE_EXPORT_COMPILE_COMMANDS=1', 409 f'-Ddir_pw_third_party_nanopb={ctx.package_root / "nanopb"}', 410 '-Dpw_third_party_nanopb_ADD_SUBDIRECTORY=ON', 411 env=env, 412 ) 413 414 415@filter_paths( 416 endswith=(*format_code.C_FORMAT.extensions, '.cmake', 'CMakeLists.txt') 417) 418def cmake_clang(ctx: PresubmitContext): 419 _run_cmake(ctx, toolchain='host_clang') 420 build.ninja(ctx, 'pw_apps', 'pw_run_tests.modules') 421 422 423@filter_paths( 424 endswith=(*format_code.C_FORMAT.extensions, '.cmake', 'CMakeLists.txt') 425) 426def cmake_gcc(ctx: PresubmitContext): 427 _run_cmake(ctx, toolchain='host_gcc') 428 build.ninja(ctx, 'pw_apps', 'pw_run_tests.modules') 429 430 431@filter_paths( 432 endswith=(*format_code.C_FORMAT.extensions, '.bazel', '.bzl', 'BUILD') 433) 434def bazel_test(ctx: PresubmitContext) -> None: 435 """Runs bazel test on the entire repo.""" 436 build.bazel( 437 ctx, 438 'test', 439 '--test_output=errors', 440 '--', 441 '//...', 442 ) 443 444 445@filter_paths( 446 endswith=( 447 *format_code.C_FORMAT.extensions, 448 '.bazel', 449 '.bzl', 450 '.py', 451 '.rs', 452 'BUILD', 453 ) 454) 455def bazel_build(ctx: PresubmitContext) -> None: 456 """Runs Bazel build for each supported platform.""" 457 # Build everything with the default flags. 458 build.bazel( 459 ctx, 460 'build', 461 '--', 462 '//...', 463 ) 464 465 # Mapping from Bazel platforms to targets which should be built for those 466 # platforms. 467 targets_for_platform = { 468 "//pw_build/platforms:lm3s6965evb": [ 469 "//pw_rust/examples/embedded_hello:hello", 470 ], 471 "//pw_build/platforms:microbit": [ 472 "//pw_rust/examples/embedded_hello:hello", 473 ], 474 } 475 476 for cxxversion in ('c++17', 'c++20'): 477 # Explicitly build for each supported C++ version. 478 build.bazel( 479 ctx, 480 'build', 481 f"--cxxopt=-std={cxxversion}", 482 '--', 483 '//...', 484 ) 485 486 for platform, targets in targets_for_platform.items(): 487 build.bazel( 488 ctx, 489 'build', 490 f'--platforms={platform}', 491 f"--cxxopt='-std={cxxversion}'", 492 *targets, 493 ) 494 495 # Provide some coverage of the FreeRTOS build. 496 # 497 # This is just a minimal presubmit intended to ensure we don't break what 498 # support we have. 499 # 500 # TODO(b/271465588): Eventually just build the entire repo for this 501 # platform. 502 build.bazel( 503 ctx, 504 'build', 505 # Designated initializers produce a warning-treated-as-error when 506 # compiled with -std=c++17. 507 # 508 # TODO(b/271299438): Remove this. 509 '--copt=-Wno-pedantic', 510 '--platforms=//pw_build/platforms:testonly_freertos', 511 '//pw_sync/...', 512 '//pw_thread/...', 513 '//pw_thread_freertos/...', 514 ) 515 516 517def pw_transfer_integration_test(ctx: PresubmitContext) -> None: 518 """Runs the pw_transfer cross-language integration test only. 519 520 This test is not part of the regular bazel build because it's slow and 521 intended to run in CI only. 522 """ 523 build.bazel( 524 ctx, 525 'test', 526 '//pw_transfer/integration_test:cross_language_small_test', 527 '//pw_transfer/integration_test:cross_language_medium_read_test', 528 '//pw_transfer/integration_test:cross_language_medium_write_test', 529 '//pw_transfer/integration_test:cross_language_large_read_test', 530 '//pw_transfer/integration_test:cross_language_large_write_test', 531 '//pw_transfer/integration_test:multi_transfer_test', 532 '//pw_transfer/integration_test:expected_errors_test', 533 '//pw_transfer/integration_test:legacy_binaries_test', 534 '--test_output=errors', 535 ) 536 537 538# 539# General presubmit checks 540# 541 542 543def _clang_system_include_paths(lang: str) -> List[str]: 544 """Generate default system header paths. 545 546 Returns the list of system include paths used by the host 547 clang installation. 548 """ 549 # Dump system include paths with preprocessor verbose. 550 command = [ 551 'clang++', 552 '-Xpreprocessor', 553 '-v', 554 '-x', 555 f'{lang}', 556 f'{os.devnull}', 557 '-fsyntax-only', 558 ] 559 process = subprocess.run( 560 command, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT 561 ) 562 563 # Parse the command output to retrieve system include paths. 564 # The paths are listed one per line. 565 output = process.stdout.decode(errors='backslashreplace') 566 include_paths: List[str] = [] 567 for line in output.splitlines(): 568 path = line.strip() 569 if os.path.exists(path): 570 include_paths.append(f'-isystem{path}') 571 572 return include_paths 573 574 575def edit_compile_commands( 576 in_path: Path, out_path: Path, func: Callable[[str, str, str], str] 577) -> None: 578 """Edit the selected compile command file. 579 580 Calls the input callback on all triplets (file, directory, command) in 581 the input compile commands database. The return value replaces the old 582 compile command in the output database. 583 """ 584 with open(in_path) as in_file: 585 compile_commands = json.load(in_file) 586 for item in compile_commands: 587 item['command'] = func( 588 item['file'], item['directory'], item['command'] 589 ) 590 with open(out_path, 'w') as out_file: 591 json.dump(compile_commands, out_file, indent=2) 592 593 594_EXCLUDE_FROM_COPYRIGHT_NOTICE: Sequence[str] = ( 595 # Configuration 596 # keep-sorted: start 597 r'\bDoxyfile$', 598 r'\bPW_PLUGINS$', 599 r'\bconstraint.list$', 600 r'^(?:.+/)?\..+$', 601 # keep-sorted: end 602 # Metadata 603 # keep-sorted: start 604 r'\bAUTHORS$', 605 r'\bLICENSE$', 606 r'\bOWNERS$', 607 r'\bPIGWEED_MODULES$', 608 r'\bgo.(mod|sum)$', 609 r'\bpackage-lock.json$', 610 r'\bpackage.json$', 611 r'\brequirements.txt$', 612 r'\byarn.lock$', 613 r'^docker/tag$', 614 # keep-sorted: end 615 # Data files 616 # keep-sorted: start 617 r'\.bin$', 618 r'\.csv$', 619 r'\.elf$', 620 r'\.gif$', 621 r'\.ico$', 622 r'\.jpg$', 623 r'\.json$', 624 r'\.png$', 625 r'\.svg$', 626 r'\.xml$', 627 # keep-sorted: end 628 # Documentation 629 # keep-sorted: start 630 r'\.md$', 631 r'\.rst$', 632 # keep-sorted: end 633 # Generated protobuf files 634 # keep-sorted: start 635 r'\.pb\.c$', 636 r'\.pb\.h$', 637 r'\_pb2.pyi?$', 638 # keep-sorted: end 639 # Diff/Patch files 640 # keep-sorted: start 641 r'\.diff$', 642 r'\.patch$', 643 # keep-sorted: end 644 # Test data 645 # keep-sorted: start 646 r'\bpw_presubmit/py/test/owners_checks/', 647 # keep-sorted: end 648) 649 650# Regular expression for the copyright comment. "\1" refers to the comment 651# characters and "\2" refers to space after the comment characters, if any. 652# All period characters are escaped using a replace call. 653# pylint: disable=line-too-long 654_COPYRIGHT = re.compile( 655 r"""(#|//|::| \*|)( ?)Copyright 2\d{3} The Pigweed Authors 656\1 657\1\2Licensed under the Apache License, Version 2.0 \(the "License"\); you may not 658\1\2use this file except in compliance with the License. You may obtain a copy of 659\1\2the License at 660\1 661\1(?:\2 |\t)https://www.apache.org/licenses/LICENSE-2.0 662\1 663\1\2Unless required by applicable law or agreed to in writing, software 664\1\2distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 665\1\2WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 666\1\2License for the specific language governing permissions and limitations under 667\1\2the License. 668""".replace( 669 '.', r'\.' 670 ), 671 re.MULTILINE, 672) 673# pylint: enable=line-too-long 674 675_SKIP_LINE_PREFIXES = ( 676 '#!', 677 '@echo off', 678 ':<<', 679 '/*', 680 ' * @jest-environment jsdom', 681 ' */', 682 '{#', # Jinja comment block 683 '# -*- coding: utf-8 -*-', 684 '<!--', 685) 686 687 688def _read_notice_lines(file: TextIO) -> Iterable[str]: 689 lines = iter(file) 690 try: 691 # Read until the first line of the copyright notice. 692 line = next(lines) 693 while line.isspace() or line.startswith(_SKIP_LINE_PREFIXES): 694 line = next(lines) 695 696 yield line 697 698 for _ in range(12): # The notice is 13 lines; read the remaining 12. 699 yield next(lines) 700 except StopIteration: 701 return 702 703 704@filter_paths(exclude=_EXCLUDE_FROM_COPYRIGHT_NOTICE) 705def copyright_notice(ctx: PresubmitContext): 706 """Checks that the Pigweed copyright notice is present.""" 707 errors = [] 708 709 for path in ctx.paths: 710 if path.stat().st_size == 0: 711 continue # Skip empty files 712 713 with path.open() as file: 714 if not _COPYRIGHT.match(''.join(_read_notice_lines(file))): 715 errors.append(path) 716 717 if errors: 718 _LOG.warning( 719 '%s with a missing or incorrect copyright notice:\n%s', 720 plural(errors, 'file'), 721 '\n'.join(str(e) for e in errors), 722 ) 723 raise PresubmitFailure 724 725 726@filter_paths(endswith=format_code.CPP_SOURCE_EXTS) 727def source_is_in_cmake_build_warn_only(ctx: PresubmitContext): 728 """Checks that source files are in the CMake build.""" 729 730 _run_cmake(ctx) 731 missing = build.check_compile_commands_for_files( 732 ctx.output_dir / 'compile_commands.json', 733 (f for f in ctx.paths if f.suffix in format_code.CPP_SOURCE_EXTS), 734 ) 735 if missing: 736 _LOG.warning( 737 'Files missing from CMake:\n%s', 738 '\n'.join(str(f) for f in missing), 739 ) 740 741 742def build_env_setup(ctx: PresubmitContext): 743 if 'PW_CARGO_SETUP' not in os.environ: 744 _LOG.warning('Skipping build_env_setup since PW_CARGO_SETUP is not set') 745 return 746 747 tmpl = ctx.root.joinpath('pw_env_setup', 'py', 'pyoxidizer.bzl.tmpl') 748 out = ctx.output_dir.joinpath('pyoxidizer.bzl') 749 750 with open(tmpl, 'r') as ins: 751 cfg = ins.read().replace('${PW_ROOT}', str(ctx.root)) 752 with open(out, 'w') as outs: 753 outs.write(cfg) 754 755 call('pyoxidizer', 'build', cwd=ctx.output_dir) 756 757 758def _valid_capitalization(word: str) -> bool: 759 """Checks that the word has a capital letter or is not a regular word.""" 760 return bool( 761 any(c.isupper() for c in word) # Any capitalizatian (iTelephone) 762 or not word.isalpha() # Non-alphabetical (cool_stuff.exe) 763 or shutil.which(word) 764 ) # Matches an executable (clangd) 765 766 767def commit_message_format(_: PresubmitContext): 768 """Checks that the top commit's message is correctly formatted.""" 769 if git_repo.commit_author().endswith('gserviceaccount.com'): 770 return 771 772 lines = git_repo.commit_message().splitlines() 773 774 # Show limits and current commit message in log. 775 _LOG.debug('%-25s%+25s%+22s', 'Line limits', '72|', '72|') 776 for line in lines: 777 _LOG.debug(line) 778 779 if not lines: 780 _LOG.error('The commit message is too short!') 781 raise PresubmitFailure 782 783 # Ignore Gerrit-generated reverts. 784 if ( 785 'Revert' in lines[0] 786 and 'This reverts commit ' in git_repo.commit_message() 787 and 'Reason for revert: ' in git_repo.commit_message() 788 ): 789 _LOG.warning('Ignoring apparent Gerrit-generated revert') 790 return 791 792 # Ignore Gerrit-generated relands 793 if ( 794 'Reland' in lines[0] 795 and 'This is a reland of ' in git_repo.commit_message() 796 and "Original change's description: " in git_repo.commit_message() 797 ): 798 _LOG.warning('Ignoring apparent Gerrit-generated reland') 799 return 800 801 errors = 0 802 803 if len(lines[0]) > 72: 804 _LOG.warning( 805 "The commit message's first line must be no longer than " 806 '72 characters.' 807 ) 808 _LOG.warning( 809 'The first line is %d characters:\n %s', len(lines[0]), lines[0] 810 ) 811 errors += 1 812 813 if lines[0].endswith('.'): 814 _LOG.warning( 815 "The commit message's first line must not end with a period:\n %s", 816 lines[0], 817 ) 818 errors += 1 819 820 # Check that the first line matches the expected pattern. 821 match = re.match( 822 r'^(?:[\w*/]+(?:{[\w* ,]+})?[\w*/]*|SEED-\d+): (?P<desc>.+)$', lines[0] 823 ) 824 if not match: 825 _LOG.warning('The first line does not match the expected format') 826 _LOG.warning( 827 'Expected:\n\n module_or_target: The description\n\n' 828 'Found:\n\n %s\n', 829 lines[0], 830 ) 831 errors += 1 832 elif not _valid_capitalization(match.group('desc').split()[0]): 833 _LOG.warning( 834 'The first word after the ":" in the first line ("%s") must be ' 835 'capitalized:\n %s', 836 match.group('desc').split()[0], 837 lines[0], 838 ) 839 errors += 1 840 841 if len(lines) > 1 and lines[1]: 842 _LOG.warning("The commit message's second line must be blank.") 843 _LOG.warning( 844 'The second line has %d characters:\n %s', len(lines[1]), lines[1] 845 ) 846 errors += 1 847 848 # Ignore the line length check for Copybara imports so they can include the 849 # commit hash and description for imported commits. 850 if not errors and ( 851 'Copybara import' in lines[0] 852 and 'GitOrigin-RevId:' in git_repo.commit_message() 853 ): 854 _LOG.warning('Ignoring Copybara import') 855 return 856 857 # Check that the lines are 72 characters or less. 858 for i, line in enumerate(lines[2:], 3): 859 # Skip any lines that might possibly have a URL, path, or metadata in 860 # them. 861 if any(c in line for c in ':/>'): 862 continue 863 864 # Skip any lines with non-ASCII characters. 865 if not line.isascii(): 866 continue 867 868 # Skip any blockquoted lines. 869 if line.startswith(' '): 870 continue 871 872 if len(line) > 72: 873 _LOG.warning( 874 'Commit message lines must be no longer than 72 characters.' 875 ) 876 _LOG.warning('Line %d has %d characters:\n %s', i, len(line), line) 877 errors += 1 878 879 if errors: 880 _LOG.error('Found %s in the commit message', plural(errors, 'error')) 881 raise PresubmitFailure 882 883 884@filter_paths(endswith=(*format_code.C_FORMAT.extensions, '.py')) 885def static_analysis(ctx: PresubmitContext): 886 """Runs all available static analysis tools.""" 887 build.gn_gen(ctx) 888 build.ninja(ctx, 'python.lint', 'static_analysis') 889 890 891_EXCLUDE_FROM_TODO_CHECK = ( 892 # keep-sorted: start 893 r'.bazelrc$', 894 r'.dockerignore$', 895 r'.gitignore$', 896 r'.pylintrc$', 897 r'\bdocs/build_system.rst', 898 r'\bpw_assert_basic/basic_handler.cc', 899 r'\bpw_assert_basic/public/pw_assert_basic/handler.h', 900 r'\bpw_blob_store/public/pw_blob_store/flat_file_system_entry.h', 901 r'\bpw_build/linker_script.gni', 902 r'\bpw_build/py/pw_build/copy_from_cipd.py', 903 r'\bpw_cpu_exception/basic_handler.cc', 904 r'\bpw_cpu_exception_cortex_m/entry.cc', 905 r'\bpw_cpu_exception_cortex_m/exception_entry_test.cc', 906 r'\bpw_doctor/py/pw_doctor/doctor.py', 907 r'\bpw_env_setup/util.sh', 908 r'\bpw_fuzzer/fuzzer.gni', 909 r'\bpw_i2c/BUILD.gn', 910 r'\bpw_i2c/public/pw_i2c/register_device.h', 911 r'\bpw_kvs/flash_memory.cc', 912 r'\bpw_kvs/key_value_store.cc', 913 r'\bpw_log_basic/log_basic.cc', 914 r'\bpw_package/py/pw_package/packages/chromium_verifier.py', 915 r'\bpw_protobuf/encoder.cc', 916 r'\bpw_rpc/docs.rst', 917 r'\bpw_watch/py/pw_watch/watch.py', 918 r'\btargets/mimxrt595_evk/BUILD.bazel', 919 r'\btargets/stm32f429i_disc1/boot.cc', 920 r'\bthird_party/chromium_verifier/BUILD.gn', 921 # keep-sorted: end 922) 923 924 925@filter_paths(exclude=_EXCLUDE_FROM_TODO_CHECK) 926def todo_check_with_exceptions(ctx: PresubmitContext): 927 """Check that non-legacy TODO lines are valid.""" # todo-check: ignore 928 todo_check.create(todo_check.BUGS_OR_USERNAMES)(ctx) 929 930 931@format_code.OWNERS_CODE_FORMAT.filter.apply_to_check() 932def owners_lint_checks(ctx: PresubmitContext): 933 """Runs OWNERS linter.""" 934 owners_checks.presubmit_check(ctx.paths) 935 936 937SOURCE_FILES_FILTER = presubmit.FileFilter( 938 endswith=_BUILD_FILE_FILTER.endswith, 939 suffix=('.bazel', '.bzl', '.gn', '.gni', *_BUILD_FILE_FILTER.suffix), 940 exclude=( 941 r'zephyr.*', 942 r'android.*', 943 r'\.black.toml', 944 r'pyproject.toml', 945 ), 946) 947 948 949# 950# Presubmit check programs 951# 952 953OTHER_CHECKS = ( 954 # keep-sorted: start 955 # TODO(b/235277910): Enable all Bazel tests when they're fixed. 956 bazel_test, 957 build.gn_gen_check, 958 cmake_clang, 959 cmake_gcc, 960 gitmodules.create(), 961 gn_clang_build, 962 gn_combined_build_check, 963 module_owners.presubmit_check(), 964 npm_presubmit.npm_test, 965 pw_transfer_integration_test, 966 # TODO(hepler): Many files are missing from the CMake build. Add this check 967 # to lintformat when the missing files are fixed. 968 source_in_build.cmake(SOURCE_FILES_FILTER, _run_cmake), 969 static_analysis, 970 stm32f429i, 971 todo_check.create(todo_check.BUGS_OR_USERNAMES), 972 # keep-sorted: end 973) 974 975# The misc program differs from other_checks in that checks in the misc 976# program block CQ on Linux. 977MISC = ( 978 # keep-sorted: start 979 gn_emboss_build, 980 gn_nanopb_build, 981 gn_pico_build, 982 gn_pw_system_demo_build, 983 gn_teensy_build, 984 # keep-sorted: end 985) 986 987SANITIZERS = (cpp_checks.all_sanitizers(),) 988 989SECURITY = ( 990 # keep-sorted: start 991 gn_crypto_boringssl_build, 992 gn_crypto_mbedtls_build, 993 gn_crypto_micro_ecc_build, 994 gn_software_update_build, 995 # keep-sorted: end 996) 997 998# Avoid running all checks on specific paths. 999PATH_EXCLUSIONS = (re.compile(r'\bthird_party/fuchsia/repo/'),) 1000 1001_LINTFORMAT = ( 1002 commit_message_format, 1003 copyright_notice, 1004 format_code.presubmit_checks(), 1005 inclusive_language.presubmit_check.with_filter( 1006 exclude=( 1007 r'\byarn.lock$', 1008 r'\bpackage-lock.json$', 1009 ) 1010 ), 1011 cpp_checks.pragma_once, 1012 build.bazel_lint, 1013 owners_lint_checks, 1014 source_in_build.gn(SOURCE_FILES_FILTER), 1015 source_is_in_cmake_build_warn_only, 1016 shell_checks.shellcheck if shutil.which('shellcheck') else (), 1017 keep_sorted.presubmit_check, 1018 todo_check_with_exceptions, 1019) 1020 1021LINTFORMAT = ( 1022 _LINTFORMAT, 1023 # This check is excluded from _LINTFORMAT because it's not quick: it issues 1024 # a bazel query that pulls in all of Pigweed's external dependencies 1025 # (https://stackoverflow.com/q/71024130/1224002). These are cached, but 1026 # after a roll it can be quite slow. 1027 source_in_build.bazel(SOURCE_FILES_FILTER), 1028 pw_presubmit.python_checks.check_python_versions, 1029 pw_presubmit.python_checks.gn_python_lint, 1030) 1031 1032QUICK = ( 1033 _LINTFORMAT, 1034 gn_quick_build_check, 1035 # TODO(b/34884583): Re-enable CMake and Bazel for Mac after we have fixed 1036 # the clang issues. The problem is that all clang++ invocations need the 1037 # two extra flags: "-nostdc++" and "${clang_prefix}/../lib/libc++.a". 1038 cmake_clang if sys.platform != 'darwin' else (), 1039) 1040 1041FULL = ( 1042 _LINTFORMAT, 1043 gn_combined_build_check, 1044 gn_host_tools, 1045 bazel_test if sys.platform == 'linux' else (), 1046 bazel_build if sys.platform == 'linux' else (), 1047 python_checks.gn_python_check, 1048 python_checks.gn_python_test_coverage, 1049 build_env_setup, 1050 # Skip gn_teensy_build if running on Windows. The Teensycore installer is 1051 # an exe that requires an admin role. 1052 gn_teensy_build if sys.platform in ['linux', 'darwin'] else (), 1053) 1054 1055PROGRAMS = Programs( 1056 # keep-sorted: start 1057 full=FULL, 1058 lintformat=LINTFORMAT, 1059 misc=MISC, 1060 other_checks=OTHER_CHECKS, 1061 quick=QUICK, 1062 sanitizers=SANITIZERS, 1063 security=SECURITY, 1064 # keep-sorted: end 1065) 1066 1067 1068def parse_args() -> argparse.Namespace: 1069 """Creates an argument parser and parses arguments.""" 1070 1071 parser = argparse.ArgumentParser(description=__doc__) 1072 cli.add_arguments(parser, PROGRAMS, 'quick') 1073 parser.add_argument( 1074 '--install', 1075 action='store_true', 1076 help='Install the presubmit as a Git pre-push hook and exit.', 1077 ) 1078 1079 return parser.parse_args() 1080 1081 1082def run(install: bool, exclude: list, **presubmit_args) -> int: 1083 """Entry point for presubmit.""" 1084 1085 if install: 1086 install_git_hook( 1087 'pre-push', 1088 [ 1089 'python', 1090 '-m', 1091 'pw_presubmit.pigweed_presubmit', 1092 '--base', 1093 'origin/main..HEAD', 1094 '--program', 1095 'quick', 1096 ], 1097 ) 1098 return 0 1099 1100 exclude.extend(PATH_EXCLUSIONS) 1101 return cli.run(exclude=exclude, **presubmit_args) 1102 1103 1104def main() -> int: 1105 """Run the presubmit for the Pigweed repository.""" 1106 return run(**vars(parse_args())) 1107 1108 1109if __name__ == '__main__': 1110 try: 1111 # If pw_cli is available, use it to initialize logs. 1112 from pw_cli import log 1113 1114 log.install(logging.INFO) 1115 except ImportError: 1116 # If pw_cli isn't available, display log messages like a simple print. 1117 logging.basicConfig(format='%(message)s', level=logging.INFO) 1118 1119 sys.exit(main()) 1120