xref: /aosp_15_r20/external/pigweed/pw_watch/py/pw_watch/watch.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
1#!/usr/bin/env python
2# Copyright 2020 The Pigweed Authors
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may not
5# use this file except in compliance with the License. You may obtain a copy of
6# the License at
7#
8#     https://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations under
14# the License.
15"""Watch files for changes and rebuild.
16
17Run arbitrary commands or invoke build systems (Ninja, Bazel and make) on one or
18more build directories whenever source files change.
19
20Examples:
21
22  # Build the default target in out/ using ninja.
23  pw watch -C out
24
25  # Build python.lint and stm32f429i targets in out/ using ninja.
26  pw watch python.lint stm32f429i
27
28  # Build pw_run_tests.modules in the out/cmake directory
29  pw watch -C out/cmake pw_run_tests.modules
30
31  # Build the default target in out/ and pw_apps in out/cmake
32  pw watch -C out -C out/cmake pw_apps
33
34  # Build python.tests in out/ and pw_apps in out/cmake/
35  pw watch python.tests -C out/cmake pw_apps
36
37  # Run 'bazel build' and 'bazel test' on the target '//...' in out/bazel/
38  pw watch -C out/bazel '//...'
39  --build-system-command out/bazel 'bazel build'
40  --build-system-command out/bazel 'bazel test'
41"""
42
43import argparse
44import concurrent.futures
45import errno
46import http.server
47import logging
48import os
49from pathlib import Path
50import re
51import subprocess
52import socketserver
53import sys
54import threading
55from threading import Thread
56from typing import (
57    Callable,
58    Iterable,
59    NoReturn,
60    Sequence,
61)
62
63from watchdog.events import FileSystemEventHandler  # type: ignore[import]
64from watchdog.observers import Observer  # type: ignore[import]
65
66from prompt_toolkit import prompt
67
68from pw_build.build_recipe import BuildRecipe, create_build_recipes
69from pw_build.project_builder import (
70    ProjectBuilder,
71    execute_command_no_logging,
72    execute_command_with_logging,
73    log_build_recipe_start,
74    log_build_recipe_finish,
75    ASCII_CHARSET,
76    EMOJI_CHARSET,
77)
78from pw_build.project_builder_context import get_project_builder_context
79import pw_cli.branding
80import pw_cli.color
81import pw_cli.env
82import pw_cli.log
83import pw_cli.plugins
84import pw_console.python_logging
85
86from pw_watch.argparser import (
87    WATCH_PATTERN_DELIMITER,
88    WATCH_PATTERNS,
89    add_parser_arguments,
90)
91from pw_watch.debounce import DebouncedFunction, Debouncer
92from pw_watch.watch_app import WatchAppPrefs, WatchApp
93
94_COLOR = pw_cli.color.colors()
95_LOG = logging.getLogger('pw_build.watch')
96_ERRNO_INOTIFY_LIMIT_REACHED = 28
97
98# Suppress events under 'fsevents', generated by watchdog on every file
99# event on MacOS.
100# TODO: b/182281481 - Fix file ignoring, rather than just suppressing logs
101_FSEVENTS_LOG = logging.getLogger('fsevents')
102_FSEVENTS_LOG.setLevel(logging.WARNING)
103
104_FULLSCREEN_STATUS_COLUMN_WIDTH = 10
105
106BUILDER_CONTEXT = get_project_builder_context()
107
108
109def git_ignored(file: Path) -> bool:
110    """Returns true if this file is in a Git repo and ignored by that repo.
111
112    Returns true for ignored files that were manually added to a repo.
113    """
114    file = file.resolve()
115    directory = file.parent
116
117    # Run the Git command from file's parent so that the correct repo is used.
118    while True:
119        try:
120            returncode = subprocess.run(
121                ['git', 'check-ignore', '--quiet', '--no-index', file],
122                stdout=subprocess.DEVNULL,
123                stderr=subprocess.DEVNULL,
124                cwd=directory,
125            ).returncode
126            return returncode in (0, 128)
127        except FileNotFoundError:
128            # If the directory no longer exists, try parent directories until
129            # an existing directory is found or all directories have been
130            # checked. This approach makes it possible to check if a deleted
131            # path is ignored in the repo it was originally created in.
132            if directory == directory.parent:
133                return False
134
135            directory = directory.parent
136
137
138class PigweedBuildWatcher(FileSystemEventHandler, DebouncedFunction):
139    """Process filesystem events and launch builds if necessary."""
140
141    # pylint: disable=too-many-instance-attributes
142    NINJA_BUILD_STEP = re.compile(
143        r'^\[(?P<step>[0-9]+)/(?P<total_steps>[0-9]+)\] (?P<action>.*)$'
144    )
145    _FILESYSTEM_EVENTS_THAT_TRIGGER_BUILDS = [
146        'created',
147        'modified',
148        'deleted',
149        'moved',
150    ]
151
152    def __init__(  # pylint: disable=too-many-arguments
153        self,
154        project_builder: ProjectBuilder,
155        patterns: Sequence[str] = (),
156        ignore_patterns: Sequence[str] = (),
157        restart: bool = True,
158        fullscreen: bool = False,
159        banners: bool = True,
160        use_logfile: bool = False,
161        separate_logfiles: bool = False,
162        parallel_workers: int = 1,
163    ):
164        super().__init__()
165
166        self.banners = banners
167        self.current_build_step = ''
168        self.current_build_percent = 0.0
169        self.current_build_errors = 0
170        self.patterns = patterns
171        self.ignore_patterns = ignore_patterns
172        self.project_builder = project_builder
173        self.parallel_workers = parallel_workers
174
175        self.restart_on_changes = restart
176        self.fullscreen_enabled = fullscreen
177        self.watch_app: WatchApp | None = None
178
179        self.use_logfile = use_logfile
180        self.separate_logfiles = separate_logfiles
181        if self.parallel_workers > 1:
182            self.separate_logfiles = True
183
184        self.debouncer = Debouncer(self)
185
186        # Track state of a build. These need to be members instead of locals
187        # due to the split between dispatch(), run(), and on_complete().
188        self.matching_path: Path | None = None
189
190        if (
191            not self.fullscreen_enabled
192            and not self.project_builder.should_use_progress_bars()
193        ):
194            self.wait_for_keypress_thread = threading.Thread(
195                None, self._wait_for_enter
196            )
197            self.wait_for_keypress_thread.start()
198
199        if self.fullscreen_enabled:
200            BUILDER_CONTEXT.using_fullscreen = True
201
202    def rebuild(self):
203        """Rebuild command triggered from watch app."""
204        self.debouncer.press('Manual build requested')
205
206    def _wait_for_enter(self) -> None:
207        try:
208            while True:
209                _ = prompt('')
210                self.rebuild()
211        # Ctrl-C on Unix generates KeyboardInterrupt
212        # Ctrl-Z on Windows generates EOFError
213        except (KeyboardInterrupt, EOFError):
214            # Force stop any running ninja builds.
215            _exit_due_to_interrupt()
216
217    def _path_matches(self, path: Path) -> bool:
218        """Returns true if path matches according to the watcher patterns"""
219        return not any(path.match(x) for x in self.ignore_patterns) and any(
220            path.match(x) for x in self.patterns
221        )
222
223    def dispatch(self, event) -> None:
224        # There isn't any point in triggering builds on new directory creation.
225        # It's the creation or modification of files that indicate something
226        # meaningful enough changed for a build.
227        if event.is_directory:
228            return
229
230        if event.event_type not in self._FILESYSTEM_EVENTS_THAT_TRIGGER_BUILDS:
231            return
232
233        # Collect paths of interest from the event.
234        paths: list[str] = []
235        if hasattr(event, 'dest_path'):
236            paths.append(os.fsdecode(event.dest_path))
237        if event.src_path:
238            paths.append(os.fsdecode(event.src_path))
239        for raw_path in paths:
240            _LOG.debug('File event: %s', raw_path)
241
242        # Check whether Git cares about any of these paths.
243        for path in (Path(p).resolve() for p in paths):
244            if not git_ignored(path) and self._path_matches(path):
245                self._handle_matched_event(path)
246                return
247
248    def _handle_matched_event(self, matching_path: Path) -> None:
249        if self.matching_path is None:
250            self.matching_path = matching_path
251
252        log_message = f'File change detected: {os.path.relpath(matching_path)}'
253        if self.restart_on_changes:
254            if self.fullscreen_enabled and self.watch_app:
255                self.watch_app.clear_log_panes()
256            self.debouncer.press(f'{log_message} Triggering build...')
257        else:
258            _LOG.info('%s ; not rebuilding', log_message)
259
260    def _clear_screen(self) -> None:
261        if self.fullscreen_enabled:
262            return
263        if self.project_builder.should_use_progress_bars():
264            BUILDER_CONTEXT.clear_progress_scrollback()
265            return
266        print('\033c', end='')  # TODO(pwbug/38): Not Windows compatible.
267        sys.stdout.flush()
268
269    # Implementation of DebouncedFunction.run()
270    #
271    # Note: This will run on the timer thread created by the Debouncer, rather
272    # than on the main thread that's watching file events. This enables the
273    # watcher to continue receiving file change events during a build.
274    def run(self) -> None:
275        """Run all the builds and capture pass/fail for each."""
276
277        # Clear the screen and show a banner indicating the build is starting.
278        self._clear_screen()
279
280        if self.banners:
281            for line in pw_cli.branding.banner().splitlines():
282                _LOG.info(line)
283        if self.fullscreen_enabled:
284            _LOG.info(
285                self.project_builder.color.green(
286                    'Watching for changes. Ctrl-d to exit; enter to rebuild'
287                )
288            )
289        else:
290            _LOG.info(
291                self.project_builder.color.green(
292                    'Watching for changes. Ctrl-C to exit; enter to rebuild'
293                )
294            )
295        if self.matching_path:
296            _LOG.info('')
297            _LOG.info('Change detected: %s', self.matching_path)
298
299        num_builds = len(self.project_builder)
300        _LOG.info('Starting build with %d directories', num_builds)
301
302        if self.project_builder.default_logfile:
303            _LOG.info(
304                '%s %s',
305                self.project_builder.color.blue('Root logfile:'),
306                self.project_builder.default_logfile.resolve(),
307            )
308
309        env = os.environ.copy()
310        if self.project_builder.colors:
311            # Force colors in Pigweed subcommands run through the watcher.
312            env['PW_USE_COLOR'] = '1'
313            # Force Ninja to output ANSI colors
314            env['CLICOLOR_FORCE'] = '1'
315
316        # Reset status
317        BUILDER_CONTEXT.set_project_builder(self.project_builder)
318        BUILDER_CONTEXT.set_enter_callback(self.rebuild)
319        BUILDER_CONTEXT.set_building()
320
321        for cfg in self.project_builder:
322            cfg.reset_status()
323
324        with concurrent.futures.ThreadPoolExecutor(
325            max_workers=self.parallel_workers
326        ) as executor:
327            futures = []
328            if (
329                not self.fullscreen_enabled
330                and self.project_builder.should_use_progress_bars()
331            ):
332                BUILDER_CONTEXT.add_progress_bars()
333
334            for i, cfg in enumerate(self.project_builder, start=1):
335                futures.append(executor.submit(self.run_recipe, i, cfg, env))
336
337            for future in concurrent.futures.as_completed(futures):
338                future.result()
339
340        BUILDER_CONTEXT.set_idle()
341
342    def run_recipe(self, index: int, cfg: BuildRecipe, env) -> None:
343        if BUILDER_CONTEXT.interrupted():
344            return
345        if not cfg.enabled:
346            return
347
348        num_builds = len(self.project_builder)
349        index_message = f'[{index}/{num_builds}]'
350
351        log_build_recipe_start(
352            index_message, self.project_builder, cfg, logger=_LOG
353        )
354
355        self.project_builder.run_build(
356            cfg,
357            env,
358            index_message=index_message,
359        )
360
361        log_build_recipe_finish(
362            index_message,
363            self.project_builder,
364            cfg,
365            logger=_LOG,
366        )
367
368    def execute_command(
369        self,
370        command: list,
371        env: dict,
372        recipe: BuildRecipe,
373        # pylint: disable=unused-argument
374        *args,
375        **kwargs,
376        # pylint: enable=unused-argument
377    ) -> bool:
378        """Runs a command with a blank before/after for visual separation."""
379        if self.fullscreen_enabled:
380            return self._execute_command_watch_app(command, env, recipe)
381
382        if self.separate_logfiles:
383            return execute_command_with_logging(
384                command, env, recipe, logger=recipe.log
385            )
386
387        if self.use_logfile:
388            return execute_command_with_logging(
389                command, env, recipe, logger=_LOG
390            )
391
392        return execute_command_no_logging(command, env, recipe)
393
394    def _execute_command_watch_app(
395        self,
396        command: list,
397        env: dict,
398        recipe: BuildRecipe,
399    ) -> bool:
400        """Runs a command with and outputs the logs."""
401        if not self.watch_app:
402            return False
403
404        self.watch_app.redraw_ui()
405
406        def new_line_callback(recipe: BuildRecipe) -> None:
407            self.current_build_step = recipe.status.current_step
408            self.current_build_percent = recipe.status.percent
409            self.current_build_errors = recipe.status.error_count
410
411            if self.watch_app:
412                self.watch_app.logs_redraw()
413
414        desired_logger = _LOG
415        if self.separate_logfiles:
416            desired_logger = recipe.log
417
418        result = execute_command_with_logging(
419            command,
420            env,
421            recipe,
422            logger=desired_logger,
423            line_processed_callback=new_line_callback,
424        )
425
426        self.watch_app.redraw_ui()
427
428        return result
429
430    # Implementation of DebouncedFunction.cancel()
431    def cancel(self) -> bool:
432        if self.restart_on_changes:
433            BUILDER_CONTEXT.restart_flag = True
434            BUILDER_CONTEXT.terminate_and_wait()
435            return True
436
437        return False
438
439    # Implementation of DebouncedFunction.on_complete()
440    def on_complete(self, cancelled: bool = False) -> None:
441        # First, use the standard logging facilities to report build status.
442        if cancelled:
443            _LOG.info('Build stopped.')
444        elif BUILDER_CONTEXT.interrupted():
445            pass  # Don't print anything.
446        elif all(
447            recipe.status.passed()
448            for recipe in self.project_builder
449            if recipe.enabled
450        ):
451            _LOG.info('Finished; all successful')
452        else:
453            _LOG.info('Finished; some builds failed')
454
455        # For non-fullscreen pw watch
456        if (
457            not self.fullscreen_enabled
458            and not self.project_builder.should_use_progress_bars()
459        ):
460            # Show a more distinct colored banner.
461            self.project_builder.print_build_summary(
462                cancelled=cancelled, logger=_LOG
463            )
464        self.project_builder.print_pass_fail_banner(
465            cancelled=cancelled, logger=_LOG
466        )
467
468        if self.watch_app:
469            self.watch_app.redraw_ui()
470        self.matching_path = None
471
472    # Implementation of DebouncedFunction.on_keyboard_interrupt()
473    def on_keyboard_interrupt(self) -> None:
474        _exit_due_to_interrupt()
475
476
477def _exit(code: int) -> NoReturn:
478    # Flush all log handlers
479    logging.shutdown()
480    # Note: The "proper" way to exit is via observer.stop(), then
481    # running a join. However it's slower, so just exit immediately.
482    #
483    # Additionally, since there are several threads in the watcher, the usual
484    # sys.exit approach doesn't work. Instead, run the low level exit which
485    # kills all threads.
486    os._exit(code)  # pylint: disable=protected-access
487
488
489def _exit_due_to_interrupt() -> None:
490    # To keep the log lines aligned with each other in the presence of
491    # a '^C' from the keyboard interrupt, add a newline before the log.
492    print('')
493    _LOG.info('Got Ctrl-C; exiting...')
494    BUILDER_CONTEXT.ctrl_c_interrupt()
495
496
497def _log_inotify_watch_limit_reached():
498    # Show information and suggested commands in OSError: inotify limit reached.
499    _LOG.error(
500        'Inotify watch limit reached: run this in your terminal if '
501        'you are in Linux to temporarily increase inotify limit.'
502    )
503    _LOG.info('')
504    _LOG.info(
505        _COLOR.green(
506            '        sudo sysctl fs.inotify.max_user_watches=' '$NEW_LIMIT$'
507        )
508    )
509    _LOG.info('')
510    _LOG.info(
511        '  Change $NEW_LIMIT$ with an integer number, '
512        'e.g., 20000 should be enough.'
513    )
514
515
516def _exit_due_to_inotify_watch_limit():
517    _log_inotify_watch_limit_reached()
518    _exit(1)
519
520
521def _log_inotify_instance_limit_reached():
522    # Show information and suggested commands in OSError: inotify limit reached.
523    _LOG.error(
524        'Inotify instance limit reached: run this in your terminal if '
525        'you are in Linux to temporarily increase inotify limit.'
526    )
527    _LOG.info('')
528    _LOG.info(
529        _COLOR.green(
530            '        sudo sysctl fs.inotify.max_user_instances=' '$NEW_LIMIT$'
531        )
532    )
533    _LOG.info('')
534    _LOG.info(
535        '  Change $NEW_LIMIT$ with an integer number, '
536        'e.g., 20000 should be enough.'
537    )
538
539
540def _exit_due_to_inotify_instance_limit():
541    _log_inotify_instance_limit_reached()
542    _exit(1)
543
544
545# Go over each directory inside of the current directory.
546# If it is not on the path of elements in directories_to_exclude, add
547# (directory, True) to subdirectories_to_watch and later recursively call
548# Observer() on them.
549# Otherwise add (directory, False) to subdirectories_to_watch and later call
550# Observer() with recursion=False.
551def minimal_watch_directories(to_watch: Path, to_exclude: Iterable[Path]):
552    """Determine which subdirectory to watch recursively"""
553    try:
554        to_watch = Path(to_watch)
555    except TypeError:
556        assert False, "Please watch one directory at a time."
557
558    # Reformat to_exclude.
559    directories_to_exclude: list[Path] = [
560        to_watch.joinpath(directory_to_exclude)
561        for directory_to_exclude in to_exclude
562        if to_watch.joinpath(directory_to_exclude).is_dir()
563    ]
564
565    # Split the relative path of directories_to_exclude (compared to to_watch),
566    # and generate all parent paths needed to be watched without recursion.
567    exclude_dir_parents = {to_watch}
568    for directory_to_exclude in directories_to_exclude:
569        # Irrelevant excluded path
570        if not Path(directory_to_exclude).is_relative_to(to_watch):
571            continue
572
573        parts = list(Path(directory_to_exclude).relative_to(to_watch).parts)[
574            :-1
575        ]
576        dir_tmp = to_watch
577        for part in parts:
578            dir_tmp = Path(dir_tmp, part)
579            exclude_dir_parents.add(dir_tmp)
580
581    # Go over all layers of directory. Append those that are the parents of
582    # directories_to_exclude to the list with recursion==False, and others
583    # with recursion==True.
584    for directory in exclude_dir_parents:
585        dir_path = Path(directory)
586        yield dir_path, False
587        for item in Path(directory).iterdir():
588            if (
589                item.is_dir()
590                and item not in exclude_dir_parents
591                and item not in directories_to_exclude
592            ):
593                yield item, True
594
595
596def get_common_excludes(
597    source_path: Path | None = None,
598) -> list[Path]:
599    """Find commonly excluded directories, and return them as a [Path]"""
600    exclude_list: list[Path] = []
601
602    typical_ignored_directories: list[str] = [
603        '.environment',  # Legacy bootstrap-created CIPD and Python venv.
604        '.presubmit',  # Presubmit-created CIPD and Python venv.
605        '.git',  # Pigweed's git repo.
606        '.mypy_cache',  # Python static analyzer.
607        '.cargo',  # Rust package manager.
608        'environment',  # Bootstrap-created CIPD and Python venv.
609        'out',  # Typical build directory.
610    ]
611
612    pw_project_root_dir = pw_cli.env.project_root()
613
614    if source_path:
615        pw_project_root_dir = source_path
616
617    # Preset exclude for common project structures.
618    exclude_list.extend(
619        pw_project_root_dir / ignored_directory
620        for ignored_directory in typical_ignored_directories
621    )
622
623    # Ignore bazel-* directories
624    exclude_list.extend(
625        d
626        for d in pw_project_root_dir.glob('bazel-*')
627        if d.is_dir() and d.is_symlink()
628    )
629
630    # Check for and warn about legacy directories.
631    legacy_directories = [
632        '.cipd',  # Legacy CIPD location.
633        '.python3-venv',  # Legacy Python venv location.
634    ]
635    found_legacy = False
636    for legacy_directory in legacy_directories:
637        full_legacy_directory = pw_project_root_dir / legacy_directory
638        if full_legacy_directory.is_dir():
639            _LOG.warning(
640                'Legacy environment directory found: %s',
641                str(full_legacy_directory),
642            )
643            exclude_list.append(full_legacy_directory)
644            found_legacy = True
645    if found_legacy:
646        _LOG.warning(
647            'Found legacy environment directory(s); these ' 'should be deleted'
648        )
649
650    return exclude_list
651
652
653def _simple_docs_server(
654    address: str, port: int, path: Path
655) -> Callable[[], None]:
656    class Handler(http.server.SimpleHTTPRequestHandler):
657        def __init__(self, *args, **kwargs):
658            super().__init__(*args, directory=str(path), **kwargs)
659
660        # Disable logs to stdout
661        def log_message(
662            self, format: str, *args  # pylint: disable=redefined-builtin
663        ) -> None:
664            return
665
666    def simple_http_server_thread():
667        with socketserver.TCPServer((address, port), Handler) as httpd:
668            httpd.serve_forever()
669
670    return simple_http_server_thread
671
672
673def _serve_docs(
674    build_dir: Path,
675    docs_path: Path,
676    address: str = '127.0.0.1',
677    port: int = 8000,
678) -> None:
679    address = '127.0.0.1'
680    docs_path = build_dir.joinpath(docs_path.joinpath('html'))
681    server_thread = _simple_docs_server(address, port, docs_path)
682    _LOG.info('Serving docs at http://%s:%d', address, port)
683
684    # Spin up server in a new thread since it blocks
685    threading.Thread(None, server_thread, 'pw_docs_server').start()
686
687
688def watch_logging_init(log_level: int, fullscreen: bool, colors: bool) -> None:
689    # Logging setup
690    if not fullscreen:
691        pw_cli.log.install(
692            level=log_level,
693            use_color=colors,
694            hide_timestamp=False,
695        )
696        return
697
698    watch_logfile = pw_console.python_logging.create_temp_log_file(
699        prefix=__package__
700    )
701
702    pw_cli.log.install(
703        level=logging.DEBUG,
704        use_color=colors,
705        hide_timestamp=False,
706        log_file=watch_logfile,
707    )
708
709
710def watch_setup(  # pylint: disable=too-many-locals
711    project_builder: ProjectBuilder,
712    # NOTE: The following args should have defaults matching argparse. This
713    # allows use of watch_setup by other project build scripts.
714    patterns: str = WATCH_PATTERN_DELIMITER.join(WATCH_PATTERNS),
715    ignore_patterns_string: str = '',
716    exclude_list: list[Path] | None = None,
717    restart: bool = True,
718    serve_docs: bool = False,
719    serve_docs_port: int = 8000,
720    serve_docs_path: Path = Path('docs/gen/docs'),
721    fullscreen: bool = False,
722    banners: bool = True,
723    logfile: Path | None = None,
724    separate_logfiles: bool = False,
725    parallel: bool = False,
726    parallel_workers: int = 0,
727    # pylint: disable=unused-argument
728    default_build_targets: list[str] | None = None,
729    build_directories: list[str] | None = None,
730    build_system_commands: list[str] | None = None,
731    run_command: list[str] | None = None,
732    jobs: int | None = None,
733    keep_going: bool = False,
734    colors: bool = True,
735    debug_logging: bool = False,
736    source_path: Path | None = None,
737    default_build_system: str | None = None,
738    # pylint: enable=unused-argument
739    # pylint: disable=too-many-arguments
740) -> tuple[PigweedBuildWatcher, list[Path]]:
741    """Watches files and runs Ninja commands when they change."""
742    watch_logging_init(
743        log_level=project_builder.default_log_level,
744        fullscreen=fullscreen,
745        colors=colors,
746    )
747
748    # Update the project_builder log formatters since pw_cli.log.install may
749    # have changed it.
750    project_builder.apply_root_log_formatting()
751
752    if project_builder.should_use_progress_bars():
753        project_builder.use_stdout_proxy()
754
755    _LOG.info('Starting Pigweed build watcher')
756
757    build_recipes = project_builder.build_recipes
758
759    # Preset exclude list for pigweed directory.
760    if not exclude_list:
761        exclude_list = []
762    exclude_list += get_common_excludes(source_path=source_path)
763
764    # Add build directories to the exclude list if they are not already ignored.
765    for build_dir in list(
766        cfg.build_dir.resolve()
767        for cfg in build_recipes
768        if isinstance(cfg.build_dir, Path)
769    ):
770        if not any(
771            # Check if build_dir.is_relative_to(excluded_dir)
772            build_dir == excluded_dir or excluded_dir in build_dir.parents
773            for excluded_dir in exclude_list
774        ):
775            exclude_list.append(build_dir)
776
777    for i, build_recipe in enumerate(build_recipes, start=1):
778        _LOG.info('Will build [%d/%d]: %s', i, len(build_recipes), build_recipe)
779
780    _LOG.debug('Patterns: %s', patterns)
781
782    for excluded_dir in exclude_list:
783        _LOG.debug('exclude-list: %s', excluded_dir)
784
785    if serve_docs:
786        _serve_docs(
787            build_recipes[0].build_dir, serve_docs_path, port=serve_docs_port
788        )
789
790    # Ignore the user-specified patterns.
791    ignore_patterns = (
792        ignore_patterns_string.split(WATCH_PATTERN_DELIMITER)
793        if ignore_patterns_string
794        else []
795    )
796
797    # Add project_builder logfiles to ignore_patterns
798    if project_builder.default_logfile:
799        ignore_patterns.append(str(project_builder.default_logfile))
800    if project_builder.separate_build_file_logging:
801        for recipe in project_builder:
802            if recipe.logfile:
803                ignore_patterns.append(str(recipe.logfile))
804
805    workers = 1
806    if parallel:
807        # If parallel is requested and parallel_workers is set to 0 run all
808        # recipes in parallel. That is, use the number of recipes as the worker
809        # count.
810        if parallel_workers == 0:
811            workers = len(project_builder)
812        else:
813            workers = parallel_workers
814
815    event_handler = PigweedBuildWatcher(
816        project_builder=project_builder,
817        patterns=patterns.split(WATCH_PATTERN_DELIMITER),
818        ignore_patterns=ignore_patterns,
819        restart=restart,
820        fullscreen=fullscreen,
821        banners=banners,
822        use_logfile=bool(logfile),
823        separate_logfiles=separate_logfiles,
824        parallel_workers=workers,
825    )
826
827    project_builder.execute_command = event_handler.execute_command
828
829    return event_handler, exclude_list
830
831
832def watch(
833    event_handler: PigweedBuildWatcher,
834    exclude_list: list[Path],
835    watch_file_path: Path = Path.cwd(),
836):
837    """Watches files and runs Ninja commands when they change."""
838    if event_handler.project_builder.source_path:
839        watch_file_path = event_handler.project_builder.source_path
840
841    # Try to make a short display path for the watched directory that has
842    # "$HOME" instead of the full home directory. This is nice for users
843    # who have deeply nested home directories.
844    path_to_log = str(watch_file_path.resolve()).replace(
845        str(Path.home()), '$HOME'
846    )
847
848    try:
849        # It can take awhile to configure the filesystem watcher, so have the
850        # message reflect that with the "...". Run inside the try: to
851        # gracefully handle the user Ctrl-C'ing out during startup.
852
853        _LOG.info('Attaching filesystem watcher to %s/...', path_to_log)
854
855        # Observe changes for all files in the root directory. Whether the
856        # directory should be observed recursively or not is determined by the
857        # second element in subdirectories_to_watch.
858        observers = []
859        for path, rec in minimal_watch_directories(
860            watch_file_path, exclude_list
861        ):
862            observer = Observer()
863            observer.schedule(
864                event_handler,
865                str(path),
866                recursive=rec,
867            )
868            observer.start()
869            observers.append(observer)
870
871        event_handler.debouncer.press('Triggering initial build...')
872        for observer in observers:
873            while observer.is_alive():
874                observer.join(1)
875        _LOG.error('observers joined')
876
877    # Ctrl-C on Unix generates KeyboardInterrupt
878    # Ctrl-Z on Windows generates EOFError
879    except (KeyboardInterrupt, EOFError):
880        _exit_due_to_interrupt()
881    except OSError as err:
882        if err.args[0] == _ERRNO_INOTIFY_LIMIT_REACHED:
883            if event_handler.watch_app:
884                event_handler.watch_app.exit(
885                    log_after_shutdown=_log_inotify_watch_limit_reached
886                )
887            elif event_handler.project_builder.should_use_progress_bars():
888                BUILDER_CONTEXT.exit(
889                    log_after_shutdown=_log_inotify_watch_limit_reached,
890                )
891            else:
892                _exit_due_to_inotify_watch_limit()
893        if err.errno == errno.EMFILE:
894            if event_handler.watch_app:
895                event_handler.watch_app.exit(
896                    log_after_shutdown=_log_inotify_instance_limit_reached
897                )
898            elif event_handler.project_builder.should_use_progress_bars():
899                BUILDER_CONTEXT.exit(
900                    log_after_shutdown=_log_inotify_instance_limit_reached
901                )
902            else:
903                _exit_due_to_inotify_instance_limit()
904        raise err
905
906
907def run_watch(
908    event_handler: PigweedBuildWatcher,
909    exclude_list: list[Path],
910    prefs: WatchAppPrefs | None = None,
911    fullscreen: bool = False,
912) -> None:
913    """Start pw_watch."""
914    if not prefs:
915        prefs = WatchAppPrefs(load_argparse_arguments=add_parser_arguments)
916
917    if fullscreen:
918        watch_thread = Thread(
919            target=watch,
920            args=(event_handler, exclude_list),
921            daemon=True,
922        )
923        watch_thread.start()
924        watch_app = WatchApp(
925            event_handler=event_handler,
926            prefs=prefs,
927        )
928
929        event_handler.watch_app = watch_app
930        watch_app.run()
931
932    else:
933        watch(event_handler, exclude_list)
934
935
936def get_parser() -> argparse.ArgumentParser:
937    parser = argparse.ArgumentParser(
938        description=__doc__,
939        formatter_class=argparse.RawDescriptionHelpFormatter,
940    )
941    parser = add_parser_arguments(parser)
942    return parser
943
944
945def main() -> int:
946    """Watch files for changes and rebuild."""
947    parser = get_parser()
948    args = parser.parse_args()
949
950    prefs = WatchAppPrefs(load_argparse_arguments=add_parser_arguments)
951    prefs.apply_command_line_args(args)
952    build_recipes = create_build_recipes(prefs)
953
954    env = pw_cli.env.pigweed_environment()
955    if env.PW_EMOJI:
956        charset = EMOJI_CHARSET
957    else:
958        charset = ASCII_CHARSET
959
960    # Force separate-logfiles for split window panes if running in parallel.
961    separate_logfiles = args.separate_logfiles
962    if args.parallel:
963        separate_logfiles = True
964
965    def _recipe_abort(*args) -> None:
966        _LOG.critical(*args)
967
968    project_builder = ProjectBuilder(
969        build_recipes=build_recipes,
970        jobs=args.jobs,
971        banners=args.banners,
972        keep_going=args.keep_going,
973        colors=args.colors,
974        charset=charset,
975        separate_build_file_logging=separate_logfiles,
976        root_logfile=args.logfile,
977        root_logger=_LOG,
978        log_level=logging.DEBUG if args.debug_logging else logging.INFO,
979        abort_callback=_recipe_abort,
980        source_path=args.source_path,
981    )
982
983    event_handler, exclude_list = watch_setup(project_builder, **vars(args))
984
985    run_watch(
986        event_handler,
987        exclude_list,
988        prefs=prefs,
989        fullscreen=args.fullscreen,
990    )
991
992    return 0
993
994
995if __name__ == '__main__':
996    main()
997