pw_watch: Support specifying build targets

- Allow specifying build directories and targets by appending #TARGET
  for each desired target.
- Provide the build commands as positional arguments instead of repeated
  --build_dir args.

Change-Id: Ia7631995941fec138ad1fd78d49db1e1f401a820
diff --git a/pw_watch/py/pw_watch/watch.py b/pw_watch/py/pw_watch/watch.py
index fadbb85..8ed066e 100755
--- a/pw_watch/py/pw_watch/watch.py
+++ b/pw_watch/py/pw_watch/watch.py
@@ -14,15 +14,17 @@
 """Rebuild every time a file is changed."""
 
 import argparse
+from dataclasses import dataclass
 import enum
 import glob
 import logging
 import os
 import pathlib
+import shlex
 import subprocess
 import sys
 import time
-from typing import NamedTuple
+from typing import List, NamedTuple, Optional, Sequence, Tuple
 
 from watchdog.events import FileSystemEventHandler
 from watchdog.observers import Observer
@@ -94,15 +96,27 @@
 _EMOJI_CHARSET = WatchCharset('✔️ ', '💥')
 
 
+@dataclass(frozen=True)
+class BuildCommand:
+    build_dir: pathlib.Path
+    targets: Tuple[str, ...] = ()
+
+    def args(self) -> Tuple[str, ...]:
+        return (str(self.build_dir), *self.targets)
+
+    def __str__(self) -> str:
+        return shlex.join(self.args())
+
+
 class PigweedBuildWatcher(FileSystemEventHandler):
     """Process filesystem events and launch builds if necessary."""
     def __init__(
         self,
-        patterns=None,
-        ignore_patterns=None,
-        case_sensitive=False,
-        build_dirs=None,
-        ignore_dirs=None,
+        patterns: Sequence[str] = (),
+        ignore_patterns: Sequence[str] = (),
+        case_sensitive: bool = False,
+        build_commands: Sequence[BuildCommand] = (),
+        ignore_dirs=Optional[List[str]],
         charset: WatchCharset = _ASCII_CHARSET,
     ):
         super().__init__()
@@ -111,8 +125,9 @@
         self.ignore_patterns = ignore_patterns
         self.case_sensitive = case_sensitive
         self.state = _State.WAITING_FOR_FILE_CHANGE_EVENT
-        self.build_dirs = build_dirs or []
-        self.ignore_dirs = (ignore_dirs or []) + self.build_dirs
+        self.build_commands = build_commands
+        self.ignore_dirs = ignore_dirs or []
+        self.ignore_dirs.extend(cmd.build_dir for cmd in self.build_commands)
         self.cooldown_finish_time = None
         self.charset: WatchCharset = charset
 
@@ -175,17 +190,17 @@
         _LOG.info('Change detected: %s', matching_path)
 
         builds_succeeded = []
-        num_builds = len(self.build_dirs)
+        num_builds = len(self.build_commands)
         _LOG.info(f'Starting build with {num_builds} directories')
-        for i, build_dir in enumerate(self.build_dirs, 1):
-            _LOG.info(f'[{i}/{num_builds}] Starting build: {build_dir}')
+        for i, cmd in enumerate(self.build_commands, 1):
+            _LOG.info(f'[{i}/{num_builds}] Starting build: {cmd}')
 
             # Run the build. Put a blank before/after for visual separation.
             print()
             env = os.environ.copy()
             # Force colors in Pigweed subcommands run through the watcher.
             env['PW_USE_COLOR'] = '1'
-            result = subprocess.run(['ninja', '-C', build_dir], env=env)
+            result = subprocess.run(['ninja', '-C', *cmd.args()], env=env)
             print()
 
             build_ok = (result.returncode == 0)
@@ -195,8 +210,7 @@
             else:
                 level = logging.ERROR
                 tag = '(FAIL)'
-            _LOG.log(level,
-                     f'[{i}/{num_builds}] Finished build: {build_dir} {tag}')
+            _LOG.log(level, f'[{i}/{num_builds}] Finished build: {cmd} {tag}')
             builds_succeeded.append(build_ok)
 
         if all(builds_succeeded):
@@ -209,9 +223,9 @@
         print()
         print(' .------------------------------------')
         print(' |')
-        for (succeeded, build_dir) in zip(builds_succeeded, self.build_dirs):
+        for (succeeded, cmd) in zip(builds_succeeded, self.build_commands):
             slug = self.charset.slug_ok if succeeded else self.charset.slug_fail
-            print(f' |   {slug}  {build_dir}')
+            print(f' |   {slug}  {cmd}')
         print(' |')
         print(" '------------------------------------")
 
@@ -267,14 +281,23 @@
                         help=(_WATCH_PATTERN_DELIMITER +
                               '-delimited list of globs to '
                               'ignore events from'))
+
+    def build_dir_and_target(arg: str) -> BuildCommand:
+        args = arg.split('#')
+        return BuildCommand(pathlib.Path(args[0]), tuple(args[1:]))
+
     parser.add_argument(
-        '--build_dir',
-        help=('Ninja directory to build. Can be specified '
-              'multiple times to build multiple configurations'),
-        action='append')
+        'build_commands',
+        nargs='*',
+        type=build_dir_and_target,
+        help=('Ninja directory to build. Can be specified multiple times to '
+              'build multiple configurations. Build targets may optionally be '
+              'specified by appending #TARGET to the directory. For example, '
+              'out/build_dir#pw_module#tests builds the pw_module and tests '
+              'targets in out/build_dir.'))
 
 
-def watch(build_dir='', patterns=None, ignore_patterns=None):
+def watch(build_commands=None, patterns=None, ignore_patterns=None):
     """TODO(keir) docstring"""
 
     _LOG.info('Starting Pigweed build watcher')
@@ -282,28 +305,26 @@
     # If no build directory was specified, search the tree for GN build
     # directories and try to build them all. In the future this may cause
     # slow startup, but for now this is fast enough.
-    build_dirs = build_dir
-    if not build_dirs:
-        build_dirs = []
+    if not build_commands:
+        build_commands = []
         _LOG.info('Searching for GN build dirs...')
         gn_args_files = glob.glob('**/args.gn', recursive=True)
         for gn_args_file in gn_args_files:
             gn_build_dir = pathlib.Path(gn_args_file).parent
             if gn_build_dir.is_dir():
-                build_dirs.append(str(gn_build_dir))
+                build_commands.append(BuildCommand(gn_build_dir))
 
     # Make sure we found something; if not, bail.
-    if not build_dirs:
+    if not build_commands:
         _die("No build dirs found. Did you forget to 'gn gen out'?")
 
     # Verify that the build output directories exist.
-    # pylint: disable=redefined-argument-from-local
-    for i, build_dir in enumerate(build_dirs, 1):
-        if not os.path.isdir(build_dir):
-            _die("Build directory doesn't exist: %s", build_dir)
+    for i, build_target in enumerate(build_commands, 1):
+        if not build_target.build_dir.is_dir():
+            _die("Build directory doesn't exist: %s", build_target)
         else:
-            _LOG.info(f'Will build [{i}/{len(build_dirs)}]: {build_dir}')
-    # pylint: enable=redefined-argument-from-local
+            _LOG.info(
+                f'Will build [{i}/{len(build_commands)}]: {build_target}')
 
     _LOG.debug('Patterns: %s', patterns)
 
@@ -340,7 +361,7 @@
     event_handler = PigweedBuildWatcher(
         patterns=patterns.split(_WATCH_PATTERN_DELIMITER),
         ignore_patterns=ignore_patterns,
-        build_dirs=build_dirs,
+        build_commands=build_commands,
         ignore_dirs=ignore_dirs,
         charset=charset,
     )