1# Copyright 2020 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""Functions for building code during presubmit checks.""" 15 16import base64 17import contextlib 18from dataclasses import dataclass 19import io 20import itertools 21import json 22import logging 23import os 24import posixpath 25from pathlib import Path 26import re 27import subprocess 28from shutil import which 29import sys 30import tarfile 31from typing import ( 32 Any, 33 Callable, 34 Collection, 35 Container, 36 ContextManager, 37 Iterable, 38 Iterator, 39 Mapping, 40 Sequence, 41 Set, 42) 43 44import pw_cli.color 45from pw_cli.plural import plural 46from pw_cli.file_filter import FileFilter 47from pw_presubmit.presubmit import ( 48 call, 49 Check, 50 filter_paths, 51 install_package, 52 PresubmitResult, 53 SubStep, 54) 55from pw_presubmit.presubmit_context import ( 56 LuciContext, 57 LuciTrigger, 58 PresubmitContext, 59 PresubmitFailure, 60) 61from pw_presubmit import ( 62 bazel_parser, 63 format_code, 64 ninja_parser, 65) 66from pw_presubmit.tools import ( 67 log_run, 68 format_command, 69) 70 71_LOG = logging.getLogger(__name__) 72 73 74BAZEL_EXECUTABLE = 'bazel' 75 76 77def _get_remote_instance_name(ctx_luci: LuciContext) -> str: 78 instance_name = '' 79 if ctx_luci.project == 'pigweed': 80 instance_name = 'pigweed-rbe-open' 81 else: 82 instance_name = 'pigweed-rbe-private' 83 if ctx_luci.is_try: 84 instance_name += '-pre' 85 86 # pylint: disable-next=line-too-long 87 return f'--remote_instance_name=projects/{instance_name}/instances/default-instance' 88 89 90def bazel( 91 ctx: PresubmitContext, 92 cmd: str, 93 *args: str, 94 remote_download_outputs: str = 'minimal', 95 stdout: io.TextIOWrapper | None = None, 96 strict_module_lockfile: bool = False, 97 use_remote_cache: bool = False, 98 **kwargs, 99) -> None: 100 """Invokes Bazel with some common flags set. 101 102 Intended for use with bazel build and test. May not work with others. 103 """ 104 105 num_jobs: list[str] = [] 106 if ctx.num_jobs is not None: 107 num_jobs.extend(('--jobs', str(ctx.num_jobs))) 108 109 keep_going: list[str] = [] 110 if ctx.continue_after_build_error: 111 keep_going.append('--keep_going') 112 113 strict_lockfile: list[str] = [] 114 if strict_module_lockfile: 115 strict_lockfile.append('--lockfile_mode=error') 116 117 remote_cache: list[str] = [] 118 if use_remote_cache and ctx.luci: 119 remote_cache.append('--config=remote_cache') 120 remote_cache.append('--remote_upload_local_results=true') 121 remote_cache.append(_get_remote_instance_name(ctx.luci)) 122 remote_cache.append( 123 f'--remote_download_outputs={remote_download_outputs}' 124 ) 125 126 symlink_prefix: list[str] = [] 127 if cmd not in ('mod', 'query'): 128 # bazel query and bazel mod don't support the --symlink_prefix flag. 129 symlink_prefix.append(f'--symlink_prefix={ctx.output_dir / "bazel-"}') 130 131 ctx.output_dir.mkdir(exist_ok=True, parents=True) 132 try: 133 with contextlib.ExitStack() as stack: 134 if not stdout: 135 stdout = stack.enter_context( 136 (ctx.output_dir / f'bazel.{cmd}.stdout').open('w') 137 ) 138 139 with (ctx.output_dir / 'bazel.output.base').open('w') as outs, ( 140 ctx.output_dir / 'bazel.output.base.err' 141 ).open('w') as errs: 142 call( 143 BAZEL_EXECUTABLE, 144 'info', 145 'output_base', 146 tee=outs, 147 stderr=errs, 148 ) 149 150 call( 151 BAZEL_EXECUTABLE, 152 cmd, 153 *symlink_prefix, 154 *num_jobs, 155 *keep_going, 156 *strict_lockfile, 157 *remote_cache, 158 *args, 159 cwd=ctx.root, 160 tee=stdout, 161 call_annotation={'build_system': 'bazel'}, 162 **kwargs, 163 ) 164 165 except PresubmitFailure as exc: 166 if stdout: 167 failure = bazel_parser.parse_bazel_stdout(Path(stdout.name)) 168 if failure: 169 with ctx.failure_summary_log.open('w') as outs: 170 outs.write(failure) 171 172 raise exc 173 174 175def _gn_value(value) -> str: 176 if isinstance(value, bool): 177 return str(value).lower() 178 179 if ( 180 isinstance(value, str) 181 and '"' not in value 182 and not value.startswith("{") 183 and not value.startswith("[") 184 ): 185 return f'"{value}"' 186 187 if isinstance(value, (list, tuple)): 188 return f'[{", ".join(_gn_value(a) for a in value)}]' 189 190 # Fall-back case handles integers as well as strings that already 191 # contain double quotation marks, or look like scopes or lists. 192 return str(value) 193 194 195def gn_args_list(**kwargs) -> list[str]: 196 """Return a list of formatted strings to use as gn args. 197 198 Currently supports bool, int, and str values. In the case of str values, 199 quotation marks will be added automatically, unless the string already 200 contains one or more double quotation marks, or starts with a { or [ 201 character, in which case it will be passed through as-is. 202 """ 203 transformed_args = [] 204 for arg, val in kwargs.items(): 205 transformed_args.append(f'{arg}={_gn_value(val)}') 206 207 # Use ccache if available for faster repeat presubmit runs. 208 if which('ccache') and 'pw_command_launcher' not in kwargs: 209 transformed_args.append('pw_command_launcher="ccache"') 210 211 return transformed_args 212 213 214def gn_args(**kwargs) -> str: 215 """Builds a string to use for the --args argument to gn gen. 216 217 Currently supports bool, int, and str values. In the case of str values, 218 quotation marks will be added automatically, unless the string already 219 contains one or more double quotation marks, or starts with a { or [ 220 character, in which case it will be passed through as-is. 221 """ 222 return '--args=' + ' '.join(gn_args_list(**kwargs)) 223 224 225def write_gn_args_file(destination_file: Path, **kwargs) -> str: 226 """Write gn args to a file. 227 228 Currently supports bool, int, and str values. In the case of str values, 229 quotation marks will be added automatically, unless the string already 230 contains one or more double quotation marks, or starts with a { or [ 231 character, in which case it will be passed through as-is. 232 233 Returns: 234 The contents of the written file. 235 """ 236 contents = '\n'.join(gn_args_list(**kwargs)) 237 # Add a trailing linebreak 238 contents += '\n' 239 destination_file.parent.mkdir(exist_ok=True, parents=True) 240 241 if ( 242 destination_file.is_file() 243 and destination_file.read_text(encoding='utf-8') == contents 244 ): 245 # File is identical, don't re-write. 246 return contents 247 248 destination_file.write_text(contents, encoding='utf-8') 249 return contents 250 251 252def gn_gen( 253 ctx: PresubmitContext, 254 *args: str, 255 gn_check: bool = True, # pylint: disable=redefined-outer-name 256 gn_fail_on_unused: bool = True, 257 export_compile_commands: bool | str = True, 258 preserve_args_gn: bool = False, 259 **gn_arguments, 260) -> None: 261 """Runs gn gen in the specified directory with optional GN args. 262 263 Runs with --check=system if gn_check=True. Note that this does not cover 264 generated files. Run gn_check() after building to check generated files. 265 """ 266 all_gn_args = {'pw_build_COLORIZE_OUTPUT': pw_cli.color.is_enabled()} 267 all_gn_args.update(gn_arguments) 268 all_gn_args.update(ctx.override_gn_args) 269 _LOG.debug('%r', all_gn_args) 270 args_option = gn_args(**all_gn_args) 271 272 if not ctx.dry_run and not preserve_args_gn: 273 # Delete args.gn to ensure this is a clean build. 274 args_gn = ctx.output_dir / 'args.gn' 275 if args_gn.is_file(): 276 args_gn.unlink() 277 278 export_commands_arg = '' 279 if export_compile_commands: 280 export_commands_arg = '--export-compile-commands' 281 if isinstance(export_compile_commands, str): 282 export_commands_arg += f'={export_compile_commands}' 283 284 call( 285 'gn', 286 '--color' if pw_cli.color.is_enabled() else '--nocolor', 287 'gen', 288 ctx.output_dir, 289 *(['--check=system'] if gn_check else []), 290 *(['--fail-on-unused-args'] if gn_fail_on_unused else []), 291 *([export_commands_arg] if export_commands_arg else []), 292 *args, 293 *([args_option] if all_gn_args else []), 294 cwd=ctx.root, 295 call_annotation={ 296 'gn_gen_args': all_gn_args, 297 'gn_gen_args_option': args_option, 298 }, 299 ) 300 301 302def gn_check(ctx: PresubmitContext) -> PresubmitResult: 303 """Runs gn check, including on generated and system files.""" 304 call( 305 'gn', 306 'check', 307 ctx.output_dir, 308 '--check-generated', 309 '--check-system', 310 cwd=ctx.root, 311 ) 312 return PresubmitResult.PASS 313 314 315def ninja( 316 ctx: PresubmitContext, 317 *args, 318 save_compdb: bool = True, 319 save_graph: bool = True, 320 **kwargs, 321) -> None: 322 """Runs ninja in the specified directory.""" 323 324 num_jobs: list[str] = [] 325 if ctx.num_jobs is not None: 326 num_jobs.extend(('-j', str(ctx.num_jobs))) 327 328 keep_going: list[str] = [] 329 if ctx.continue_after_build_error: 330 keep_going.extend(('-k', '0')) 331 332 if save_compdb: 333 proc = log_run( 334 ['ninja', '-C', ctx.output_dir, '-t', 'compdb', *args], 335 capture_output=True, 336 **kwargs, 337 ) 338 if not ctx.dry_run: 339 (ctx.output_dir / 'ninja.compdb').write_bytes(proc.stdout) 340 341 if save_graph: 342 proc = log_run( 343 ['ninja', '-C', ctx.output_dir, '-t', 'graph', *args], 344 capture_output=True, 345 **kwargs, 346 ) 347 if not ctx.dry_run: 348 (ctx.output_dir / 'ninja.graph').write_bytes(proc.stdout) 349 350 ninja_stdout = ctx.output_dir / 'ninja.stdout' 351 ctx.output_dir.mkdir(exist_ok=True, parents=True) 352 try: 353 with ninja_stdout.open('w') as outs: 354 if sys.platform == 'win32': 355 # Windows doesn't support pw-wrap-ninja. 356 ninja_command = ['ninja'] 357 else: 358 ninja_command = ['pw-wrap-ninja', '--log-actions'] 359 360 call( 361 *ninja_command, 362 '-C', 363 ctx.output_dir, 364 *num_jobs, 365 *keep_going, 366 *args, 367 tee=outs, 368 propagate_sigterm=True, 369 call_annotation={'build_system': 'ninja'}, 370 **kwargs, 371 ) 372 373 except PresubmitFailure as exc: 374 failure = ninja_parser.parse_ninja_stdout(ninja_stdout) 375 if failure: 376 with ctx.failure_summary_log.open('w') as outs: 377 outs.write(failure) 378 379 raise exc 380 381 382def get_gn_args(directory: Path) -> list[dict[str, dict[str, str]]]: 383 """Dumps GN variables to JSON.""" 384 proc = log_run( 385 ['gn', 'args', directory, '--list', '--json'], stdout=subprocess.PIPE 386 ) 387 return json.loads(proc.stdout) 388 389 390def cmake( 391 ctx: PresubmitContext, 392 *args: str, 393 env: Mapping['str', 'str'] | None = None, 394) -> None: 395 """Runs CMake for Ninja on the given source and output directories.""" 396 call( 397 'cmake', 398 '-B', 399 ctx.output_dir, 400 '-S', 401 ctx.root, 402 '-G', 403 'Ninja', 404 *args, 405 env=env, 406 ) 407 408 409def env_with_clang_vars() -> Mapping[str, str]: 410 """Returns the environment variables with CC, CXX, etc. set for clang.""" 411 env = os.environ.copy() 412 env['CC'] = env['LD'] = env['AS'] = 'clang' 413 env['CXX'] = 'clang++' 414 return env 415 416 417def _get_paths_from_command(source_dir: Path, *args, **kwargs) -> Set[Path]: 418 """Runs a command and reads Bazel or GN //-style paths from it.""" 419 process = log_run(args, capture_output=True, cwd=source_dir, **kwargs) 420 421 if process.returncode: 422 _LOG.error( 423 'Build invocation failed with return code %d!', process.returncode 424 ) 425 _LOG.error( 426 '[COMMAND] %s\n%s\n%s', 427 *format_command(args, kwargs), 428 process.stderr.decode(), 429 ) 430 raise PresubmitFailure 431 432 files = set() 433 434 for line in process.stdout.splitlines(): 435 path = line.strip().lstrip(b'/').replace(b':', b'/').decode() 436 path = source_dir.joinpath(path) 437 if path.is_file(): 438 files.add(path) 439 440 return files 441 442 443# Finds string literals with '.' in them. 444_MAYBE_A_PATH = re.compile( 445 r'"' # Starting double quote. 446 # Start capture group 1 - the whole filename: 447 # File basename, a single period, file extension. 448 r'([^\n" ]+\.[^\n" ]+)' 449 # Non-capturing group 2 (optional). 450 r'(?: > [^\n"]+)?' # pw_zip style string "input_file.txt > output_file.txt" 451 r'"' # Ending double quote. 452) 453 454 455def _search_files_for_paths(build_files: Iterable[Path]) -> Iterable[Path]: 456 for build_file in build_files: 457 directory = build_file.parent 458 459 for string in _MAYBE_A_PATH.finditer(build_file.read_text()): 460 path = directory / string.group(1) 461 if path.is_file(): 462 yield path 463 464 465def _read_compile_commands(compile_commands: Path) -> dict: 466 with compile_commands.open('rb') as fd: 467 return json.load(fd) 468 469 470def compiled_files(compile_commands: Path) -> Iterable[Path]: 471 for command in _read_compile_commands(compile_commands): 472 file = Path(command['file']) 473 if file.is_absolute(): 474 yield file 475 else: 476 yield file.joinpath(command['directory']).resolve() 477 478 479def check_compile_commands_for_files( 480 compile_commands: Path | Iterable[Path], 481 files: Iterable[Path], 482 extensions: Collection[str] = format_code.CPP_SOURCE_EXTS, 483) -> list[Path]: 484 """Checks for paths in one or more compile_commands.json files. 485 486 Only checks C and C++ source files by default. 487 """ 488 if isinstance(compile_commands, Path): 489 compile_commands = [compile_commands] 490 491 compiled = frozenset( 492 itertools.chain.from_iterable( 493 compiled_files(cmds) for cmds in compile_commands 494 ) 495 ) 496 return [f for f in files if f not in compiled and f.suffix in extensions] 497 498 499def check_bazel_build_for_files( 500 bazel_extensions_to_check: Container[str], 501 files: Iterable[Path], 502 bazel_dirs: Iterable[Path] = (), 503) -> list[Path]: 504 """Checks that source files are in the Bazel builds. 505 506 Args: 507 bazel_extensions_to_check: which file suffixes to look for in Bazel 508 files: the files that should be checked 509 bazel_dirs: directories in which to run bazel query 510 511 Returns: 512 a list of missing files; will be empty if there were no missing files 513 """ 514 515 # Collect all paths in the Bazel builds. 516 bazel_builds: Set[Path] = set() 517 for directory in bazel_dirs: 518 bazel_builds.update( 519 _get_paths_from_command( 520 directory, 521 BAZEL_EXECUTABLE, 522 'query', 523 'kind("source file", //...:*)', 524 ) 525 ) 526 527 missing: list[Path] = [] 528 529 if bazel_dirs: 530 for path in (p for p in files if p.suffix in bazel_extensions_to_check): 531 if path not in bazel_builds: 532 # TODO: b/234883555 - Replace this workaround for fuzzers. 533 if 'fuzz' not in str(path): 534 missing.append(path) 535 536 if missing: 537 _LOG.warning( 538 '%s missing from the Bazel build:\n%s', 539 plural(missing, 'file', are=True), 540 '\n'.join(str(x) for x in missing), 541 ) 542 543 return missing 544 545 546def check_gn_build_for_files( 547 gn_extensions_to_check: Container[str], 548 files: Iterable[Path], 549 gn_dirs: Iterable[tuple[Path, Path]] = (), 550 gn_build_files: Iterable[Path] = (), 551) -> list[Path]: 552 """Checks that source files are in the GN build. 553 554 Args: 555 gn_extensions_to_check: which file suffixes to look for in GN 556 files: the files that should be checked 557 gn_dirs: (source_dir, output_dir) tuples with which to run gn desc 558 gn_build_files: paths to BUILD.gn files to directly search for paths 559 560 Returns: 561 a list of missing files; will be empty if there were no missing files 562 """ 563 564 # Collect all paths in GN builds. 565 gn_builds: Set[Path] = set() 566 567 for source_dir, output_dir in gn_dirs: 568 gn_builds.update( 569 _get_paths_from_command(source_dir, 'gn', 'desc', output_dir, '*') 570 ) 571 572 gn_builds.update(_search_files_for_paths(gn_build_files)) 573 574 missing: list[Path] = [] 575 576 if gn_dirs or gn_build_files: 577 for path in (p for p in files if p.suffix in gn_extensions_to_check): 578 if path not in gn_builds: 579 missing.append(path) 580 581 if missing: 582 _LOG.warning( 583 '%s missing from the GN build:\n%s', 584 plural(missing, 'file', are=True), 585 '\n'.join(str(x) for x in missing), 586 ) 587 588 return missing 589 590 591def check_soong_build_for_files( 592 soong_extensions_to_check: Container[str], 593 files: Iterable[Path], 594 soong_build_files: Iterable[Path] = (), 595) -> list[Path]: 596 """Checks that source files are in the Soong build. 597 598 Args: 599 bp_extensions_to_check: which file suffixes to look for in Soong files 600 files: the files that should be checked 601 bp_build_files: paths to Android.bp files to directly search for paths 602 603 Returns: 604 a list of missing files; will be empty if there were no missing files 605 """ 606 607 # Collect all paths in Soong builds. 608 soong_builds = set(_search_files_for_paths(soong_build_files)) 609 610 missing: list[Path] = [] 611 612 if soong_build_files: 613 for path in (p for p in files if p.suffix in soong_extensions_to_check): 614 if path not in soong_builds: 615 missing.append(path) 616 617 if missing: 618 _LOG.warning( 619 '%s missing from the Soong build:\n%s', 620 plural(missing, 'file', are=True), 621 '\n'.join(str(x) for x in missing), 622 ) 623 624 return missing 625 626 627def check_builds_for_files( 628 bazel_extensions_to_check: Container[str], 629 gn_extensions_to_check: Container[str], 630 files: Iterable[Path], 631 bazel_dirs: Iterable[Path] = (), 632 gn_dirs: Iterable[tuple[Path, Path]] = (), 633 gn_build_files: Iterable[Path] = (), 634) -> dict[str, list[Path]]: 635 """Checks that source files are in the GN and Bazel builds. 636 637 Args: 638 bazel_extensions_to_check: which file suffixes to look for in Bazel 639 gn_extensions_to_check: which file suffixes to look for in GN 640 files: the files that should be checked 641 bazel_dirs: directories in which to run bazel query 642 gn_dirs: (source_dir, output_dir) tuples with which to run gn desc 643 gn_build_files: paths to BUILD.gn files to directly search for paths 644 645 Returns: 646 a dictionary mapping build system ('Bazel' or 'GN' to a list of missing 647 files; will be empty if there were no missing files 648 """ 649 650 bazel_missing = check_bazel_build_for_files( 651 bazel_extensions_to_check=bazel_extensions_to_check, 652 files=files, 653 bazel_dirs=bazel_dirs, 654 ) 655 gn_missing = check_gn_build_for_files( 656 gn_extensions_to_check=gn_extensions_to_check, 657 files=files, 658 gn_dirs=gn_dirs, 659 gn_build_files=gn_build_files, 660 ) 661 662 result = {} 663 if bazel_missing: 664 result['Bazel'] = bazel_missing 665 if gn_missing: 666 result['GN'] = gn_missing 667 return result 668 669 670@contextlib.contextmanager 671def test_server(executable: str, output_dir: Path): 672 """Context manager that runs a test server executable. 673 674 Args: 675 executable: name of the test server executable 676 output_dir: path to the output directory (for logs) 677 """ 678 679 with open(output_dir / 'test_server.log', 'w') as outs: 680 try: 681 proc = subprocess.Popen( 682 [executable, '--verbose'], 683 stdout=outs, 684 stderr=subprocess.STDOUT, 685 ) 686 687 yield 688 689 finally: 690 proc.terminate() # pylint: disable=used-before-assignment 691 692 693@contextlib.contextmanager 694def modified_env(**envvars): 695 """Context manager that sets environment variables. 696 697 Use by assigning values to variable names in the argument list, e.g.: 698 `modified_env(MY_FLAG="some value")` 699 700 Args: 701 envvars: Keyword arguments 702 """ 703 saved_env = os.environ.copy() 704 os.environ.update(envvars) 705 try: 706 yield 707 finally: 708 os.environ = saved_env 709 710 711def fuzztest_prng_seed(ctx: PresubmitContext) -> str: 712 """Convert the RNG seed to the format expected by FuzzTest. 713 714 FuzzTest can be configured to use the seed by setting the 715 `FUZZTEST_PRNG_SEED` environment variable to this value. 716 717 Args: 718 ctx: The context that includes a pseudorandom number generator seed. 719 """ 720 rng_bytes = ctx.rng_seed.to_bytes(32, sys.byteorder) 721 return base64.urlsafe_b64encode(rng_bytes).decode('ascii').rstrip('=') 722 723 724@filter_paths( 725 file_filter=FileFilter( 726 endswith=('.bzl', '.bazel'), 727 name=('WORKSPACE',), 728 exclude=(r'pw_presubmit/py/pw_presubmit/format/test_data',), 729 ) 730) 731def bazel_lint(ctx: PresubmitContext): 732 """Runs buildifier with lint on Bazel files. 733 734 Should be run after bazel_format since that will give more useful output 735 for formatting-only issues. 736 """ 737 738 failure = False 739 for path in ctx.paths: 740 try: 741 call('buildifier', '--lint=warn', '--mode=check', path) 742 except PresubmitFailure: 743 failure = True 744 745 if failure: 746 raise PresubmitFailure 747 748 749@Check 750def gn_gen_check(ctx: PresubmitContext): 751 """Runs gn gen --check to enforce correct header dependencies.""" 752 gn_gen(ctx, gn_check=True) 753 754 755Item = int | str 756Value = Item | Sequence[Item] 757ValueCallable = Callable[[PresubmitContext], Value] 758InputItem = Item | ValueCallable 759InputValue = InputItem | Sequence[InputItem] 760 761 762def _value(ctx: PresubmitContext, val: InputValue) -> Value: 763 """Process any lambdas inside val 764 765 val is a single value or a list of values, any of which might be a lambda 766 that needs to be resolved. Call each of these lambdas with ctx and replace 767 the lambda with the result. Return the updated top-level structure. 768 """ 769 770 if isinstance(val, (str, int)): 771 return val 772 if callable(val): 773 return val(ctx) 774 775 result: list[Item] = [] 776 for item in val: 777 if callable(item): 778 call_result = item(ctx) 779 if isinstance(call_result, (int, str)): 780 result.append(call_result) 781 else: # Sequence. 782 result.extend(call_result) 783 elif isinstance(item, (int, str)): 784 result.append(item) 785 else: # Sequence. 786 result.extend(item) 787 return result 788 789 790_CtxMgrLambda = Callable[[PresubmitContext], ContextManager] 791_CtxMgrOrLambda = ContextManager | _CtxMgrLambda 792 793 794@dataclass(frozen=True) 795class CommonCoverageOptions: 796 """Coverage options shared by both CodeSearch and Gerrit. 797 798 For Google use only. 799 """ 800 801 # The "root" of the Kalypsi GCS bucket path to which the coverage data 802 # should be uploaded. Typically gs://ng3-metrics/ng3-<teamname>-coverage. 803 target_bucket_root: str 804 805 # The project name in the Kalypsi GCS bucket path. 806 target_bucket_project: str 807 808 # See go/kalypsi-abs#trace-type-required. 809 trace_type: str 810 811 # go/kalypsi-abs#owner-required. 812 owner: str 813 814 # go/kalypsi-abs#bug-component-required. 815 bug_component: str 816 817 818@dataclass(frozen=True) 819class CodeSearchCoverageOptions: 820 """CodeSearch-specific coverage options. For Google use only.""" 821 822 # The name of the Gerrit host containing the CodeSearch repo. Just the name 823 # ("pigweed"), not the full URL ("pigweed.googlesource.com"). This may be 824 # different from the host from which the code was originally checked out. 825 host: str 826 827 # The name of the project, as expected by CodeSearch. Typically 828 # 'codesearch'. 829 project: str 830 831 # See go/kalypsi-abs#ref-required. 832 ref: str 833 834 # See go/kalypsi-abs#source-required. 835 source: str 836 837 # See go/kalypsi-abs#add-prefix-optional. 838 add_prefix: str = '' 839 840 841@dataclass(frozen=True) 842class GerritCoverageOptions: 843 """Gerrit-specific coverage options. For Google use only.""" 844 845 # The name of the project, as expected by Gerrit. This is typically the 846 # repository name, e.g. 'pigweed/pigweed' for upstream Pigweed. 847 # See go/kalypsi-inc#project-required. 848 project: str 849 850 851@dataclass(frozen=True) 852class CoverageOptions: 853 """Coverage collection configuration. For Google use only.""" 854 855 common: CommonCoverageOptions 856 codesearch: tuple[CodeSearchCoverageOptions, ...] 857 gerrit: GerritCoverageOptions 858 859 860class _NinjaBase(Check): 861 """Thin wrapper of Check for steps that call ninja.""" 862 863 def __init__( 864 self, 865 *args, 866 packages: Sequence[str] = (), 867 ninja_contexts: Sequence[_CtxMgrOrLambda] = (), 868 ninja_targets: str | Sequence[str] | Sequence[Sequence[str]] = (), 869 coverage_options: CoverageOptions | None = None, 870 **kwargs, 871 ): 872 """Initializes a _NinjaBase object. 873 874 Args: 875 *args: Passed on to superclass. 876 packages: List of 'pw package' packages to install. 877 ninja_contexts: List of context managers to apply around ninja 878 calls. 879 ninja_targets: Single ninja target, list of Ninja targets, or list 880 of list of ninja targets. If a list of a list, ninja will be 881 called multiple times with the same build directory. 882 coverage_options: Coverage collection options (or None, if not 883 collecting coverage data). 884 **kwargs: Passed on to superclass. 885 """ 886 super().__init__(*args, **kwargs) 887 self._packages: Sequence[str] = packages 888 self._ninja_contexts: tuple[_CtxMgrOrLambda, ...] = tuple( 889 ninja_contexts 890 ) 891 self._coverage_options = coverage_options 892 893 if isinstance(ninja_targets, str): 894 ninja_targets = (ninja_targets,) 895 ninja_targets = list(ninja_targets) 896 all_strings = all(isinstance(x, str) for x in ninja_targets) 897 any_strings = any(isinstance(x, str) for x in ninja_targets) 898 if ninja_targets and all_strings != any_strings: 899 raise ValueError(repr(ninja_targets)) 900 901 self._ninja_target_lists: tuple[tuple[str, ...], ...] 902 if all_strings: 903 targets: list[str] = [] 904 for target in ninja_targets: 905 targets.append(target) # type: ignore 906 self._ninja_target_lists = (tuple(targets),) 907 else: 908 self._ninja_target_lists = tuple(tuple(x) for x in ninja_targets) 909 910 @property 911 def ninja_targets(self) -> list[str]: 912 return list(itertools.chain(*self._ninja_target_lists)) 913 914 def _install_package( # pylint: disable=no-self-use 915 self, 916 ctx: PresubmitContext, 917 package: str, 918 ) -> PresubmitResult: 919 install_package(ctx, package) 920 return PresubmitResult.PASS 921 922 @contextlib.contextmanager 923 def _context(self, ctx: PresubmitContext): 924 """Apply any context managers necessary for building.""" 925 with contextlib.ExitStack() as stack: 926 for mgr in self._ninja_contexts: 927 if isinstance(mgr, contextlib.AbstractContextManager): 928 stack.enter_context(mgr) 929 else: 930 stack.enter_context(mgr(ctx)) # type: ignore 931 yield 932 933 def _ninja( 934 self, ctx: PresubmitContext, targets: Sequence[str] 935 ) -> PresubmitResult: 936 with self._context(ctx): 937 ninja(ctx, *targets) 938 return PresubmitResult.PASS 939 940 def _coverage( 941 self, ctx: PresubmitContext, options: CoverageOptions 942 ) -> PresubmitResult: 943 """Archive and (on LUCI) upload coverage reports.""" 944 reports = ctx.output_dir / 'coverage_reports' 945 os.makedirs(reports, exist_ok=True) 946 coverage_jsons: list[Path] = [] 947 for path in ctx.output_dir.rglob('coverage_report'): 948 _LOG.debug('exploring %s', path) 949 name = str(path.relative_to(ctx.output_dir)) 950 name = name.replace('_', '').replace('/', '_') 951 with tarfile.open(reports / f'{name}.tar.gz', 'w:gz') as tar: 952 tar.add(path, arcname=name, recursive=True) 953 json_path = path / 'json' / 'report.json' 954 if json_path.is_file(): 955 _LOG.debug('found json %s', json_path) 956 coverage_jsons.append(json_path) 957 958 if not coverage_jsons: 959 ctx.fail('No coverage json file found') 960 return PresubmitResult.FAIL 961 962 if len(coverage_jsons) > 1: 963 _LOG.warning( 964 'More than one coverage json file, selecting first: %r', 965 coverage_jsons, 966 ) 967 968 coverage_json = coverage_jsons[0] 969 970 if ctx.luci: 971 if not ctx.luci.is_prod: 972 _LOG.warning('Not uploading coverage since not running in prod') 973 return PresubmitResult.PASS 974 975 with self._context(ctx): 976 metadata_json_paths = _write_coverage_metadata(ctx, options) 977 for i, metadata_json in enumerate(metadata_json_paths): 978 # GCS bucket paths are POSIX-like. 979 coverage_gcs_path = posixpath.join( 980 options.common.target_bucket_root, 981 'incremental' if ctx.luci.is_try else 'absolute', 982 options.common.target_bucket_project, 983 f'{ctx.luci.buildbucket_id}-{i}', 984 ) 985 _copy_to_gcs( 986 ctx, 987 coverage_json, 988 posixpath.join(coverage_gcs_path, 'report.json'), 989 ) 990 _copy_to_gcs( 991 ctx, 992 metadata_json, 993 posixpath.join(coverage_gcs_path, 'metadata.json'), 994 ) 995 996 return PresubmitResult.PASS 997 998 _LOG.warning('Not uploading coverage since running locally') 999 return PresubmitResult.PASS 1000 1001 def _package_substeps(self) -> Iterator[SubStep]: 1002 for package in self._packages: 1003 yield SubStep( 1004 f'install {package} package', 1005 self._install_package, 1006 (package,), 1007 ) 1008 1009 def _ninja_substeps(self) -> Iterator[SubStep]: 1010 targets_parts = set() 1011 for targets in self._ninja_target_lists: 1012 targets_part = " ".join(targets) 1013 maxlen = 70 1014 if len(targets_part) > maxlen: 1015 targets_part = f'{targets_part[0:maxlen-3]}...' 1016 assert targets_part not in targets_parts 1017 targets_parts.add(targets_part) 1018 yield SubStep(f'ninja {targets_part}', self._ninja, (targets,)) 1019 1020 def _coverage_substeps(self) -> Iterator[SubStep]: 1021 if self._coverage_options is not None: 1022 yield SubStep('coverage', self._coverage, (self._coverage_options,)) 1023 1024 1025def _copy_to_gcs(ctx: PresubmitContext, filepath: Path, gcs_dst: str): 1026 luci = Path(pw_cli.env.pigweed_environment().PW_LUCI_CIPD_INSTALL_DIR) 1027 gsutil = luci / 'gsutil' / 'gsutil' 1028 1029 cmd = [gsutil, 'cp', filepath, gcs_dst] 1030 1031 upload_stdout = ctx.output_dir / (filepath.name + '.stdout') 1032 with upload_stdout.open('w') as outs: 1033 call(*cmd, tee=outs) 1034 1035 1036class NoPrimaryTriggerError(Exception): 1037 pass 1038 1039 1040def _get_primary_change(ctx: PresubmitContext) -> LuciTrigger: 1041 assert ctx.luci is not None 1042 1043 if len(ctx.luci.triggers) == 1: 1044 return ctx.luci.triggers[0] 1045 1046 for trigger in ctx.luci.triggers: 1047 if trigger.primary: 1048 return trigger 1049 1050 raise NoPrimaryTriggerError(repr(ctx.luci.triggers)) 1051 1052 1053def _write_coverage_metadata( 1054 ctx: PresubmitContext, options: CoverageOptions 1055) -> Sequence[Path]: 1056 """Write out Kalypsi coverage metadata file(s) and return their paths.""" 1057 assert ctx.luci is not None 1058 change = _get_primary_change(ctx) 1059 1060 metadata = { 1061 'trace_type': options.common.trace_type, 1062 'trim_prefix': str(ctx.root), 1063 'patchset_num': change.patchset, 1064 'change_id': change.number, 1065 'owner': options.common.owner, 1066 'bug_component': options.common.bug_component, 1067 } 1068 1069 if ctx.luci.is_try: 1070 # Running in CQ: uploading incremental coverage 1071 metadata.update( 1072 { 1073 'change_id': change.number, 1074 'host': change.gerrit_name, 1075 'patchset_num': change.patchset, 1076 'project': options.gerrit.project, 1077 } 1078 ) 1079 1080 metadata_json = ctx.output_dir / "metadata.json" 1081 with metadata_json.open('w') as metadata_file: 1082 json.dump(metadata, metadata_file) 1083 return (metadata_json,) 1084 1085 # Running in CI: uploading absolute coverage, possibly to multiple locations 1086 # since a repo could be in codesearch in multiple places. 1087 metadata_jsons = [] 1088 for i, cs in enumerate(options.codesearch): 1089 metadata.update( 1090 { 1091 'add_prefix': cs.add_prefix, 1092 'commit_id': change.ref, 1093 'host': cs.host, 1094 'project': cs.project, 1095 'ref': cs.ref, 1096 'source': cs.source, 1097 } 1098 ) 1099 1100 metadata_json = ctx.output_dir / f'metadata-{i}.json' 1101 with metadata_json.open('w') as metadata_file: 1102 json.dump(metadata, metadata_file) 1103 metadata_jsons.append(metadata_json) 1104 1105 return tuple(metadata_jsons) 1106 1107 1108class GnGenNinja(_NinjaBase): 1109 """Thin wrapper of Check for steps that just call gn/ninja. 1110 1111 Runs gn gen, ninja, then gn check. 1112 """ 1113 1114 def __init__( 1115 self, 1116 *args, 1117 gn_args: ( # pylint: disable=redefined-outer-name 1118 dict[str, Any] | None 1119 ) = None, 1120 **kwargs, 1121 ): 1122 """Initializes a GnGenNinja object. 1123 1124 Args: 1125 *args: Passed on to superclass. 1126 gn_args: dict of GN args. 1127 **kwargs: Passed on to superclass. 1128 """ 1129 super().__init__(self._substeps(), *args, **kwargs) 1130 self._gn_args: dict[str, Any] = gn_args or {} 1131 1132 def add_default_gn_args(self, args): 1133 """Add any project-specific default GN args to 'args'.""" 1134 1135 @property 1136 def gn_args(self) -> dict[str, Any]: 1137 return self._gn_args 1138 1139 def _gn_gen(self, ctx: PresubmitContext) -> PresubmitResult: 1140 args: dict[str, Any] = {} 1141 if self._coverage_options is not None: 1142 args['pw_toolchain_COVERAGE_ENABLED'] = True 1143 args['pw_build_PYTHON_TEST_COVERAGE'] = True 1144 1145 if ctx.incremental: 1146 args['pw_toolchain_PROFILE_SOURCE_FILES'] = [ 1147 f'//{x.relative_to(ctx.root)}' for x in ctx.paths 1148 ] 1149 1150 self.add_default_gn_args(args) 1151 1152 args.update({k: _value(ctx, v) for k, v in self._gn_args.items()}) 1153 gn_gen(ctx, gn_check=False, **args) # type: ignore 1154 return PresubmitResult.PASS 1155 1156 def _substeps(self) -> Iterator[SubStep]: 1157 yield from self._package_substeps() 1158 1159 yield SubStep('gn gen', self._gn_gen) 1160 1161 yield from self._ninja_substeps() 1162 1163 # Run gn check after building so it can check generated files. 1164 yield SubStep('gn check', gn_check) 1165 1166 yield from self._coverage_substeps() 1167