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