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 contextlib 17import itertools 18import json 19import logging 20import os 21from pathlib import Path 22import re 23import subprocess 24from shutil import which 25import sys 26from typing import ( 27 Any, 28 Callable, 29 Collection, 30 Container, 31 ContextManager, 32 Dict, 33 Iterable, 34 List, 35 Mapping, 36 Optional, 37 Sequence, 38 Set, 39 Tuple, 40 Union, 41) 42 43from pw_presubmit import ( 44 bazel_parser, 45 call, 46 Check, 47 FileFilter, 48 filter_paths, 49 format_code, 50 install_package, 51 Iterator, 52 log_run, 53 ninja_parser, 54 plural, 55 PresubmitContext, 56 PresubmitFailure, 57 PresubmitResult, 58 SubStep, 59 tools, 60) 61 62_LOG = logging.getLogger(__name__) 63 64 65def bazel(ctx: PresubmitContext, cmd: str, *args: str) -> None: 66 """Invokes Bazel with some common flags set. 67 68 Intended for use with bazel build and test. May not work with others. 69 """ 70 71 num_jobs: List[str] = [] 72 if ctx.num_jobs is not None: 73 num_jobs.extend(('--jobs', str(ctx.num_jobs))) 74 75 keep_going: List[str] = [] 76 if ctx.continue_after_build_error: 77 keep_going.append('--keep_going') 78 79 bazel_stdout = ctx.output_dir / 'bazel.stdout' 80 try: 81 with bazel_stdout.open('w') as outs: 82 call( 83 'bazel', 84 cmd, 85 '--verbose_failures', 86 '--verbose_explanations', 87 '--worker_verbose', 88 f'--symlink_prefix={ctx.output_dir / ".bazel-"}', 89 *num_jobs, 90 *keep_going, 91 *args, 92 cwd=ctx.root, 93 env=env_with_clang_vars(), 94 tee=outs, 95 ) 96 97 except PresubmitFailure as exc: 98 failure = bazel_parser.parse_bazel_stdout(bazel_stdout) 99 if failure: 100 with ctx.failure_summary_log.open('w') as outs: 101 outs.write(failure) 102 103 raise exc 104 105 106def _gn_value(value) -> str: 107 if isinstance(value, bool): 108 return str(value).lower() 109 110 if ( 111 isinstance(value, str) 112 and '"' not in value 113 and not value.startswith("{") 114 and not value.startswith("[") 115 ): 116 return f'"{value}"' 117 118 if isinstance(value, (list, tuple)): 119 return f'[{", ".join(_gn_value(a) for a in value)}]' 120 121 # Fall-back case handles integers as well as strings that already 122 # contain double quotation marks, or look like scopes or lists. 123 return str(value) 124 125 126def gn_args(**kwargs) -> str: 127 """Builds a string to use for the --args argument to gn gen. 128 129 Currently supports bool, int, and str values. In the case of str values, 130 quotation marks will be added automatically, unless the string already 131 contains one or more double quotation marks, or starts with a { or [ 132 character, in which case it will be passed through as-is. 133 """ 134 transformed_args = [] 135 for arg, val in kwargs.items(): 136 transformed_args.append(f'{arg}={_gn_value(val)}') 137 138 # Use ccache if available for faster repeat presubmit runs. 139 if which('ccache'): 140 transformed_args.append('pw_command_launcher="ccache"') 141 142 return '--args=' + ' '.join(transformed_args) 143 144 145def gn_gen( 146 ctx: PresubmitContext, 147 *args: str, 148 gn_check: bool = True, 149 gn_fail_on_unused: bool = True, 150 export_compile_commands: Union[bool, str] = True, 151 preserve_args_gn: bool = False, 152 **gn_arguments, 153) -> None: 154 """Runs gn gen in the specified directory with optional GN args.""" 155 all_gn_args = dict(gn_arguments) 156 all_gn_args.update(ctx.override_gn_args) 157 _LOG.debug('%r', all_gn_args) 158 args_option = gn_args(**all_gn_args) 159 160 if not preserve_args_gn: 161 # Delete args.gn to ensure this is a clean build. 162 args_gn = ctx.output_dir / 'args.gn' 163 if args_gn.is_file(): 164 args_gn.unlink() 165 166 export_commands_arg = '' 167 if export_compile_commands: 168 export_commands_arg = '--export-compile-commands' 169 if isinstance(export_compile_commands, str): 170 export_commands_arg += f'={export_compile_commands}' 171 172 call( 173 'gn', 174 'gen', 175 ctx.output_dir, 176 '--color=always', 177 *(['--fail-on-unused-args'] if gn_fail_on_unused else []), 178 *([export_commands_arg] if export_commands_arg else []), 179 *args, 180 *([args_option] if all_gn_args else []), 181 cwd=ctx.root, 182 ) 183 184 if gn_check: 185 call( 186 'gn', 187 'check', 188 ctx.output_dir, 189 '--check-generated', 190 '--check-system', 191 cwd=ctx.root, 192 ) 193 194 195def ninja( 196 ctx: PresubmitContext, *args, save_compdb=True, save_graph=True, **kwargs 197) -> None: 198 """Runs ninja in the specified directory.""" 199 200 num_jobs: List[str] = [] 201 if ctx.num_jobs is not None: 202 num_jobs.extend(('-j', str(ctx.num_jobs))) 203 204 keep_going: List[str] = [] 205 if ctx.continue_after_build_error: 206 keep_going.extend(('-k', '0')) 207 208 if save_compdb: 209 proc = subprocess.run( 210 ['ninja', '-C', ctx.output_dir, '-t', 'compdb', *args], 211 capture_output=True, 212 **kwargs, 213 ) 214 (ctx.output_dir / 'ninja.compdb').write_bytes(proc.stdout) 215 216 if save_graph: 217 proc = subprocess.run( 218 ['ninja', '-C', ctx.output_dir, '-t', 'graph', *args], 219 capture_output=True, 220 **kwargs, 221 ) 222 (ctx.output_dir / 'ninja.graph').write_bytes(proc.stdout) 223 224 ninja_stdout = ctx.output_dir / 'ninja.stdout' 225 try: 226 with ninja_stdout.open('w') as outs: 227 if sys.platform == 'win32': 228 # Windows doesn't support pw-wrap-ninja. 229 ninja_command = ['ninja'] 230 else: 231 ninja_command = ['pw-wrap-ninja', '--log-actions'] 232 233 call( 234 *ninja_command, 235 '-C', 236 ctx.output_dir, 237 *num_jobs, 238 *keep_going, 239 *args, 240 tee=outs, 241 propagate_sigterm=True, 242 **kwargs, 243 ) 244 245 except PresubmitFailure as exc: 246 failure = ninja_parser.parse_ninja_stdout(ninja_stdout) 247 if failure: 248 with ctx.failure_summary_log.open('w') as outs: 249 outs.write(failure) 250 251 raise exc 252 253 254def get_gn_args(directory: Path) -> List[Dict[str, Dict[str, str]]]: 255 """Dumps GN variables to JSON.""" 256 proc = subprocess.run( 257 ['gn', 'args', directory, '--list', '--json'], stdout=subprocess.PIPE 258 ) 259 return json.loads(proc.stdout) 260 261 262def cmake( 263 ctx: PresubmitContext, 264 *args: str, 265 env: Optional[Mapping['str', 'str']] = None, 266) -> None: 267 """Runs CMake for Ninja on the given source and output directories.""" 268 call( 269 'cmake', 270 '-B', 271 ctx.output_dir, 272 '-S', 273 ctx.root, 274 '-G', 275 'Ninja', 276 *args, 277 env=env, 278 ) 279 280 281def env_with_clang_vars() -> Mapping[str, str]: 282 """Returns the environment variables with CC, CXX, etc. set for clang.""" 283 env = os.environ.copy() 284 env['CC'] = env['LD'] = env['AS'] = 'clang' 285 env['CXX'] = 'clang++' 286 return env 287 288 289def _get_paths_from_command(source_dir: Path, *args, **kwargs) -> Set[Path]: 290 """Runs a command and reads Bazel or GN //-style paths from it.""" 291 process = log_run(args, capture_output=True, cwd=source_dir, **kwargs) 292 293 if process.returncode: 294 _LOG.error( 295 'Build invocation failed with return code %d!', process.returncode 296 ) 297 _LOG.error( 298 '[COMMAND] %s\n%s\n%s', 299 *tools.format_command(args, kwargs), 300 process.stderr.decode(), 301 ) 302 raise PresubmitFailure 303 304 files = set() 305 306 for line in process.stdout.splitlines(): 307 path = line.strip().lstrip(b'/').replace(b':', b'/').decode() 308 path = source_dir.joinpath(path) 309 if path.is_file(): 310 files.add(path) 311 312 return files 313 314 315# Finds string literals with '.' in them. 316_MAYBE_A_PATH = re.compile( 317 r'"' # Starting double quote. 318 # Start capture group 1 - the whole filename: 319 # File basename, a single period, file extension. 320 r'([^\n" ]+\.[^\n" ]+)' 321 # Non-capturing group 2 (optional). 322 r'(?: > [^\n"]+)?' # pw_zip style string "input_file.txt > output_file.txt" 323 r'"' # Ending double quote. 324) 325 326 327def _search_files_for_paths(build_files: Iterable[Path]) -> Iterable[Path]: 328 for build_file in build_files: 329 directory = build_file.parent 330 331 for string in _MAYBE_A_PATH.finditer(build_file.read_text()): 332 path = directory / string.group(1) 333 if path.is_file(): 334 yield path 335 336 337def _read_compile_commands(compile_commands: Path) -> dict: 338 with compile_commands.open('rb') as fd: 339 return json.load(fd) 340 341 342def compiled_files(compile_commands: Path) -> Iterable[Path]: 343 for command in _read_compile_commands(compile_commands): 344 file = Path(command['file']) 345 if file.is_absolute(): 346 yield file 347 else: 348 yield file.joinpath(command['directory']).resolve() 349 350 351def check_compile_commands_for_files( 352 compile_commands: Union[Path, Iterable[Path]], 353 files: Iterable[Path], 354 extensions: Collection[str] = format_code.CPP_SOURCE_EXTS, 355) -> List[Path]: 356 """Checks for paths in one or more compile_commands.json files. 357 358 Only checks C and C++ source files by default. 359 """ 360 if isinstance(compile_commands, Path): 361 compile_commands = [compile_commands] 362 363 compiled = frozenset( 364 itertools.chain.from_iterable( 365 compiled_files(cmds) for cmds in compile_commands 366 ) 367 ) 368 return [f for f in files if f not in compiled and f.suffix in extensions] 369 370 371def check_bazel_build_for_files( 372 bazel_extensions_to_check: Container[str], 373 files: Iterable[Path], 374 bazel_dirs: Iterable[Path] = (), 375) -> List[Path]: 376 """Checks that source files are in the Bazel builds. 377 378 Args: 379 bazel_extensions_to_check: which file suffixes to look for in Bazel 380 files: the files that should be checked 381 bazel_dirs: directories in which to run bazel query 382 383 Returns: 384 a list of missing files; will be empty if there were no missing files 385 """ 386 387 # Collect all paths in the Bazel builds. 388 bazel_builds: Set[Path] = set() 389 for directory in bazel_dirs: 390 bazel_builds.update( 391 _get_paths_from_command( 392 directory, 'bazel', 'query', 'kind("source file", //...:*)' 393 ) 394 ) 395 396 missing: List[Path] = [] 397 398 if bazel_dirs: 399 for path in (p for p in files if p.suffix in bazel_extensions_to_check): 400 if path not in bazel_builds: 401 # TODO(b/234883555) Replace this workaround for fuzzers. 402 if 'fuzz' not in str(path): 403 missing.append(path) 404 405 if missing: 406 _LOG.warning( 407 '%s missing from the Bazel build:\n%s', 408 plural(missing, 'file', are=True), 409 '\n'.join(str(x) for x in missing), 410 ) 411 412 return missing 413 414 415def check_gn_build_for_files( 416 gn_extensions_to_check: Container[str], 417 files: Iterable[Path], 418 gn_dirs: Iterable[Tuple[Path, Path]] = (), 419 gn_build_files: Iterable[Path] = (), 420) -> List[Path]: 421 """Checks that source files are in the GN build. 422 423 Args: 424 gn_extensions_to_check: which file suffixes to look for in GN 425 files: the files that should be checked 426 gn_dirs: (source_dir, output_dir) tuples with which to run gn desc 427 gn_build_files: paths to BUILD.gn files to directly search for paths 428 429 Returns: 430 a list of missing files; will be empty if there were no missing files 431 """ 432 433 # Collect all paths in GN builds. 434 gn_builds: Set[Path] = set() 435 436 for source_dir, output_dir in gn_dirs: 437 gn_builds.update( 438 _get_paths_from_command(source_dir, 'gn', 'desc', output_dir, '*') 439 ) 440 441 gn_builds.update(_search_files_for_paths(gn_build_files)) 442 443 missing: List[Path] = [] 444 445 if gn_dirs or gn_build_files: 446 for path in (p for p in files if p.suffix in gn_extensions_to_check): 447 if path not in gn_builds: 448 missing.append(path) 449 450 if missing: 451 _LOG.warning( 452 '%s missing from the GN build:\n%s', 453 plural(missing, 'file', are=True), 454 '\n'.join(str(x) for x in missing), 455 ) 456 457 return missing 458 459 460def check_builds_for_files( 461 bazel_extensions_to_check: Container[str], 462 gn_extensions_to_check: Container[str], 463 files: Iterable[Path], 464 bazel_dirs: Iterable[Path] = (), 465 gn_dirs: Iterable[Tuple[Path, Path]] = (), 466 gn_build_files: Iterable[Path] = (), 467) -> Dict[str, List[Path]]: 468 """Checks that source files are in the GN and Bazel builds. 469 470 Args: 471 bazel_extensions_to_check: which file suffixes to look for in Bazel 472 gn_extensions_to_check: which file suffixes to look for in GN 473 files: the files that should be checked 474 bazel_dirs: directories in which to run bazel query 475 gn_dirs: (source_dir, output_dir) tuples with which to run gn desc 476 gn_build_files: paths to BUILD.gn files to directly search for paths 477 478 Returns: 479 a dictionary mapping build system ('Bazel' or 'GN' to a list of missing 480 files; will be empty if there were no missing files 481 """ 482 483 bazel_missing = check_bazel_build_for_files( 484 bazel_extensions_to_check=bazel_extensions_to_check, 485 files=files, 486 bazel_dirs=bazel_dirs, 487 ) 488 gn_missing = check_gn_build_for_files( 489 gn_extensions_to_check=gn_extensions_to_check, 490 files=files, 491 gn_dirs=gn_dirs, 492 gn_build_files=gn_build_files, 493 ) 494 495 result = {} 496 if bazel_missing: 497 result['Bazel'] = bazel_missing 498 if gn_missing: 499 result['GN'] = gn_missing 500 return result 501 502 503@contextlib.contextmanager 504def test_server(executable: str, output_dir: Path): 505 """Context manager that runs a test server executable. 506 507 Args: 508 executable: name of the test server executable 509 output_dir: path to the output directory (for logs) 510 """ 511 512 with open(output_dir / 'test_server.log', 'w') as outs: 513 try: 514 proc = subprocess.Popen( 515 [executable, '--verbose'], 516 stdout=outs, 517 stderr=subprocess.STDOUT, 518 ) 519 520 yield 521 522 finally: 523 proc.terminate() # pylint: disable=used-before-assignment 524 525 526@filter_paths( 527 file_filter=FileFilter(endswith=('.bzl', '.bazel'), name=('WORKSPACE',)) 528) 529def bazel_lint(ctx: PresubmitContext): 530 """Runs buildifier with lint on Bazel files. 531 532 Should be run after bazel_format since that will give more useful output 533 for formatting-only issues. 534 """ 535 536 failure = False 537 for path in ctx.paths: 538 try: 539 call('buildifier', '--lint=warn', '--mode=check', path) 540 except PresubmitFailure: 541 failure = True 542 543 if failure: 544 raise PresubmitFailure 545 546 547@Check 548def gn_gen_check(ctx: PresubmitContext): 549 """Runs gn gen --check to enforce correct header dependencies.""" 550 gn_gen(ctx, gn_check=True) 551 552 553_CtxMgrLambda = Callable[[PresubmitContext], ContextManager] 554_CtxMgrOrLambda = Union[ContextManager, _CtxMgrLambda] 555 556 557class GnGenNinja(Check): 558 """Thin wrapper of Check for steps that just call gn/ninja.""" 559 560 def __init__( 561 self, 562 *args, 563 packages: Sequence[str] = (), 564 gn_args: Optional[ # pylint: disable=redefined-outer-name 565 Dict[str, Any] 566 ] = None, 567 ninja_contexts: Sequence[_CtxMgrOrLambda] = (), 568 ninja_targets: Union[str, Sequence[str], Sequence[Sequence[str]]] = (), 569 **kwargs, 570 ): 571 """Initializes a GnGenNinja object. 572 573 Args: 574 *args: Passed on to superclass. 575 packages: List of 'pw package' packages to install. 576 gn_args: Dict of GN args. 577 ninja_contexts: List of context managers to apply around ninja 578 calls. 579 ninja_targets: Single ninja target, list of Ninja targets, or list 580 of list of ninja targets. If a list of a list, ninja will be 581 called multiple times with the same build directory. 582 **kwargs: Passed on to superclass. 583 """ 584 super().__init__(self._substeps(), *args, **kwargs) 585 self.packages: Sequence[str] = packages 586 self.gn_args: Dict[str, Any] = gn_args or {} 587 self.ninja_contexts: Tuple[_CtxMgrOrLambda, ...] = tuple(ninja_contexts) 588 589 if isinstance(ninja_targets, str): 590 ninja_targets = (ninja_targets,) 591 ninja_targets = list(ninja_targets) 592 all_strings = all(isinstance(x, str) for x in ninja_targets) 593 any_strings = any(isinstance(x, str) for x in ninja_targets) 594 if ninja_targets and all_strings != any_strings: 595 raise ValueError(repr(ninja_targets)) 596 597 self.ninja_target_lists: Tuple[Tuple[str, ...], ...] 598 if all_strings: 599 targets: List[str] = [] 600 for target in ninja_targets: 601 targets.append(target) # type: ignore 602 self.ninja_target_lists = (tuple(targets),) 603 else: 604 self.ninja_target_lists = tuple(tuple(x) for x in ninja_targets) 605 606 def _install_package( # pylint: disable=no-self-use 607 self, 608 ctx: PresubmitContext, 609 package: str, 610 ) -> PresubmitResult: 611 install_package(ctx, package) 612 return PresubmitResult.PASS 613 614 def _gn_gen(self, ctx: PresubmitContext) -> PresubmitResult: 615 Item = Union[int, str] 616 Value = Union[Item, Sequence[Item]] 617 ValueCallable = Callable[[PresubmitContext], Value] 618 InputItem = Union[Item, ValueCallable] 619 InputValue = Union[InputItem, Sequence[InputItem]] 620 621 # TODO(mohrr) Use typing.TypeGuard instead of "type: ignore" 622 623 def value(val: InputValue) -> Value: 624 if isinstance(val, (str, int)): 625 return val 626 if callable(val): 627 return val(ctx) 628 629 result: List[Item] = [] 630 for item in val: 631 if callable(item): 632 call_result = item(ctx) 633 if isinstance(item, (int, str)): 634 result.append(call_result) 635 else: # Sequence. 636 result.extend(call_result) # type: ignore 637 elif isinstance(item, (int, str)): 638 result.append(item) 639 else: # Sequence. 640 result.extend(item) 641 return result 642 643 args = {k: value(v) for k, v in self.gn_args.items()} 644 gn_gen(ctx, **args) # type: ignore 645 return PresubmitResult.PASS 646 647 def _ninja( 648 self, ctx: PresubmitContext, targets: Sequence[str] 649 ) -> PresubmitResult: 650 with contextlib.ExitStack() as stack: 651 for mgr in self.ninja_contexts: 652 if isinstance(mgr, contextlib.AbstractContextManager): 653 stack.enter_context(mgr) 654 else: 655 stack.enter_context(mgr(ctx)) # type: ignore 656 ninja(ctx, *targets) 657 return PresubmitResult.PASS 658 659 def _substeps(self) -> Iterator[SubStep]: 660 for package in self.packages: 661 yield SubStep( 662 f'install {package} package', 663 self._install_package, 664 (package,), 665 ) 666 667 yield SubStep('gn gen', self._gn_gen) 668 669 targets_parts = set() 670 for targets in self.ninja_target_lists: 671 targets_part = " ".join(targets) 672 maxlen = 70 673 if len(targets_part) > maxlen: 674 targets_part = f'{targets_part[0:maxlen-3]}...' 675 assert targets_part not in targets_parts 676 targets_parts.add(targets_part) 677 yield SubStep(f'ninja {targets_part}', self._ninja, (targets,)) 678