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