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