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