• 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 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 socketserver
51import sys
52import threading
53from threading import Thread
54from typing import Callable, Sequence
55
56from watchdog.events import FileSystemEventHandler
57
58from prompt_toolkit import prompt
59
60from pw_build.build_recipe import BuildRecipe, create_build_recipes
61from pw_build.project_builder import (
62    ProjectBuilder,
63    execute_command_no_logging,
64    execute_command_with_logging,
65    log_build_recipe_start,
66    log_build_recipe_finish,
67    ASCII_CHARSET,
68    EMOJI_CHARSET,
69)
70from pw_build.project_builder_context import get_project_builder_context
71import pw_cli.branding
72import pw_cli.env
73import pw_cli.log
74import pw_console.python_logging
75
76from pw_watch.argparser import (
77    WATCH_PATTERN_DELIMITER,
78    WATCH_PATTERN_STRING,
79    add_parser_arguments,
80)
81from pw_watch.debounce import DebouncedFunction, Debouncer
82from pw_watch import common
83from pw_watch.watch_app import WatchAppPrefs, WatchApp
84
85_LOG = logging.getLogger('pw_build.watch')
86
87_FULLSCREEN_STATUS_COLUMN_WIDTH = 10
88
89BUILDER_CONTEXT = get_project_builder_context()
90
91
92def _log_event(event_description: str) -> None:
93    if BUILDER_CONTEXT.using_progress_bars():
94        _LOG.warning('Event while running: %s', event_description)
95    else:
96        print('\n')
97        _LOG.warning('Event while running: %s', event_description)
98        print('')
99
100
101class PigweedBuildWatcher(FileSystemEventHandler, DebouncedFunction):
102    """Process filesystem events and launch builds if necessary."""
103
104    # pylint: disable=too-many-instance-attributes
105    def __init__(  # pylint: disable=too-many-arguments
106        self,
107        project_builder: ProjectBuilder,
108        patterns: Sequence[str] = (),
109        ignore_patterns: Sequence[str] = (),
110        restart: bool = True,
111        fullscreen: bool = False,
112        banners: bool = True,
113        use_logfile: bool = False,
114        separate_logfiles: bool = False,
115        parallel_workers: int = 1,
116    ):
117        super().__init__()
118
119        self.banners = banners
120        self.current_build_step = ''
121        self.current_build_percent = 0.0
122        self.current_build_errors = 0
123        self.patterns = patterns
124        self.ignore_patterns = ignore_patterns
125        self.project_builder = project_builder
126        self.parallel_workers = parallel_workers
127
128        self.restart_on_changes = restart
129        self.fullscreen_enabled = fullscreen
130        self.watch_app: WatchApp | None = None
131
132        self.use_logfile = use_logfile
133        self.separate_logfiles = separate_logfiles
134        if self.parallel_workers > 1:
135            self.separate_logfiles = True
136
137        self.debouncer = Debouncer(self, log_event=_log_event)
138
139        # Track state of a build. These need to be members instead of locals
140        # due to the split between dispatch(), run(), and on_complete().
141        self.matching_path: Path | None = None
142
143        if (
144            not self.fullscreen_enabled
145            and not self.project_builder.should_use_progress_bars()
146        ):
147            self.wait_for_keypress_thread = threading.Thread(
148                None, self._wait_for_enter
149            )
150            self.wait_for_keypress_thread.start()
151
152        if self.fullscreen_enabled:
153            BUILDER_CONTEXT.using_fullscreen = True
154
155    def rebuild(self):
156        """Rebuild command triggered from watch app."""
157        self.debouncer.press('Manual build requested')
158
159    def _wait_for_enter(self) -> None:
160        try:
161            while True:
162                _ = prompt('')
163                self.rebuild()
164        # Ctrl-C on Unix generates KeyboardInterrupt
165        # Ctrl-Z on Windows generates EOFError
166        except (KeyboardInterrupt, EOFError):
167            # Force stop any running ninja builds.
168            _exit_due_to_interrupt()
169
170    def dispatch(self, event) -> None:
171        path = common.handle_watchdog_event(
172            event, self.patterns, self.ignore_patterns
173        )
174        if path is not None:
175            self._handle_matched_event(path)
176
177    def _handle_matched_event(self, matching_path: Path) -> None:
178        if self.matching_path is None:
179            self.matching_path = matching_path
180
181        log_message = f'File change detected: {os.path.relpath(matching_path)}'
182        if self.restart_on_changes:
183            if self.fullscreen_enabled and self.watch_app:
184                self.watch_app.clear_log_panes()
185            self.debouncer.press(f'{log_message} Triggering build...')
186        else:
187            _LOG.info('%s ; not rebuilding', log_message)
188
189    def _clear_screen(self) -> None:
190        if self.fullscreen_enabled:
191            return
192        if self.project_builder.should_use_progress_bars():
193            BUILDER_CONTEXT.clear_progress_scrollback()
194            return
195        print('\033c', end='')  # TODO(pwbug/38): Not Windows compatible.
196        sys.stdout.flush()
197
198    # Implementation of DebouncedFunction.run()
199    #
200    # Note: This will run on the timer thread created by the Debouncer, rather
201    # than on the main thread that's watching file events. This enables the
202    # watcher to continue receiving file change events during a build.
203    def run(self) -> None:
204        """Run all the builds and capture pass/fail for each."""
205
206        # Clear the screen and show a banner indicating the build is starting.
207        self._clear_screen()
208
209        if self.banners:
210            for line in pw_cli.branding.banner().splitlines():
211                _LOG.info(line)
212        if self.fullscreen_enabled:
213            _LOG.info(
214                self.project_builder.color.green(
215                    'Watching for changes. Ctrl-d to exit; enter to rebuild'
216                )
217            )
218        else:
219            _LOG.info(
220                self.project_builder.color.green(
221                    'Watching for changes. Ctrl-C to exit; enter to rebuild'
222                )
223            )
224        if self.matching_path:
225            _LOG.info('')
226            _LOG.info('Change detected: %s', self.matching_path)
227
228        num_builds = len(self.project_builder)
229        _LOG.info('Starting build with %d directories', num_builds)
230
231        if self.project_builder.default_logfile:
232            _LOG.info(
233                '%s %s',
234                self.project_builder.color.blue('Root logfile:'),
235                self.project_builder.default_logfile.resolve(),
236            )
237
238        env = os.environ.copy()
239        if self.project_builder.colors:
240            # Force colors in Pigweed subcommands run through the watcher.
241            env['PW_USE_COLOR'] = '1'
242            # Force Ninja to output ANSI colors
243            env['CLICOLOR_FORCE'] = '1'
244
245        # Reset status
246        BUILDER_CONTEXT.set_project_builder(self.project_builder)
247        BUILDER_CONTEXT.set_enter_callback(self.rebuild)
248        BUILDER_CONTEXT.set_building()
249
250        for cfg in self.project_builder:
251            cfg.reset_status()
252
253        with concurrent.futures.ThreadPoolExecutor(
254            max_workers=self.parallel_workers
255        ) as executor:
256            futures = []
257            if (
258                not self.fullscreen_enabled
259                and self.project_builder.should_use_progress_bars()
260            ):
261                BUILDER_CONTEXT.add_progress_bars()
262
263            for i, cfg in enumerate(self.project_builder, start=1):
264                futures.append(executor.submit(self.run_recipe, i, cfg, env))
265
266            for future in concurrent.futures.as_completed(futures):
267                future.result()
268
269        BUILDER_CONTEXT.set_idle()
270
271    def run_recipe(self, index: int, cfg: BuildRecipe, env) -> None:
272        if BUILDER_CONTEXT.interrupted():
273            return
274        if not cfg.enabled:
275            return
276
277        num_builds = len(self.project_builder)
278        index_message = f'[{index}/{num_builds}]'
279
280        log_build_recipe_start(
281            index_message, self.project_builder, cfg, logger=_LOG
282        )
283
284        self.project_builder.run_build(
285            cfg,
286            env,
287            index_message=index_message,
288        )
289
290        log_build_recipe_finish(
291            index_message,
292            self.project_builder,
293            cfg,
294            logger=_LOG,
295        )
296
297    def execute_command(
298        self,
299        command: list,
300        env: dict,
301        recipe: BuildRecipe,
302        # pylint: disable=unused-argument
303        *args,
304        **kwargs,
305        # pylint: enable=unused-argument
306    ) -> bool:
307        """Runs a command with a blank before/after for visual separation."""
308        if self.fullscreen_enabled:
309            return self._execute_command_watch_app(command, env, recipe)
310
311        if self.separate_logfiles:
312            return execute_command_with_logging(
313                command, env, recipe, logger=recipe.log
314            )
315
316        if self.use_logfile:
317            return execute_command_with_logging(
318                command, env, recipe, logger=_LOG
319            )
320
321        return execute_command_no_logging(command, env, recipe)
322
323    def _execute_command_watch_app(
324        self,
325        command: list,
326        env: dict,
327        recipe: BuildRecipe,
328    ) -> bool:
329        """Runs a command with and outputs the logs."""
330        if not self.watch_app:
331            return False
332
333        self.watch_app.redraw_ui()
334
335        def new_line_callback(recipe: BuildRecipe) -> None:
336            self.current_build_step = recipe.status.current_step
337            self.current_build_percent = recipe.status.percent
338            self.current_build_errors = recipe.status.error_count
339
340            if self.watch_app:
341                self.watch_app.logs_redraw()
342
343        desired_logger = _LOG
344        if self.separate_logfiles:
345            desired_logger = recipe.log
346
347        result = execute_command_with_logging(
348            command,
349            env,
350            recipe,
351            logger=desired_logger,
352            line_processed_callback=new_line_callback,
353        )
354
355        self.watch_app.redraw_ui()
356
357        return result
358
359    # Implementation of DebouncedFunction.cancel()
360    def cancel(self) -> bool:
361        if self.restart_on_changes:
362            BUILDER_CONTEXT.restart_flag = True
363            BUILDER_CONTEXT.terminate_and_wait()
364            return True
365
366        return False
367
368    # Implementation of DebouncedFunction.on_complete()
369    def on_complete(self, cancelled: bool = False) -> None:
370        # First, use the standard logging facilities to report build status.
371        if cancelled:
372            _LOG.info('Build stopped.')
373        elif BUILDER_CONTEXT.interrupted():
374            pass  # Don't print anything.
375        elif all(
376            recipe.status.passed()
377            for recipe in self.project_builder
378            if recipe.enabled
379        ):
380            _LOG.info('Finished; all successful')
381        else:
382            _LOG.info('Finished; some builds failed')
383
384        # For non-fullscreen pw watch
385        if (
386            not self.fullscreen_enabled
387            and not self.project_builder.should_use_progress_bars()
388        ):
389            # Show a more distinct colored banner.
390            self.project_builder.print_build_summary(
391                cancelled=cancelled, logger=_LOG
392            )
393        self.project_builder.print_pass_fail_banner(
394            cancelled=cancelled, logger=_LOG
395        )
396
397        if self.watch_app:
398            self.watch_app.redraw_ui()
399        self.matching_path = None
400
401    # Implementation of DebouncedFunction.on_keyboard_interrupt()
402    def on_keyboard_interrupt(self) -> None:
403        _exit_due_to_interrupt()
404
405
406def _exit_due_to_interrupt() -> None:
407    # To keep the log lines aligned with each other in the presence of
408    # a '^C' from the keyboard interrupt, add a newline before the log.
409    print('')
410    _LOG.info('Got Ctrl-C; exiting...')
411    BUILDER_CONTEXT.ctrl_c_interrupt()
412
413
414def _exit_due_to_inotify_watch_limit():
415    common.log_inotify_watch_limit_reached()
416    common.exit_immediately(1)
417
418
419def _exit_due_to_inotify_instance_limit():
420    common.log_inotify_instance_limit_reached()
421    common.exit_immediately(1)
422
423
424def _simple_docs_server(
425    address: str, port: int, path: Path
426) -> Callable[[], None]:
427    class Handler(http.server.SimpleHTTPRequestHandler):
428        def __init__(self, *args, **kwargs):
429            super().__init__(*args, directory=str(path), **kwargs)
430
431        # Disable logs to stdout
432        def log_message(
433            self, format: str, *args  # pylint: disable=redefined-builtin
434        ) -> None:
435            return
436
437    def simple_http_server_thread():
438        with socketserver.TCPServer((address, port), Handler) as httpd:
439            httpd.serve_forever()
440
441    return simple_http_server_thread
442
443
444def _serve_docs(
445    build_dir: Path,
446    docs_path: Path,
447    address: str = '127.0.0.1',
448    port: int = 8000,
449) -> None:
450    address = '127.0.0.1'
451    docs_path = build_dir.joinpath(docs_path.joinpath('html'))
452    server_thread = _simple_docs_server(address, port, docs_path)
453    _LOG.info('Serving docs at http://%s:%d', address, port)
454
455    # Spin up server in a new thread since it blocks
456    threading.Thread(None, server_thread, 'pw_docs_server').start()
457
458
459def watch_logging_init(log_level: int, fullscreen: bool, colors: bool) -> None:
460    # Logging setup
461    if not fullscreen:
462        pw_cli.log.install(
463            level=log_level,
464            use_color=colors,
465            hide_timestamp=False,
466        )
467        return
468
469    watch_logfile = pw_console.python_logging.create_temp_log_file(
470        prefix=__package__
471    )
472
473    pw_cli.log.install(
474        level=logging.DEBUG,
475        use_color=colors,
476        hide_timestamp=False,
477        log_file=watch_logfile,
478    )
479
480
481def watch_setup(  # pylint: disable=too-many-locals
482    project_builder: ProjectBuilder,
483    # NOTE: The following args should have defaults matching argparse. This
484    # allows use of watch_setup by other project build scripts.
485    patterns: str = WATCH_PATTERN_STRING,
486    ignore_patterns_string: str = '',
487    exclude_list: list[Path] | None = None,
488    restart: bool = True,
489    serve_docs: bool = False,
490    serve_docs_port: int = 8000,
491    serve_docs_path: Path = Path('docs/gen/docs'),
492    fullscreen: bool = False,
493    banners: bool = True,
494    logfile: Path | None = None,
495    separate_logfiles: bool = False,
496    parallel: bool = False,
497    parallel_workers: int = 0,
498    # pylint: disable=unused-argument
499    default_build_targets: list[str] | None = None,
500    build_directories: list[str] | None = None,
501    build_system_commands: list[str] | None = None,
502    run_command: list[str] | None = None,
503    jobs: int | None = None,
504    keep_going: bool = False,
505    colors: bool = True,
506    debug_logging: bool = False,
507    source_path: Path | None = None,
508    default_build_system: str | None = None,
509    # pylint: enable=unused-argument
510    # pylint: disable=too-many-arguments
511) -> tuple[PigweedBuildWatcher, list[Path]]:
512    """Watches files and runs Ninja commands when they change."""
513    watch_logging_init(
514        log_level=project_builder.default_log_level,
515        fullscreen=fullscreen,
516        colors=colors,
517    )
518
519    # Update the project_builder log formatters since pw_cli.log.install may
520    # have changed it.
521    project_builder.apply_root_log_formatting()
522
523    if project_builder.should_use_progress_bars():
524        project_builder.use_stdout_proxy()
525
526    _LOG.info('Starting Pigweed build watcher')
527
528    build_recipes = project_builder.build_recipes
529
530    if source_path is None:
531        source_path = pw_cli.env.project_root()
532
533    # Preset exclude list for pigweed directory.
534    if not exclude_list:
535        exclude_list = []
536    exclude_list += common.get_common_excludes(source_path)
537
538    # Add build directories to the exclude list if they are not already ignored.
539    for build_dir in list(
540        cfg.build_dir.resolve()
541        for cfg in build_recipes
542        if isinstance(cfg.build_dir, Path)
543    ):
544        if not any(
545            # Check if build_dir.is_relative_to(excluded_dir)
546            build_dir == excluded_dir or excluded_dir in build_dir.parents
547            for excluded_dir in exclude_list
548        ):
549            exclude_list.append(build_dir)
550
551    for i, build_recipe in enumerate(build_recipes, start=1):
552        _LOG.info('Will build [%d/%d]: %s', i, len(build_recipes), build_recipe)
553
554    _LOG.debug('Patterns: %s', patterns)
555
556    for excluded_dir in exclude_list:
557        _LOG.debug('exclude-list: %s', excluded_dir)
558
559    if serve_docs:
560        _serve_docs(
561            build_recipes[0].build_dir, serve_docs_path, port=serve_docs_port
562        )
563
564    # Ignore the user-specified patterns.
565    ignore_patterns = (
566        ignore_patterns_string.split(WATCH_PATTERN_DELIMITER)
567        if ignore_patterns_string
568        else []
569    )
570
571    # Add project_builder logfiles to ignore_patterns
572    if project_builder.default_logfile:
573        ignore_patterns.append(str(project_builder.default_logfile))
574    if project_builder.separate_build_file_logging:
575        for recipe in project_builder:
576            if recipe.logfile:
577                ignore_patterns.append(str(recipe.logfile))
578
579    workers = 1
580    if parallel:
581        # If parallel is requested and parallel_workers is set to 0 run all
582        # recipes in parallel. That is, use the number of recipes as the worker
583        # count.
584        if parallel_workers == 0:
585            workers = len(project_builder)
586        else:
587            workers = parallel_workers
588
589    event_handler = PigweedBuildWatcher(
590        project_builder=project_builder,
591        patterns=patterns.split(WATCH_PATTERN_DELIMITER),
592        ignore_patterns=ignore_patterns,
593        restart=restart,
594        fullscreen=fullscreen,
595        banners=banners,
596        use_logfile=bool(logfile),
597        separate_logfiles=separate_logfiles,
598        parallel_workers=workers,
599    )
600
601    project_builder.execute_command = event_handler.execute_command
602
603    return event_handler, exclude_list
604
605
606def watch(
607    event_handler: PigweedBuildWatcher,
608    exclude_list: list[Path],
609    watch_file_path: Path = Path.cwd(),
610):
611    """Watches files and runs Ninja commands when they change."""
612    if event_handler.project_builder.source_path:
613        watch_file_path = event_handler.project_builder.source_path
614
615    try:
616        wait = common.watch(watch_file_path, exclude_list, event_handler)
617        event_handler.debouncer.press('Triggering initial build...')
618        wait()
619    # Ctrl-C on Unix generates KeyboardInterrupt
620    # Ctrl-Z on Windows generates EOFError
621    except (KeyboardInterrupt, EOFError):
622        _exit_due_to_interrupt()
623    except OSError as err:
624        if err.args[0] == common.ERRNO_INOTIFY_LIMIT_REACHED:
625            if event_handler.watch_app:
626                event_handler.watch_app.exit(
627                    log_after_shutdown=common.log_inotify_watch_limit_reached
628                )
629            elif event_handler.project_builder.should_use_progress_bars():
630                BUILDER_CONTEXT.exit(
631                    log_after_shutdown=common.log_inotify_watch_limit_reached
632                )
633            else:
634                _exit_due_to_inotify_watch_limit()
635        if err.errno == errno.EMFILE:
636            if event_handler.watch_app:
637                event_handler.watch_app.exit(
638                    log_after_shutdown=common.log_inotify_instance_limit_reached
639                )
640            elif event_handler.project_builder.should_use_progress_bars():
641                BUILDER_CONTEXT.exit(
642                    log_after_shutdown=common.log_inotify_instance_limit_reached
643                )
644            else:
645                _exit_due_to_inotify_instance_limit()
646        raise err
647
648
649def run_watch(
650    event_handler: PigweedBuildWatcher,
651    exclude_list: list[Path],
652    prefs: WatchAppPrefs | None = None,
653    fullscreen: bool = False,
654) -> None:
655    """Start pw_watch."""
656    if not prefs:
657        prefs = WatchAppPrefs(load_argparse_arguments=add_parser_arguments)
658
659    if fullscreen:
660        watch_thread = Thread(
661            target=watch,
662            args=(event_handler, exclude_list),
663            daemon=True,
664        )
665        watch_thread.start()
666        watch_app = WatchApp(
667            event_handler=event_handler,
668            prefs=prefs,
669        )
670
671        event_handler.watch_app = watch_app
672        watch_app.run()
673
674    else:
675        watch(event_handler, exclude_list)
676
677
678def get_parser() -> argparse.ArgumentParser:
679    parser = argparse.ArgumentParser(
680        description=__doc__,
681        formatter_class=argparse.RawDescriptionHelpFormatter,
682    )
683    parser = add_parser_arguments(parser)
684    return parser
685
686
687def main() -> int:
688    """Watch files for changes and rebuild."""
689    parser = get_parser()
690    args = parser.parse_args()
691
692    prefs = WatchAppPrefs(load_argparse_arguments=add_parser_arguments)
693    prefs.apply_command_line_args(args)
694    build_recipes = create_build_recipes(prefs)
695
696    env = pw_cli.env.pigweed_environment()
697    if env.PW_EMOJI:
698        charset = EMOJI_CHARSET
699    else:
700        charset = ASCII_CHARSET
701
702    # Force separate-logfiles for split window panes if running in parallel.
703    separate_logfiles = args.separate_logfiles
704    if args.parallel:
705        separate_logfiles = True
706
707    def _recipe_abort(*args) -> None:
708        _LOG.critical(*args)
709
710    project_builder = ProjectBuilder(
711        build_recipes=build_recipes,
712        jobs=args.jobs,
713        banners=args.banners,
714        keep_going=args.keep_going,
715        colors=args.colors,
716        charset=charset,
717        separate_build_file_logging=separate_logfiles,
718        root_logfile=args.logfile,
719        root_logger=_LOG,
720        log_level=logging.DEBUG if args.debug_logging else logging.INFO,
721        abort_callback=_recipe_abort,
722        source_path=args.source_path,
723    )
724
725    event_handler, exclude_list = watch_setup(project_builder, **vars(args))
726
727    run_watch(
728        event_handler,
729        exclude_list,
730        prefs=prefs,
731        fullscreen=args.fullscreen,
732    )
733
734    return 0
735
736
737if __name__ == '__main__':
738    main()
739