pw_watch: Kill the build when a rebuild is requested

- Kill the ongoing build when a rebuild is manually requested by
  hitting enter.
- Expand type annotations.

Change-Id: I651b7ce1f6a3c55e2389a5b71bb9fe407612bf6b
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/39642
Commit-Queue: Wyatt Hepler <hepler@google.com>
Pigweed-Auto-Submit: Wyatt Hepler <hepler@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
diff --git a/pw_watch/docs.rst b/pw_watch/docs.rst
index 8b0cf4b..17b55e1 100644
--- a/pw_watch/docs.rst
+++ b/pw_watch/docs.rst
@@ -47,6 +47,8 @@
 being watched by your system. This can decrease the inotify number in Linux
 system.
 
+While running ``pw watch``, press enter to immediately trigger a rebuild.
+
 Unit Test Integration
 =====================
 Thanks to GN's understanding of the full dependency tree, only the tests
diff --git a/pw_watch/py/pw_watch/debounce.py b/pw_watch/py/pw_watch/debounce.py
index 73c5e97..0384219 100644
--- a/pw_watch/py/pw_watch/debounce.py
+++ b/pw_watch/py/pw_watch/debounce.py
@@ -34,7 +34,7 @@
         Returns true if run was successfully cancelled, false otherwise"""
 
     @abstractmethod
-    def on_complete(self, cancelled: bool = False) -> bool:
+    def on_complete(self, cancelled: bool = False) -> None:
         """Called after run() finishes. If true, cancelled indicates
         cancel() was invoked during the last run()"""
 
@@ -57,7 +57,7 @@
 
 class Debouncer:
     """Run an interruptable, cancellable function with debouncing"""
-    def __init__(self, function):
+    def __init__(self, function: DebouncedFunction) -> None:
         super().__init__()
         self.function = function
 
@@ -69,22 +69,22 @@
         self.cooldown_seconds = 1
         self.cooldown_timer = None
 
-        self.rerun_event_description = None
+        self.rerun_event_description = ''
 
         self.lock = threading.Lock()
 
-    def press(self, event_description=None):
+    def press(self, event_description: str = '') -> None:
         """Try to run the function for the class. If the function is recently
         started, this may push out the deadline for actually starting. If the
         function is already running, will interrupt the function"""
         with self.lock:
             self._press_unlocked(event_description)
 
-    def _press_unlocked(self, event_description=None):
+    def _press_unlocked(self, event_description: str) -> None:
         _LOG.debug('Press - state = %s', str(self.state))
         if self.state == State.IDLE:
             if event_description:
-                _LOG.info(event_description)
+                _LOG.info('%s', event_description)
             self._start_debounce_timer()
             self._transition(State.DEBOUNCING)
 
@@ -118,8 +118,8 @@
             self._transition(State.RERUN)
             self.rerun_event_description = event_description
 
-    def _transition(self, new_state):
-        _LOG.debug('State: %s -> %s', str(self.state), str(new_state))
+    def _transition(self, new_state: State) -> None:
+        _LOG.debug('State: %s -> %s', self.state, new_state)
         self.state = new_state
 
     def _start_debounce_timer(self):
@@ -173,7 +173,7 @@
 
                 # If we were in the RERUN state, then re-trigger the event.
                 if rerun:
-                    self._press_unlocked('Rerunning: %s' %
+                    self._press_unlocked('Rerunning: ' +
                                          self.rerun_event_description)
 
         # Ctrl-C on Unix generates KeyboardInterrupt
diff --git a/pw_watch/py/pw_watch/watch.py b/pw_watch/py/pw_watch/watch.py
index 159e88c..5674e66 100755
--- a/pw_watch/py/pw_watch/watch.py
+++ b/pw_watch/py/pw_watch/watch.py
@@ -127,7 +127,7 @@
         patterns: Sequence[str] = (),
         ignore_patterns: Sequence[str] = (),
         build_commands: Sequence[BuildCommand] = (),
-        ignore_dirs=Optional[List[str]],
+        ignore_dirs: Iterable[Path] = (),
         charset: WatchCharset = _ASCII_CHARSET,
         restart: bool = False,
     ):
@@ -136,12 +136,12 @@
         self.patterns = patterns
         self.ignore_patterns = ignore_patterns
         self.build_commands = build_commands
-        self.ignore_dirs = ignore_dirs or []
+        self.ignore_dirs = list(ignore_dirs)
         self.ignore_dirs.extend(cmd.build_dir for cmd in self.build_commands)
         self.charset: WatchCharset = charset
 
         self.restart_on_changes = restart
-        self._current_build: Optional[subprocess.Popen] = None
+        self._current_build: subprocess.Popen
 
         self.debouncer = Debouncer(self)
 
@@ -154,10 +154,12 @@
             None, self._wait_for_enter)
         self.wait_for_keypress_thread.start()
 
-    def _wait_for_enter(self):
+    def _wait_for_enter(self) -> NoReturn:
         try:
             while True:
                 _ = input()
+                self._current_build.kill()
+
                 self.debouncer.press('Manual build requested...')
         # Ctrl-C on Unix generates KeyboardInterrupt
         # Ctrl-Z on Windows generates EOFError
@@ -173,7 +175,7 @@
         # watching directories at the OS level is not trivial due to limitations
         # of the watchdog module.
         for ignore_dir in self.ignore_dirs:
-            resolved_ignore_dir = Path(ignore_dir).resolve()
+            resolved_ignore_dir = ignore_dir.resolve()
             try:
                 modified_path.relative_to(resolved_ignore_dir)
                 # If no ValueError is raised by the .relative_to() call, then
@@ -221,7 +223,7 @@
     # Note: This will run on the timer thread created by the Debouncer, rather
     # than on the main thread that's watching file events. This enables the
     # watcher to continue receiving file change events during a build.
-    def run(self):
+    def run(self) -> None:
         """Run all the builds in serial and capture pass/fail for each."""
 
         # Clear the screen and show a banner indicating the build is starting.
@@ -263,7 +265,7 @@
             self.builds_succeeded.append(build_ok)
 
     # Implementation of DebouncedFunction.cancel()
-    def cancel(self):
+    def cancel(self) -> bool:
         if self.restart_on_changes:
             self._current_build.kill()
             return True
@@ -271,7 +273,7 @@
         return False
 
     # Implementation of DebouncedFunction.run()
-    def on_complete(self, cancelled=False):
+    def on_complete(self, cancelled: bool = False) -> None:
         # First, use the standard logging facilities to report build status.
         if cancelled:
             _LOG.error('Finished; build was interrupted')
@@ -312,7 +314,7 @@
         self.matching_path = None
 
     # Implementation of DebouncedFunction.on_keyboard_interrupt()
-    def on_keyboard_interrupt(self):
+    def on_keyboard_interrupt(self) -> NoReturn:
         _exit_due_to_interrupt()
 
 
@@ -382,7 +384,7 @@
               '-C out tgt`'))
 
 
-def _exit(code):
+def _exit(code: int) -> NoReturn:
     # Note: The "proper" way to exit is via observer.stop(), then
     # running a join. However it's slower, so just exit immediately.
     #
@@ -392,7 +394,7 @@
     os._exit(code)  # pylint: disable=protected-access
 
 
-def _exit_due_to_interrupt():
+def _exit_due_to_interrupt() -> NoReturn:
     # To keep the log lines aligned with each other in the presence of
     # a '^C' from the keyboard interrupt, add a newline before the log.
     print()
@@ -600,8 +602,6 @@
     # Ignore top level pw_root_dir/.gitignore patterns.
     ignore_patterns += gitignore_patterns()
 
-    ignore_dirs = ['.presubmit', '.python3-env']
-
     env = pw_cli.env.pigweed_environment()
     if env.PW_EMOJI:
         charset = _EMOJI_CHARSET
@@ -612,7 +612,8 @@
         patterns=patterns.split(_WATCH_PATTERN_DELIMITER),
         ignore_patterns=ignore_patterns,
         build_commands=build_commands,
-        ignore_dirs=ignore_dirs,
+        ignore_dirs=[Path('.presubmit'),
+                     Path('.python3-env')],
         charset=charset,
         restart=restart,
     )