xref: /aosp_15_r20/external/pigweed/pw_build/py/pw_build/project_builder_presubmit_runner.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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