• 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
17pw watch runs Ninja in a build directory when source files change. It works with
18any Ninja project (GN or CMake).
19
20Usage examples:
21
22  # Find a build directory and build the default target
23  pw watch
24
25  # Find a build directory and build the stm32f429i target
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  # Find a directory and build python.tests, and build pw_apps in out/cmake
35  pw watch python.tests -C out/cmake pw_apps
36"""
37
38import argparse
39from dataclasses import dataclass
40import errno
41from itertools import zip_longest
42import logging
43import os
44from pathlib import Path
45import re
46import shlex
47import subprocess
48import sys
49import threading
50from threading import Thread
51from typing import (
52    Iterable,
53    List,
54    NamedTuple,
55    NoReturn,
56    Optional,
57    Sequence,
58    Tuple,
59)
60
61import httpwatcher  # type: ignore
62
63from watchdog.events import FileSystemEventHandler  # type: ignore[import]
64from watchdog.observers import Observer  # type: ignore[import]
65
66from prompt_toolkit.formatted_text.base import OneStyleAndTextTuple
67from prompt_toolkit.formatted_text import StyleAndTextTuples
68
69import pw_cli.branding
70import pw_cli.color
71import pw_cli.env
72import pw_cli.log
73import pw_cli.plugins
74import pw_console.python_logging
75
76from pw_watch.watch_app import WatchApp
77from pw_watch.debounce import DebouncedFunction, Debouncer
78
79_COLOR = pw_cli.color.colors()
80_LOG = logging.getLogger('pw_watch')
81_NINJA_LOG = logging.getLogger('pw_watch_ninja_output')
82_ERRNO_INOTIFY_LIMIT_REACHED = 28
83
84# Suppress events under 'fsevents', generated by watchdog on every file
85# event on MacOS.
86# TODO(b/182281481): Fix file ignoring, rather than just suppressing logs
87_FSEVENTS_LOG = logging.getLogger('fsevents')
88_FSEVENTS_LOG.setLevel(logging.WARNING)
89
90_PASS_MESSAGE = """
91  ██████╗  █████╗ ███████╗███████╗██╗
92  ██╔══██╗██╔══██╗██╔════╝██╔════╝██║
93  ██████╔╝███████║███████╗███████╗██║
94  ██╔═══╝ ██╔══██║╚════██║╚════██║╚═╝
95  ██║     ██║  ██║███████║███████║██╗
96  ╚═╝     ╚═╝  ╚═╝╚══════╝╚══════╝╚═╝
97"""
98
99# Pick a visually-distinct font from "PASS" to ensure that readers can't
100# possibly mistake the difference between the two states.
101_FAIL_MESSAGE = """
102   ▄██████▒░▄▄▄       ██▓  ░██▓
103  ▓█▓     ░▒████▄    ▓██▒  ░▓██▒
104  ▒████▒   ░▒█▀  ▀█▄  ▒██▒ ▒██░
105  ░▓█▒    ░░██▄▄▄▄██ ░██░  ▒██░
106  ░▒█░      ▓█   ▓██▒░██░░ ████████▒
107   ▒█░      ▒▒   ▓▒█░░▓  ░  ▒░▓  ░
108   ░▒        ▒   ▒▒ ░ ▒ ░░  ░ ▒  ░
109   ░ ░       ░   ▒    ▒ ░   ░ ░
110                 ░  ░ ░       ░  ░
111"""
112
113_FULLSCREEN_STATUS_COLUMN_WIDTH = 10
114
115
116# TODO(keir): Figure out a better strategy for exiting. The problem with the
117# watcher is that doing a "clean exit" is slow. However, by directly exiting,
118# we remove the possibility of the wrapper script doing anything on exit.
119def _die(*args) -> NoReturn:
120    _LOG.critical(*args)
121    sys.exit(1)
122
123
124class WatchCharset(NamedTuple):
125    slug_ok: str
126    slug_fail: str
127
128
129_ASCII_CHARSET = WatchCharset(_COLOR.green('OK  '), _COLOR.red('FAIL'))
130_EMOJI_CHARSET = WatchCharset('✔️ ', '��')
131
132
133@dataclass(frozen=True)
134class BuildCommand:
135    build_dir: Path
136    targets: Tuple[str, ...] = ()
137
138    def args(self) -> Tuple[str, ...]:
139        return (str(self.build_dir), *self.targets)
140
141    def __str__(self) -> str:
142        return ' '.join(shlex.quote(arg) for arg in self.args())
143
144
145def git_ignored(file: Path) -> bool:
146    """Returns true if this file is in a Git repo and ignored by that repo.
147
148    Returns true for ignored files that were manually added to a repo.
149    """
150    file = file.resolve()
151    directory = file.parent
152
153    # Run the Git command from file's parent so that the correct repo is used.
154    while True:
155        try:
156            returncode = subprocess.run(
157                ['git', 'check-ignore', '--quiet', '--no-index', file],
158                stdout=subprocess.DEVNULL,
159                stderr=subprocess.DEVNULL,
160                cwd=directory).returncode
161            return returncode in (0, 128)
162        except FileNotFoundError:
163            # If the directory no longer exists, try parent directories until
164            # an existing directory is found or all directories have been
165            # checked. This approach makes it possible to check if a deleted
166            # path is ignored in the repo it was originally created in.
167            if directory == directory.parent:
168                return False
169
170            directory = directory.parent
171
172
173class PigweedBuildWatcher(FileSystemEventHandler, DebouncedFunction):
174    """Process filesystem events and launch builds if necessary."""
175    # pylint: disable=too-many-instance-attributes
176    NINJA_BUILD_STEP = re.compile(
177        r'^\[(?P<step>[0-9]+)/(?P<total_steps>[0-9]+)\] (?P<action>.*)$')
178
179    def __init__(
180        self,
181        build_commands: Sequence[BuildCommand],
182        patterns: Sequence[str] = (),
183        ignore_patterns: Sequence[str] = (),
184        charset: WatchCharset = _ASCII_CHARSET,
185        restart: bool = True,
186        jobs: int = None,
187        fullscreen: bool = False,
188        banners: bool = True,
189    ):
190        super().__init__()
191
192        self.banners = banners
193        self.status_message: Optional[OneStyleAndTextTuple] = None
194        self.result_message: Optional[StyleAndTextTuples] = None
195        self.current_stdout = ''
196        self.current_build_step = ''
197        self.current_build_percent = 0.0
198        self.current_build_errors = 0
199        self.patterns = patterns
200        self.ignore_patterns = ignore_patterns
201        self.build_commands = build_commands
202        self.charset: WatchCharset = charset
203
204        self.restart_on_changes = restart
205        self.fullscreen_enabled = fullscreen
206        self.watch_app: Optional[WatchApp] = None
207        self._current_build: subprocess.Popen
208
209        self._extra_ninja_args = [] if jobs is None else [f'-j{jobs}']
210
211        self.debouncer = Debouncer(self)
212
213        # Track state of a build. These need to be members instead of locals
214        # due to the split between dispatch(), run(), and on_complete().
215        self.matching_path: Optional[Path] = None
216        self.builds_succeeded: List[bool] = []
217
218        if not self.fullscreen_enabled:
219            self.wait_for_keypress_thread = threading.Thread(
220                None, self._wait_for_enter)
221            self.wait_for_keypress_thread.start()
222
223    def rebuild(self):
224        """ Rebuild command triggered from watch app."""
225        self._current_build.terminate()
226        self._current_build.wait()
227        self.debouncer.press('Manual build requested')
228
229    def _wait_for_enter(self) -> NoReturn:
230        try:
231            while True:
232                _ = input()
233                self._current_build.terminate()
234                self._current_build.wait()
235
236                self.debouncer.press('Manual build requested...')
237        # Ctrl-C on Unix generates KeyboardInterrupt
238        # Ctrl-Z on Windows generates EOFError
239        except (KeyboardInterrupt, EOFError):
240            _exit_due_to_interrupt()
241
242    def _path_matches(self, path: Path) -> bool:
243        """Returns true if path matches according to the watcher patterns"""
244        return (not any(path.match(x) for x in self.ignore_patterns)
245                and any(path.match(x) for x in self.patterns))
246
247    def dispatch(self, event) -> None:
248        # There isn't any point in triggering builds on new directory creation.
249        # It's the creation or modification of files that indicate something
250        # meaningful enough changed for a build.
251        if event.is_directory:
252            return
253
254        # Collect paths of interest from the event.
255        paths: List[str] = []
256        if hasattr(event, 'dest_path'):
257            paths.append(os.fsdecode(event.dest_path))
258        if event.src_path:
259            paths.append(os.fsdecode(event.src_path))
260        for raw_path in paths:
261            _LOG.debug('File event: %s', raw_path)
262
263        # Check whether Git cares about any of these paths.
264        for path in (Path(p).resolve() for p in paths):
265            if not git_ignored(path) and self._path_matches(path):
266                self._handle_matched_event(path)
267                return
268
269    def _handle_matched_event(self, matching_path: Path) -> None:
270        if self.matching_path is None:
271            self.matching_path = matching_path
272
273        log_message = f'File change detected: {os.path.relpath(matching_path)}'
274        if self.restart_on_changes:
275            if self.fullscreen_enabled and self.watch_app:
276                self.watch_app.rebuild_on_filechange()
277            self.debouncer.press(f'{log_message} Triggering build...')
278        else:
279            _LOG.info('%s ; not rebuilding', log_message)
280
281    def _clear_screen(self) -> None:
282        if not self.fullscreen_enabled:
283            print('\033c', end='')  # TODO(pwbug/38): Not Windows compatible.
284            sys.stdout.flush()
285
286    # Implementation of DebouncedFunction.run()
287    #
288    # Note: This will run on the timer thread created by the Debouncer, rather
289    # than on the main thread that's watching file events. This enables the
290    # watcher to continue receiving file change events during a build.
291    def run(self) -> None:
292        """Run all the builds in serial and capture pass/fail for each."""
293
294        # Clear the screen and show a banner indicating the build is starting.
295        self._clear_screen()
296
297        if self.fullscreen_enabled:
298            self.create_result_message()
299            _LOG.info(
300                _COLOR.green(
301                    'Watching for changes. Ctrl-d to exit; enter to rebuild'))
302        else:
303            for line in pw_cli.branding.banner().splitlines():
304                _LOG.info(line)
305            _LOG.info(
306                _COLOR.green(
307                    '  Watching for changes. Ctrl-C to exit; enter to rebuild')
308            )
309        _LOG.info('')
310        _LOG.info('Change detected: %s', self.matching_path)
311
312        self._clear_screen()
313
314        self.builds_succeeded = []
315        num_builds = len(self.build_commands)
316        _LOG.info('Starting build with %d directories', num_builds)
317
318        env = os.environ.copy()
319        # Force colors in Pigweed subcommands run through the watcher.
320        env['PW_USE_COLOR'] = '1'
321        # Force Ninja to output ANSI colors
322        env['CLICOLOR_FORCE'] = '1'
323
324        for i, cmd in enumerate(self.build_commands, 1):
325            index = f'[{i}/{num_builds}]'
326            self.builds_succeeded.append(self._run_build(index, cmd, env))
327            if self.builds_succeeded[-1]:
328                level = logging.INFO
329                tag = '(OK)'
330            else:
331                level = logging.ERROR
332                tag = '(FAIL)'
333
334            _LOG.log(level, '%s Finished build: %s %s', index, cmd, tag)
335            self.create_result_message()
336
337    def create_result_message(self):
338        if not self.fullscreen_enabled:
339            return
340
341        self.result_message = []
342        first_building_target_found = False
343        for (succeeded, command) in zip_longest(self.builds_succeeded,
344                                                self.build_commands):
345            if succeeded:
346                self.result_message.append(
347                    ('class:theme-fg-green',
348                     'OK'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH)))
349            elif succeeded is None and not first_building_target_found:
350                first_building_target_found = True
351                self.result_message.append(
352                    ('class:theme-fg-yellow',
353                     'Building'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH)))
354            elif first_building_target_found:
355                self.result_message.append(
356                    ('', ''.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH)))
357            else:
358                self.result_message.append(
359                    ('class:theme-fg-red',
360                     'Failed'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH)))
361            self.result_message.append(('', f'  {command}\n'))
362
363    def _run_build(self, index: str, cmd: BuildCommand, env: dict) -> bool:
364        # Make sure there is a build.ninja file for Ninja to use.
365        build_ninja = cmd.build_dir / 'build.ninja'
366        if not build_ninja.exists():
367            # If this is a CMake directory, prompt the user to re-run CMake.
368            if cmd.build_dir.joinpath('CMakeCache.txt').exists():
369                _LOG.error('%s %s does not exist; re-run CMake to generate it',
370                           index, build_ninja)
371                return False
372
373            _LOG.warning('%s %s does not exist; running gn gen %s', index,
374                         build_ninja, cmd.build_dir)
375            if not self._execute_command(['gn', 'gen', cmd.build_dir], env):
376                return False
377
378        command = ['ninja', *self._extra_ninja_args, '-C', *cmd.args()]
379        _LOG.info('%s Starting build: %s', index,
380                  ' '.join(shlex.quote(arg) for arg in command))
381
382        return self._execute_command(command, env)
383
384    def _execute_command(self, command: list, env: dict) -> bool:
385        """Runs a command with a blank before/after for visual separation."""
386        self.current_build_errors = 0
387        self.status_message = (
388            'class:theme-fg-yellow',
389            'Building'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH))
390        if self.fullscreen_enabled:
391            return self._execute_command_watch_app(command, env)
392        print()
393        self._current_build = subprocess.Popen(command, env=env)
394        returncode = self._current_build.wait()
395        print()
396        return returncode == 0
397
398    def _execute_command_watch_app(self, command: list, env: dict) -> bool:
399        """Runs a command with and outputs the logs."""
400        if not self.watch_app:
401            return False
402        self.current_stdout = ''
403        returncode = None
404        with subprocess.Popen(command,
405                              env=env,
406                              stdout=subprocess.PIPE,
407                              stderr=subprocess.STDOUT,
408                              errors='replace') as proc:
409            self._current_build = proc
410
411            # Empty line at the start.
412            _NINJA_LOG.info('')
413            while returncode is None:
414                if not proc.stdout:
415                    continue
416
417                output = proc.stdout.readline()
418                self.current_stdout += output
419
420                line_match_result = self.NINJA_BUILD_STEP.match(output)
421                if line_match_result:
422                    matches = line_match_result.groupdict()
423                    self.current_build_step = line_match_result.group(0)
424                    self.current_build_percent = float(
425                        int(matches.get('step', 0)) /
426                        int(matches.get('total_steps', 1)))
427
428                elif output.startswith(WatchApp.NINJA_FAILURE_TEXT):
429                    _NINJA_LOG.critical(output.strip())
430                    self.current_build_errors += 1
431
432                else:
433                    # Mypy output mixes character encoding in its colored output
434                    # due to it's use of the curses module retrieving the 'sgr0'
435                    # (or exit_attribute_mode) capability from the host
436                    # machine's terminfo database.
437                    #
438                    # This can result in this sequence ending up in STDOUT as
439                    # b'\x1b(B\x1b[m'. (B tells terminals to interpret text as
440                    # USASCII encoding but will appear in prompt_toolkit as a B
441                    # character.
442                    #
443                    # The following replace calls will strip out those
444                    # instances.
445                    _NINJA_LOG.info(
446                        output.replace('\x1b(B\x1b[m',
447                                       '').replace('\x1b[1m', '').strip())
448                self.watch_app.redraw_ui()
449
450                returncode = proc.poll()
451            # Empty line at the end.
452            _NINJA_LOG.info('')
453
454        return returncode == 0
455
456    # Implementation of DebouncedFunction.cancel()
457    def cancel(self) -> bool:
458        if self.restart_on_changes:
459            self._current_build.terminate()
460            self._current_build.wait()
461            return True
462
463        return False
464
465    # Implementation of DebouncedFunction.run()
466    def on_complete(self, cancelled: bool = False) -> None:
467        # First, use the standard logging facilities to report build status.
468        if cancelled:
469            self.status_message = (
470                '', 'Cancelled'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH))
471            _LOG.error('Finished; build was interrupted')
472        elif all(self.builds_succeeded):
473            self.status_message = (
474                'class:theme-fg-green',
475                'Succeeded'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH))
476            _LOG.info('Finished; all successful')
477        else:
478            self.status_message = (
479                'class:theme-fg-red',
480                'Failed'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH))
481            _LOG.info('Finished; some builds failed')
482
483        # Show individual build results for fullscreen app
484        if self.fullscreen_enabled:
485            self.create_result_message()
486        # For non-fullscreen pw watch
487        else:
488            # Show a more distinct colored banner.
489            if not cancelled:
490                # Write out build summary table so you can tell which builds
491                # passed and which builds failed.
492                _LOG.info('')
493                _LOG.info(' .------------------------------------')
494                _LOG.info(' |')
495                for (succeeded, cmd) in zip(self.builds_succeeded,
496                                            self.build_commands):
497                    slug = (self.charset.slug_ok
498                            if succeeded else self.charset.slug_fail)
499                    _LOG.info(' |   %s  %s', slug, cmd)
500                _LOG.info(' |')
501                _LOG.info(" '------------------------------------")
502            else:
503                # Build was interrupted.
504                _LOG.info('')
505                _LOG.info(' .------------------------------------')
506                _LOG.info(' |')
507                _LOG.info(' |  %s- interrupted', self.charset.slug_fail)
508                _LOG.info(' |')
509                _LOG.info(" '------------------------------------")
510
511            # Show a large color banner for the overall result.
512            if self.banners:
513                if all(self.builds_succeeded) and not cancelled:
514                    for line in _PASS_MESSAGE.splitlines():
515                        _LOG.info(_COLOR.green(line))
516                else:
517                    for line in _FAIL_MESSAGE.splitlines():
518                        _LOG.info(_COLOR.red(line))
519
520        if self.watch_app:
521            self.watch_app.redraw_ui()
522        self.matching_path = None
523
524    # Implementation of DebouncedFunction.on_keyboard_interrupt()
525    def on_keyboard_interrupt(self) -> NoReturn:
526        _exit_due_to_interrupt()
527
528
529_WATCH_PATTERN_DELIMITER = ','
530_WATCH_PATTERNS = (
531    '*.bloaty',
532    '*.c',
533    '*.cc',
534    '*.css',
535    '*.cpp',
536    '*.cmake',
537    'CMakeLists.txt',
538    '*.gn',
539    '*.gni',
540    '*.go',
541    '*.h',
542    '*.hpp',
543    '*.ld',
544    '*.md',
545    '*.options',
546    '*.proto',
547    '*.py',
548    '*.rst',
549    '*.s',
550    '*.S',
551)
552
553
554def add_parser_arguments(parser: argparse.ArgumentParser) -> None:
555    """Sets up an argument parser for pw watch."""
556    parser.add_argument('--patterns',
557                        help=(_WATCH_PATTERN_DELIMITER +
558                              '-delimited list of globs to '
559                              'watch to trigger recompile'),
560                        default=_WATCH_PATTERN_DELIMITER.join(_WATCH_PATTERNS))
561    parser.add_argument('--ignore_patterns',
562                        dest='ignore_patterns_string',
563                        help=(_WATCH_PATTERN_DELIMITER +
564                              '-delimited list of globs to '
565                              'ignore events from'))
566
567    parser.add_argument('--exclude_list',
568                        nargs='+',
569                        type=Path,
570                        help='directories to ignore during pw watch',
571                        default=[])
572    parser.add_argument('--no-restart',
573                        dest='restart',
574                        action='store_false',
575                        help='do not restart ongoing builds if files change')
576    parser.add_argument(
577        'default_build_targets',
578        nargs='*',
579        metavar='target',
580        default=[],
581        help=('Automatically locate a build directory and build these '
582              'targets. For example, `host docs` searches for a Ninja '
583              'build directory at out/ and builds the `host` and `docs` '
584              'targets. To specify one or more directories, ust the '
585              '-C / --build_directory option.'))
586    parser.add_argument(
587        '-C',
588        '--build_directory',
589        dest='build_directories',
590        nargs='+',
591        action='append',
592        default=[],
593        metavar=('directory', 'target'),
594        help=('Specify a build directory and optionally targets to '
595              'build. `pw watch -C out tgt` is equivalent to `ninja '
596              '-C out tgt`'))
597    parser.add_argument(
598        '--serve-docs',
599        dest='serve_docs',
600        action='store_true',
601        default=False,
602        help='Start a webserver for docs on localhost. The port for this '
603        ' webserver can be set with the --serve-docs-port option. '
604        ' Defaults to http://127.0.0.1:8000')
605    parser.add_argument(
606        '--serve-docs-port',
607        dest='serve_docs_port',
608        type=int,
609        default=8000,
610        help='Set the port for the docs webserver. Default to 8000.')
611
612    parser.add_argument(
613        '--serve-docs-path',
614        dest='serve_docs_path',
615        type=Path,
616        default="docs/gen/docs",
617        help='Set the path for the docs to serve. Default to docs/gen/docs'
618        ' in the build directory.')
619    parser.add_argument(
620        '-j',
621        '--jobs',
622        type=int,
623        help="Number of cores to use; defaults to Ninja's default")
624    parser.add_argument('-f',
625                        '--fullscreen',
626                        action='store_true',
627                        default=False,
628                        help='Use a fullscreen interface.')
629    parser.add_argument('--debug-logging',
630                        action='store_true',
631                        help='Enable debug logging.')
632    parser.add_argument('--no-banners',
633                        dest='banners',
634                        action='store_false',
635                        help='Hide pass/fail banners.')
636
637
638def _exit(code: int) -> NoReturn:
639    # Note: The "proper" way to exit is via observer.stop(), then
640    # running a join. However it's slower, so just exit immediately.
641    #
642    # Additionally, since there are several threads in the watcher, the usual
643    # sys.exit approach doesn't work. Instead, run the low level exit which
644    # kills all threads.
645    os._exit(code)  # pylint: disable=protected-access
646
647
648def _exit_due_to_interrupt() -> NoReturn:
649    # To keep the log lines aligned with each other in the presence of
650    # a '^C' from the keyboard interrupt, add a newline before the log.
651    _LOG.info('Got Ctrl-C; exiting...')
652    _exit(0)
653
654
655def _exit_due_to_inotify_watch_limit():
656    # Show information and suggested commands in OSError: inotify limit reached.
657    _LOG.error('Inotify watch limit reached: run this in your terminal if '
658               'you are in Linux to temporarily increase inotify limit.  \n')
659    _LOG.info(
660        _COLOR.green('        sudo sysctl fs.inotify.max_user_watches='
661                     '$NEW_LIMIT$\n'))
662    _LOG.info('  Change $NEW_LIMIT$ with an integer number, '
663              'e.g., 20000 should be enough.')
664    _exit(0)
665
666
667def _exit_due_to_inotify_instance_limit():
668    # Show information and suggested commands in OSError: inotify limit reached.
669    _LOG.error('Inotify instance limit reached: run this in your terminal if '
670               'you are in Linux to temporarily increase inotify limit.  \n')
671    _LOG.info(
672        _COLOR.green('        sudo sysctl fs.inotify.max_user_instances='
673                     '$NEW_LIMIT$\n'))
674    _LOG.info('  Change $NEW_LIMIT$ with an integer number, '
675              'e.g., 20000 should be enough.')
676    _exit(0)
677
678
679def _exit_due_to_pigweed_not_installed():
680    # Show information and suggested commands when pigweed environment variable
681    # not found.
682    _LOG.error('Environment variable $PW_ROOT not defined or is defined '
683               'outside the current directory.')
684    _LOG.error('Did you forget to activate the Pigweed environment? '
685               'Try source ./activate.sh')
686    _LOG.error('Did you forget to install the Pigweed environment? '
687               'Try source ./bootstrap.sh')
688    _exit(1)
689
690
691# Go over each directory inside of the current directory.
692# If it is not on the path of elements in directories_to_exclude, add
693# (directory, True) to subdirectories_to_watch and later recursively call
694# Observer() on them.
695# Otherwise add (directory, False) to subdirectories_to_watch and later call
696# Observer() with recursion=False.
697def minimal_watch_directories(to_watch: Path, to_exclude: Iterable[Path]):
698    """Determine which subdirectory to watch recursively"""
699    try:
700        to_watch = Path(to_watch)
701    except TypeError:
702        assert False, "Please watch one directory at a time."
703
704    # Reformat to_exclude.
705    directories_to_exclude: List[Path] = [
706        to_watch.joinpath(directory_to_exclude)
707        for directory_to_exclude in to_exclude
708        if to_watch.joinpath(directory_to_exclude).is_dir()
709    ]
710
711    # Split the relative path of directories_to_exclude (compared to to_watch),
712    # and generate all parent paths needed to be watched without recursion.
713    exclude_dir_parents = {to_watch}
714    for directory_to_exclude in directories_to_exclude:
715        parts = list(
716            Path(directory_to_exclude).relative_to(to_watch).parts)[:-1]
717        dir_tmp = to_watch
718        for part in parts:
719            dir_tmp = Path(dir_tmp, part)
720            exclude_dir_parents.add(dir_tmp)
721
722    # Go over all layers of directory. Append those that are the parents of
723    # directories_to_exclude to the list with recursion==False, and others
724    # with recursion==True.
725    for directory in exclude_dir_parents:
726        dir_path = Path(directory)
727        yield dir_path, False
728        for item in Path(directory).iterdir():
729            if (item.is_dir() and item not in exclude_dir_parents
730                    and item not in directories_to_exclude):
731                yield item, True
732
733
734def get_common_excludes() -> List[Path]:
735    """Find commonly excluded directories, and return them as a [Path]"""
736    exclude_list: List[Path] = []
737
738    typical_ignored_directories: List[str] = [
739        '.environment',  # Legacy bootstrap-created CIPD and Python venv.
740        '.presubmit',  # Presubmit-created CIPD and Python venv.
741        '.git',  # Pigweed's git repo.
742        '.mypy_cache',  # Python static analyzer.
743        '.cargo',  # Rust package manager.
744        'environment',  # Bootstrap-created CIPD and Python venv.
745        'out',  # Typical build directory.
746    ]
747
748    # Preset exclude list for Pigweed's upstream directories.
749    pw_root_dir = Path(os.environ['PW_ROOT'])
750    exclude_list.extend(pw_root_dir / ignored_directory
751                        for ignored_directory in typical_ignored_directories)
752
753    # Preset exclude for common downstream project structures.
754    #
755    # If watch is invoked outside of the Pigweed root, exclude common
756    # directories.
757    pw_project_root_dir = Path(os.environ['PW_PROJECT_ROOT'])
758    if pw_project_root_dir != pw_root_dir:
759        exclude_list.extend(
760            pw_project_root_dir / ignored_directory
761            for ignored_directory in typical_ignored_directories)
762
763    # Check for and warn about legacy directories.
764    legacy_directories = [
765        '.cipd',  # Legacy CIPD location.
766        '.python3-venv',  # Legacy Python venv location.
767    ]
768    found_legacy = False
769    for legacy_directory in legacy_directories:
770        full_legacy_directory = pw_root_dir / legacy_directory
771        if full_legacy_directory.is_dir():
772            _LOG.warning('Legacy environment directory found: %s',
773                         str(full_legacy_directory))
774            exclude_list.append(full_legacy_directory)
775            found_legacy = True
776    if found_legacy:
777        _LOG.warning('Found legacy environment directory(s); these '
778                     'should be deleted')
779
780    return exclude_list
781
782
783def watch_setup(
784    default_build_targets: List[str],
785    build_directories: List[str],
786    patterns: str,
787    ignore_patterns_string: str,
788    exclude_list: List[Path],
789    restart: bool,
790    jobs: Optional[int],
791    serve_docs: bool,
792    serve_docs_port: int,
793    serve_docs_path: Path,
794    fullscreen: bool,
795    banners: bool,
796    # pylint: disable=too-many-arguments
797) -> Tuple[str, PigweedBuildWatcher, List[Path]]:
798    """Watches files and runs Ninja commands when they change."""
799    _LOG.info('Starting Pigweed build watcher')
800
801    # Get pigweed directory information from environment variable PW_ROOT.
802    if os.environ['PW_ROOT'] is None:
803        _exit_due_to_pigweed_not_installed()
804    pw_root = Path(os.environ['PW_ROOT']).resolve()
805    if Path.cwd().resolve() not in [pw_root, *pw_root.parents]:
806        _exit_due_to_pigweed_not_installed()
807
808    # Preset exclude list for pigweed directory.
809    exclude_list += get_common_excludes()
810    # Add build directories to the exclude list.
811    exclude_list.extend(
812        Path(build_dir[0]).resolve() for build_dir in build_directories)
813
814    build_commands = [
815        BuildCommand(Path(build_dir[0]), tuple(build_dir[1:]))
816        for build_dir in build_directories
817    ]
818
819    # If no build directory was specified, check for out/build.ninja.
820    if default_build_targets or not build_directories:
821        # Make sure we found something; if not, bail.
822        if not Path('out').exists():
823            _die("No build dirs found. Did you forget to run 'gn gen out'?")
824
825        build_commands.append(
826            BuildCommand(Path('out'), tuple(default_build_targets)))
827
828    # Verify that the build output directories exist.
829    for i, build_target in enumerate(build_commands, 1):
830        if not build_target.build_dir.is_dir():
831            _die("Build directory doesn't exist: %s", build_target)
832        else:
833            _LOG.info('Will build [%d/%d]: %s', i, len(build_commands),
834                      build_target)
835
836    _LOG.debug('Patterns: %s', patterns)
837
838    if serve_docs:
839
840        def _serve_docs():
841            # Disable logs from httpwatcher and deps
842            logging.getLogger('httpwatcher').setLevel(logging.CRITICAL)
843            logging.getLogger('tornado').setLevel(logging.CRITICAL)
844
845            docs_path = build_commands[0].build_dir.joinpath(
846                serve_docs_path.joinpath('html'))
847            httpwatcher.watch(docs_path,
848                              host="127.0.0.1",
849                              port=serve_docs_port)
850
851        # Spin up an httpwatcher in a new thread since it blocks
852        threading.Thread(None, _serve_docs, "httpwatcher").start()
853
854    # Try to make a short display path for the watched directory that has
855    # "$HOME" instead of the full home directory. This is nice for users
856    # who have deeply nested home directories.
857    path_to_log = str(Path().resolve()).replace(str(Path.home()), '$HOME')
858
859    # Ignore the user-specified patterns.
860    ignore_patterns = (ignore_patterns_string.split(_WATCH_PATTERN_DELIMITER)
861                       if ignore_patterns_string else [])
862
863    env = pw_cli.env.pigweed_environment()
864    if env.PW_EMOJI:
865        charset = _EMOJI_CHARSET
866    else:
867        charset = _ASCII_CHARSET
868
869    event_handler = PigweedBuildWatcher(
870        build_commands=build_commands,
871        patterns=patterns.split(_WATCH_PATTERN_DELIMITER),
872        ignore_patterns=ignore_patterns,
873        charset=charset,
874        restart=restart,
875        jobs=jobs,
876        fullscreen=fullscreen,
877        banners=banners,
878    )
879    return path_to_log, event_handler, exclude_list
880
881
882def watch(path_to_log: Path, event_handler: PigweedBuildWatcher,
883          exclude_list: List[Path]):
884    """Watches files and runs Ninja commands when they change."""
885    try:
886        # It can take awhile to configure the filesystem watcher, so have the
887        # message reflect that with the "...". Run inside the try: to
888        # gracefully handle the user Ctrl-C'ing out during startup.
889
890        _LOG.info('Attaching filesystem watcher to %s/...', path_to_log)
891
892        # Observe changes for all files in the root directory. Whether the
893        # directory should be observed recursively or not is determined by the
894        # second element in subdirectories_to_watch.
895        observers = []
896        for path, rec in minimal_watch_directories(Path.cwd(), exclude_list):
897            observer = Observer()
898            observer.schedule(
899                event_handler,
900                str(path),
901                recursive=rec,
902            )
903            observer.start()
904            observers.append(observer)
905
906        event_handler.debouncer.press('Triggering initial build...')
907        for observer in observers:
908            while observer.is_alive():
909                observer.join(1)
910
911    # Ctrl-C on Unix generates KeyboardInterrupt
912    # Ctrl-Z on Windows generates EOFError
913    except (KeyboardInterrupt, EOFError):
914        _exit_due_to_interrupt()
915    except OSError as err:
916        if err.args[0] == _ERRNO_INOTIFY_LIMIT_REACHED:
917            _exit_due_to_inotify_watch_limit()
918        if err.errno == errno.EMFILE:
919            _exit_due_to_inotify_instance_limit()
920        raise err
921
922    _LOG.critical('Should never get here')
923    observer.join()
924
925
926def main() -> None:
927    """Watch files for changes and rebuild."""
928    parser = argparse.ArgumentParser(
929        description=__doc__,
930        formatter_class=argparse.RawDescriptionHelpFormatter)
931    add_parser_arguments(parser)
932    args = parser.parse_args()
933
934    path_to_log, event_handler, exclude_list = watch_setup(
935        default_build_targets=args.default_build_targets,
936        build_directories=args.build_directories,
937        patterns=args.patterns,
938        ignore_patterns_string=args.ignore_patterns_string,
939        exclude_list=args.exclude_list,
940        restart=args.restart,
941        jobs=args.jobs,
942        serve_docs=args.serve_docs,
943        serve_docs_port=args.serve_docs_port,
944        serve_docs_path=args.serve_docs_path,
945        fullscreen=args.fullscreen,
946        banners=args.banners,
947    )
948
949    if args.fullscreen:
950        watch_logfile = (pw_console.python_logging.create_temp_log_file(
951            prefix=__package__))
952        pw_cli.log.install(
953            level=logging.DEBUG,
954            use_color=True,
955            hide_timestamp=False,
956            log_file=watch_logfile,
957        )
958        pw_console.python_logging.setup_python_logging(
959            last_resort_filename=watch_logfile)
960
961        watch_thread = Thread(target=watch,
962                              args=(path_to_log, event_handler, exclude_list),
963                              daemon=True)
964        watch_thread.start()
965        watch_app = WatchApp(event_handler=event_handler,
966                             debug_logging=args.debug_logging,
967                             log_file_name=watch_logfile)
968
969        event_handler.watch_app = watch_app
970        watch_app.run()
971    else:
972        pw_cli.log.install(
973            level=logging.DEBUG if args.debug_logging else logging.INFO,
974            use_color=True,
975            hide_timestamp=False,
976        )
977        watch(Path(path_to_log), event_handler, exclude_list)
978
979
980if __name__ == '__main__':
981    main()
982