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