1# Copyright 2023 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"""pw_build.project_builder_presubmit_runner""" 15 16from __future__ import annotations 17 18import argparse 19import fnmatch 20import logging 21from pathlib import Path 22 23import pw_cli.env 24import pw_cli.log 25from pw_cli.arguments import ( 26 print_completions_for_option, 27 add_tab_complete_arguments, 28) 29from pw_presubmit.presubmit import ( 30 Program, 31 Programs, 32 Presubmit, 33 PresubmitContext, 34 PresubmitResult, 35 Check, 36 fetch_file_lists, 37) 38import pw_presubmit.pigweed_presubmit 39from pw_presubmit.build import GnGenNinja, gn_args 40from pw_presubmit.presubmit_context import ( 41 PresubmitCheckTrace, 42 PresubmitFailure, 43 get_check_traces, 44) 45from pw_presubmit.tools import file_summary 46 47# pw_watch is not required by pw_build, this is an optional feature. 48try: 49 from pw_watch.argparser import ( # type: ignore 50 add_parser_arguments as add_watch_arguments, 51 ) 52 from pw_watch.watch import run_watch, watch_setup # type: ignore 53 from pw_watch.watch_app import WatchAppPrefs # type: ignore 54 55 PW_WATCH_AVAILABLE = True 56except ImportError: 57 PW_WATCH_AVAILABLE = False 58 59from pw_build.project_builder import ( 60 ProjectBuilder, 61 run_builds, 62 ASCII_CHARSET, 63 EMOJI_CHARSET, 64) 65from pw_build.build_recipe import ( 66 BuildCommand, 67 BuildRecipe, 68 UnknownBuildSystem, 69 create_build_recipes, 70 should_gn_gen, 71) 72from pw_build.project_builder_argparse import add_project_builder_arguments 73from pw_build.project_builder_prefs import ProjectBuilderPrefs 74 75 76_COLOR = pw_cli.color.colors() 77_LOG = logging.getLogger('pw_build') 78 79 80class PresubmitTraceAnnotationError(Exception): 81 """Exception for malformed PresubmitCheckTrace annotations.""" 82 83 84def _pw_package_install_command(package_name: str) -> BuildCommand: 85 return BuildCommand( 86 command=[ 87 'pw', 88 '--no-banner', 89 'package', 90 'install', 91 package_name, 92 ], 93 ) 94 95 96def _pw_package_install_to_build_command( 97 trace: PresubmitCheckTrace, 98) -> BuildCommand: 99 """Returns a BuildCommand from a PresubmitCheckTrace.""" 100 package_name = trace.call_annotation.get('pw_package_install', None) 101 if package_name is None: 102 raise PresubmitTraceAnnotationError( 103 'Missing "pw_package_install" value.' 104 ) 105 106 return _pw_package_install_command(package_name) 107 108 109def _bazel_command_args_to_build_commands( 110 trace: PresubmitCheckTrace, 111) -> list[BuildCommand]: 112 """Returns a list of BuildCommands based on a bazel PresubmitCheckTrace.""" 113 build_steps: list[BuildCommand] = [] 114 115 if 'bazel' not in trace.args: 116 return build_steps 117 118 bazel_command = list(arg for arg in trace.args if not arg.startswith('--')) 119 bazel_options = list( 120 arg for arg in trace.args if arg.startswith('--') and arg != '--' 121 ) 122 # Check for bazel build, info or test subcommands. 123 if not ( 124 bazel_command[0].endswith('bazel') 125 and bazel_command[1] in ['build', 'info', 'test'] 126 ): 127 raise UnknownBuildSystem( 128 f'Unable to parse bazel command:\n {trace.args}' 129 ) 130 131 bazel_subcommand = bazel_command[1] 132 bazel_targets = bazel_command[2:] 133 build_steps.append( 134 BuildCommand( 135 build_system_command='bazel', 136 build_system_extra_args=[bazel_subcommand] + bazel_options, 137 targets=bazel_targets, 138 ) 139 ) 140 return build_steps 141 142 143def _presubmit_trace_to_build_commands( 144 ctx: PresubmitContext, 145 presubmit_step: Check, 146) -> list[BuildCommand]: 147 """Convert a presubmit step to a list of BuildCommands. 148 149 Specifically, this handles the following types of PresubmitCheckTraces: 150 151 - pw package installs 152 - gn gen followed by ninja 153 - bazel commands 154 155 If none of the specific scenarios listed above are found the command args 156 are passed along to BuildCommand as is. 157 158 Returns: 159 List of BuildCommands representing each command found in the 160 presubmit_step traces. 161 """ 162 build_steps: list[BuildCommand] = [] 163 164 result = presubmit_step(ctx) 165 if result == PresubmitResult.FAIL: 166 raise PresubmitFailure( 167 '\n\nERROR: This presubmit cannot be run with "pw build". ' 168 'Please run with:\n\n' 169 f' pw presubmit --step {presubmit_step}' 170 ) 171 172 step_traces = get_check_traces(ctx) 173 174 for trace in step_traces: 175 trace_args = list(trace.args) 176 # Check for ninja -t graph command and skip it 177 if trace_args[0].endswith('ninja'): 178 try: 179 dash_t_index = trace_args.index('-t') 180 graph_index = trace_args.index('graph') 181 if graph_index == dash_t_index + 1: 182 # This trace has -t graph, skip it. 183 continue 184 except ValueError: 185 # '-t graph' was not found 186 pass 187 188 if 'pw_package_install' in trace.call_annotation: 189 build_steps.append(_pw_package_install_to_build_command(trace)) 190 continue 191 192 if 'bazel' in trace.args: 193 build_steps.extend(_bazel_command_args_to_build_commands(trace)) 194 continue 195 196 # Check for gn gen or pw-wrap-ninja 197 transformed_args = [] 198 pw_wrap_ninja_found = False 199 gn_found = False 200 gn_gen_found = False 201 202 for arg in trace.args: 203 # Check for a 'gn gen' command 204 if arg == 'gn': 205 gn_found = True 206 if arg == 'gen' and gn_found: 207 gn_gen_found = True 208 209 # Check for pw-wrap-ninja, pw build doesn't use this. 210 if arg == 'pw-wrap-ninja': 211 # Use ninja instead 212 transformed_args.append('ninja') 213 pw_wrap_ninja_found = True 214 continue 215 # Remove --log-actions if pw-wrap-ninja was found. This is a 216 # non-standard ninja arg. 217 if pw_wrap_ninja_found and arg == '--log-actions': 218 continue 219 transformed_args.append(str(arg)) 220 221 if gn_gen_found: 222 # Run the command with run_if=should_gn_gen 223 build_steps.append( 224 BuildCommand(run_if=should_gn_gen, command=transformed_args) 225 ) 226 else: 227 # Run the command as is. 228 build_steps.append(BuildCommand(command=transformed_args)) 229 230 return build_steps 231 232 233def presubmit_build_recipe( # pylint: disable=too-many-locals 234 repo_root: Path, 235 presubmit_out_dir: Path, 236 package_root: Path, 237 presubmit_step: Check, 238 all_files: list[Path], 239 modified_files: list[Path], 240) -> BuildRecipe | None: 241 """Construct a BuildRecipe from a pw_presubmit step.""" 242 out_dir = presubmit_out_dir / presubmit_step.name 243 244 ctx = PresubmitContext( 245 root=repo_root, 246 repos=(repo_root,), 247 output_dir=out_dir, 248 failure_summary_log=out_dir / 'failure-summary.log', 249 paths=tuple(modified_files), 250 all_paths=tuple(all_files), 251 package_root=package_root, 252 luci=None, 253 override_gn_args={}, 254 num_jobs=None, 255 continue_after_build_error=True, 256 _failed=False, 257 format_options=pw_presubmit.presubmit.FormatOptions.load(), 258 dry_run=True, 259 ) 260 261 presubmit_instance = Presubmit( 262 root=repo_root, 263 repos=(repo_root,), 264 output_directory=out_dir, 265 paths=modified_files, 266 all_paths=all_files, 267 package_root=package_root, 268 override_gn_args={}, 269 continue_after_build_error=True, 270 rng_seed=1, 271 full=False, 272 ) 273 274 program = Program('', [presubmit_step]) 275 checks = list(presubmit_instance.apply_filters(program)) 276 if not checks: 277 _LOG.warning('') 278 _LOG.warning( 279 'Step "%s" is not required for the current set of modified files.', 280 presubmit_step.name, 281 ) 282 _LOG.warning('') 283 return None 284 285 try: 286 ctx.paths = tuple(checks[0].paths) 287 except IndexError: 288 raise PresubmitTraceAnnotationError( 289 'Missing pw_presubmit.presubmit.Check for presubmit step:\n' 290 + repr(presubmit_step) 291 ) 292 293 if isinstance(presubmit_step, GnGenNinja): 294 # GnGenNinja is directly translatable to a BuildRecipe. 295 selected_gn_args = { 296 name: value(ctx) if callable(value) else value 297 for name, value in presubmit_step.gn_args.items() 298 } 299 300 return BuildRecipe( 301 build_dir=out_dir, 302 title=presubmit_step.name, 303 steps=[ 304 _pw_package_install_command(name) 305 for name in presubmit_step._packages # pylint: disable=protected-access 306 ] 307 + [ 308 BuildCommand( 309 run_if=should_gn_gen, 310 command=[ 311 'gn', 312 'gen', 313 str(out_dir), 314 gn_args(**selected_gn_args), 315 ], 316 ), 317 BuildCommand( 318 build_system_command='ninja', 319 targets=presubmit_step.ninja_targets, 320 ), 321 ], 322 ) 323 324 # Unknown type of presubmit, use dry-run to capture subprocess traces. 325 build_steps = _presubmit_trace_to_build_commands(ctx, presubmit_step) 326 327 out_dir.mkdir(parents=True, exist_ok=True) 328 329 return BuildRecipe( 330 build_dir=out_dir, 331 title=presubmit_step.name, 332 steps=build_steps, 333 ) 334 335 336def get_parser( 337 presubmit_programs: Programs | None = None, 338 build_recipes: list[BuildRecipe] | None = None, 339) -> argparse.ArgumentParser: 340 """Setup argparse for pw_build.project_builder and optionally pw_watch.""" 341 parser = argparse.ArgumentParser( 342 prog='pw build', 343 description=__doc__, 344 formatter_class=argparse.RawDescriptionHelpFormatter, 345 ) 346 347 if PW_WATCH_AVAILABLE: 348 parser = add_watch_arguments(parser) 349 else: 350 parser = add_project_builder_arguments(parser) 351 352 if build_recipes is not None: 353 354 def build_recipe_argparse_type(arg: str) -> list[BuildRecipe]: 355 """Return a list of matching presubmit steps.""" 356 assert build_recipes 357 all_recipe_names = list( 358 recipe.display_name for recipe in build_recipes 359 ) 360 filtered_names = fnmatch.filter(all_recipe_names, arg) 361 362 if not filtered_names: 363 recipe_name_str = '\n'.join(sorted(all_recipe_names)) 364 raise argparse.ArgumentTypeError( 365 f'"{arg}" does not match the name of a recipe.\n\n' 366 f'Valid Recipes:\n{recipe_name_str}' 367 ) 368 369 return list( 370 recipe 371 for recipe in build_recipes 372 if recipe.display_name in filtered_names 373 ) 374 375 parser.add_argument( 376 '-r', 377 '--recipe', 378 action='extend', 379 default=[], 380 help=( 381 'Run a build recipe. Include an asterix to match more than one ' 382 "name. For example: --recipe 'gn_*'" 383 ), 384 type=build_recipe_argparse_type, 385 ) 386 387 if presubmit_programs is not None: 388 # Add presubmit step arguments. 389 all_steps = presubmit_programs.all_steps() 390 391 def presubmit_step_argparse_type(arg: str) -> list[Check]: 392 """Return a list of matching presubmit steps.""" 393 filtered_step_names = fnmatch.filter(all_steps.keys(), arg) 394 395 if not filtered_step_names: 396 all_step_names = '\n'.join(sorted(all_steps.keys())) 397 raise argparse.ArgumentTypeError( 398 f'"{arg}" does not match the name of a presubmit step.\n\n' 399 f'Valid Steps:\n{all_step_names}' 400 ) 401 402 return list(all_steps[name] for name in filtered_step_names) 403 404 parser.add_argument( 405 '-s', 406 '--step', 407 action='extend', 408 default=[], 409 help=( 410 'Run presubmit step. Include an asterix to match more than one ' 411 "step name. For example: --step '*_format'" 412 ), 413 type=presubmit_step_argparse_type, 414 ) 415 416 if build_recipes or presubmit_programs: 417 parser.add_argument( 418 '-l', 419 '--list', 420 action='store_true', 421 default=False, 422 help=('List all known build recipes and presubmit steps.'), 423 ) 424 425 if build_recipes: 426 parser.add_argument( 427 '--all', 428 action='store_true', 429 default=False, 430 help=('Run all known build recipes.'), 431 ) 432 433 parser.add_argument( 434 '--progress-bars', 435 action=argparse.BooleanOptionalAction, 436 default=True, 437 help='Show progress bars in the terminal.', 438 ) 439 440 parser.add_argument( 441 '--log-build-steps', 442 action=argparse.BooleanOptionalAction, 443 help='Show ninja build step log lines in output.', 444 ) 445 446 if PW_WATCH_AVAILABLE: 447 parser.add_argument( 448 '-w', 449 '--watch', 450 action='store_true', 451 help='Use pw_watch to monitor changes.', 452 default=False, 453 ) 454 455 parser.add_argument( 456 '-b', 457 '--base', 458 help=( 459 'Git revision to diff for changed files. This is used for ' 460 'presubmit steps.' 461 ), 462 ) 463 464 parser = add_tab_complete_arguments(parser) 465 466 parser.add_argument( 467 '--tab-complete-recipe', 468 nargs='?', 469 help='Print tab completions for the supplied recipe name.', 470 ) 471 472 parser.add_argument( 473 '--tab-complete-presubmit-step', 474 nargs='?', 475 help='Print tab completions for the supplied presubmit name.', 476 ) 477 478 return parser 479 480 481def _get_prefs( 482 args: argparse.Namespace, 483) -> ProjectBuilderPrefs | WatchAppPrefs: 484 """Load either WatchAppPrefs or ProjectBuilderPrefs. 485 486 Applies the command line args to the correct prefs class. 487 488 Returns: 489 A WatchAppPrefs instance if pw_watch is importable, ProjectBuilderPrefs 490 otherwise. 491 """ 492 prefs: ProjectBuilderPrefs | WatchAppPrefs 493 if PW_WATCH_AVAILABLE: 494 prefs = WatchAppPrefs(load_argparse_arguments=add_watch_arguments) 495 prefs.apply_command_line_args(args) 496 else: 497 prefs = ProjectBuilderPrefs( 498 load_argparse_arguments=add_project_builder_arguments, 499 ) 500 prefs.apply_command_line_args(args) 501 return prefs 502 503 504def load_presubmit_build_recipes( 505 presubmit_programs: Programs, 506 presubmit_steps: list[Check], 507 repo_root: Path, 508 presubmit_out_dir: Path, 509 package_root: Path, 510 all_files: list[Path], 511 modified_files: list[Path], 512 default_presubmit_step_names: list[str] | None = None, 513) -> list[BuildRecipe]: 514 """Convert selected presubmit steps into a list of BuildRecipes.""" 515 # Use the default presubmit if no other steps or command line out 516 # directories are provided. 517 if len(presubmit_steps) == 0 and default_presubmit_step_names: 518 default_steps = list( 519 check 520 for name, check in presubmit_programs.all_steps().items() 521 if name in default_presubmit_step_names 522 ) 523 presubmit_steps = default_steps 524 525 presubmit_recipes: list[BuildRecipe] = [] 526 527 for step in presubmit_steps: 528 build_recipe = presubmit_build_recipe( 529 repo_root, 530 presubmit_out_dir, 531 package_root, 532 step, 533 all_files, 534 modified_files, 535 ) 536 if build_recipe: 537 presubmit_recipes.append(build_recipe) 538 539 return presubmit_recipes 540 541 542def _tab_complete_recipe( 543 build_recipes: list[BuildRecipe], 544 text: str = '', 545) -> None: 546 for name in sorted(recipe.display_name for recipe in build_recipes): 547 if name.startswith(text): 548 print(name) 549 550 551def _tab_complete_presubmit_step( 552 presubmit_programs: Programs, 553 text: str = '', 554) -> None: 555 for name in sorted(presubmit_programs.all_steps().keys()): 556 if name.startswith(text): 557 print(name) 558 559 560def _list_steps_and_recipes( 561 presubmit_programs: Programs | None = None, 562 build_recipes: list[BuildRecipe] | None = None, 563) -> None: 564 if presubmit_programs: 565 _LOG.info('Presubmit steps:') 566 print() 567 for name in sorted(presubmit_programs.all_steps().keys()): 568 print(name) 569 print() 570 if build_recipes: 571 _LOG.info('Build recipes:') 572 print() 573 for name in sorted(recipe.display_name for recipe in build_recipes): 574 print(name) 575 print() 576 577 578def _print_usage_help( 579 presubmit_programs: Programs | None = None, 580 build_recipes: list[BuildRecipe] | None = None, 581) -> None: 582 """Print usage examples with known presubmits and build recipes.""" 583 584 def print_pw_build( 585 option: str, arg: str | None = None, end: str = '\n' 586 ) -> None: 587 print( 588 ' '.join( 589 [ 590 'pw build', 591 _COLOR.cyan(option), 592 _COLOR.yellow(arg) if arg else '', 593 ] 594 ), 595 end=end, 596 ) 597 598 if presubmit_programs: 599 print(_COLOR.green('All presubmit steps:')) 600 for name in sorted(presubmit_programs.all_steps().keys()): 601 print_pw_build('--step', name) 602 if build_recipes: 603 if presubmit_programs: 604 # Add a blank line separator 605 print() 606 print(_COLOR.green('All build recipes:')) 607 for name in sorted(recipe.display_name for recipe in build_recipes): 608 print_pw_build('--recipe', name) 609 610 print() 611 print( 612 _COLOR.green( 613 'Recipe and step names may use wildcards and be repeated:' 614 ) 615 ) 616 print_pw_build('--recipe', '"default_*"', end=' ') 617 print( 618 _COLOR.cyan('--step'), 619 _COLOR.yellow('step1'), 620 _COLOR.cyan('--step'), 621 _COLOR.yellow('step2'), 622 ) 623 print() 624 print(_COLOR.green('Run all build recipes:')) 625 print_pw_build('--all') 626 print() 627 print(_COLOR.green('For more help please run:')) 628 print_pw_build('--help') 629 630 631def main( 632 presubmit_programs: Programs | None = None, 633 default_presubmit_step_names: list[str] | None = None, 634 build_recipes: list[BuildRecipe] | None = None, 635 default_build_recipe_names: list[str] | None = None, 636 repo_root: Path | None = None, 637 presubmit_out_dir: Path | None = None, 638 package_root: Path | None = None, 639 default_root_logfile: Path = Path('out/build.txt'), 640 force_pw_watch: bool = False, 641) -> int: 642 """Build upstream Pigweed presubmit steps.""" 643 # pylint: disable=too-many-locals,too-many-branches 644 parser = get_parser(presubmit_programs, build_recipes) 645 args = parser.parse_args() 646 647 if args.tab_complete_option is not None: 648 print_completions_for_option( 649 parser, 650 text=args.tab_complete_option, 651 tab_completion_format=args.tab_complete_format, 652 ) 653 return 0 654 655 log_level = logging.DEBUG if args.debug_logging else logging.INFO 656 657 pw_cli.log.install( 658 level=log_level, 659 use_color=args.colors, 660 # Hide the date from the timestamp 661 time_format='%H:%M:%S', 662 ) 663 664 pw_env = pw_cli.env.pigweed_environment() 665 if pw_env.PW_EMOJI: 666 charset = EMOJI_CHARSET 667 else: 668 charset = ASCII_CHARSET 669 670 if args.tab_complete_recipe is not None: 671 if build_recipes: 672 _tab_complete_recipe(build_recipes, text=args.tab_complete_recipe) 673 # Must exit if there are no build_recipes. 674 return 0 675 676 if args.tab_complete_presubmit_step is not None: 677 if presubmit_programs: 678 _tab_complete_presubmit_step( 679 presubmit_programs, text=args.tab_complete_presubmit_step 680 ) 681 # Must exit if there are no presubmit_programs. 682 return 0 683 684 # List valid steps + recipes. 685 if hasattr(args, 'list') and args.list: 686 _list_steps_and_recipes(presubmit_programs, build_recipes) 687 return 0 688 689 command_line_dash_c_recipes: list[BuildRecipe] = [] 690 # If -C out directories are provided add them to the recipes list. 691 if args.build_directories: 692 prefs = _get_prefs(args) 693 command_line_dash_c_recipes = create_build_recipes(prefs) 694 695 if repo_root is None: 696 repo_root = pw_cli.env.project_root() 697 if presubmit_out_dir is None: 698 presubmit_out_dir = repo_root / 'out/presubmit' 699 if package_root is None: 700 package_root = pw_env.PW_PACKAGE_ROOT 701 702 all_files: list[Path] 703 modified_files: list[Path] 704 705 all_files, modified_files = fetch_file_lists( 706 root=repo_root, 707 repo=repo_root, 708 pathspecs=[], 709 base=args.base, 710 ) 711 712 # Log modified file summary just like pw_presubmit if using --base. 713 if args.base: 714 _LOG.info( 715 'Running steps that apply to modified files since "%s":', args.base 716 ) 717 _LOG.info('') 718 for line in file_summary( 719 mf.relative_to(repo_root) for mf in modified_files 720 ): 721 _LOG.info(line) 722 _LOG.info('') 723 724 selected_build_recipes: list[BuildRecipe] = [] 725 if build_recipes: 726 if hasattr(args, 'recipe'): 727 selected_build_recipes = args.recipe 728 if not selected_build_recipes and default_build_recipe_names: 729 selected_build_recipes = [ 730 recipe 731 for recipe in build_recipes 732 if recipe.display_name in default_build_recipe_names 733 ] 734 735 selected_presubmit_recipes: list[BuildRecipe] = [] 736 if presubmit_programs and hasattr(args, 'step'): 737 selected_presubmit_recipes = load_presubmit_build_recipes( 738 presubmit_programs, 739 args.step, 740 repo_root, 741 presubmit_out_dir, 742 package_root, 743 all_files, 744 modified_files, 745 default_presubmit_step_names=default_presubmit_step_names, 746 ) 747 748 # If no builds specifed on the command line print a useful help message: 749 if ( 750 not selected_build_recipes 751 and not command_line_dash_c_recipes 752 and not selected_presubmit_recipes 753 and not args.all 754 ): 755 _print_usage_help(presubmit_programs, build_recipes) 756 return 1 757 758 if build_recipes and args.all: 759 selected_build_recipes = build_recipes 760 761 # Run these builds in order: 762 recipes_to_build = ( 763 # -C dirs 764 command_line_dash_c_recipes 765 # --step 'name' 766 + selected_presubmit_recipes 767 # --recipe 'name' 768 + selected_build_recipes 769 ) 770 771 # Always set separate build file logging. 772 if not args.logfile: 773 args.logfile = default_root_logfile 774 if not args.separate_logfiles: 775 args.separate_logfiles = True 776 777 workers = 1 778 if args.parallel: 779 # If parallel is requested and parallel_workers is set to 0 run all 780 # recipes in parallel. That is, use the number of recipes as the worker 781 # count. 782 if args.parallel_workers == 0: 783 workers = len(recipes_to_build) 784 else: 785 workers = args.parallel_workers 786 787 project_builder = ProjectBuilder( 788 build_recipes=recipes_to_build, 789 jobs=args.jobs, 790 banners=args.banners, 791 keep_going=args.keep_going, 792 colors=args.colors, 793 charset=charset, 794 separate_build_file_logging=args.separate_logfiles, 795 # If running builds in serial, send all sub build logs to the root log 796 # window (or terminal). 797 send_recipe_logs_to_root=(workers == 1), 798 root_logfile=args.logfile, 799 root_logger=_LOG, 800 log_level=log_level, 801 allow_progress_bars=args.progress_bars, 802 log_build_steps=args.log_build_steps, 803 source_path=args.source_path, 804 ) 805 806 if project_builder.should_use_progress_bars(): 807 project_builder.use_stdout_proxy() 808 809 if PW_WATCH_AVAILABLE and ( 810 force_pw_watch or (args.watch or args.fullscreen) 811 ): 812 event_handler, exclude_list = watch_setup( 813 project_builder, 814 parallel=args.parallel, 815 parallel_workers=workers, 816 fullscreen=args.fullscreen, 817 logfile=args.logfile, 818 separate_logfiles=args.separate_logfiles, 819 ) 820 821 run_watch( 822 event_handler, 823 exclude_list, 824 fullscreen=args.fullscreen, 825 ) 826 return 0 827 828 # One off build 829 return run_builds(project_builder, workers) 830