pw_build: Update python_runner.py file resolution

- Search for .a and .so files to support static_library and
  shared_library targets.
- Detect when multiple potential files are found and fail with a helpful
  error.

Change-Id: I31b702d649a8af6320467d03cc346b1c6e01db99
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/14642
Reviewed-by: Alexei Frolov <frolv@google.com>
Commit-Queue: Rob Mohr <mohrr@google.com>
diff --git a/pw_build/py/python_runner.py b/pw_build/py/python_runner.py
index 0b2a123..167de87 100755
--- a/pw_build/py/python_runner.py
+++ b/pw_build/py/python_runner.py
@@ -16,11 +16,12 @@
 import argparse
 import logging
 import os
-import pathlib
+from pathlib import Path
 import re
 import shlex
 import subprocess
 import sys
+from typing import Collection, Iterator
 
 _LOG = logging.getLogger(__name__)
 
@@ -81,7 +82,25 @@
     return parser.parse_args()
 
 
-def find_binary(target: pathlib.Path) -> str:
+class TooManyFilesError(Exception):
+    def __init__(self, target: Path, files: Collection[Path]):
+        super().__init__(f'Found {len(files)} files for target {target}')
+        self.target = target
+        self.files = files
+
+
+# Look for files with these extensions.
+_EXTENSIONS = '', '.elf', '.exe', '.a', '.so'
+
+
+def _find_potential_files(path: Path, target_name: str) -> Iterator[Path]:
+    for extension in _EXTENSIONS:
+        potential_file = path / f'{target_name}{extension}'
+        if potential_file.is_file():
+            yield potential_file
+
+
+def find_binary(target: Path) -> str:
     """Tries to find a binary for a gn build target.
 
     Args:
@@ -92,19 +111,23 @@
         Full path to the target's binary.
 
     Raises:
-        RuntimeError: No binary found for target.
+        FileNotFoundError: No binary found for the target.
+        TooManyFilesError: Found multiple binaries for the target.
     """
 
     target_dirname, target_name = target.name.rsplit(':', 1)
 
-    for extension in ['', '.elf', '.exe']:
-        potential_file = target.parent.joinpath(target_dirname,
-                                                f'{target_name}{extension}')
-        if potential_file.is_file():
-            return str(potential_file)
+    potential_files = set(
+        _find_potential_files(target.parent / target_dirname, target_name))
 
-    raise FileNotFoundError(
-        f'Could not find output binary for build target {target}')
+    if not potential_files:
+        raise FileNotFoundError(
+            f'Could not find output binary for build target {target}')
+
+    if len(potential_files) > 1:
+        raise TooManyFilesError(target, potential_files)
+
+    return str(next(iter(potential_files)))
 
 
 def _resolve_path(gn_root: str, out_dir: str, string: str) -> str:
@@ -123,7 +146,7 @@
         return string
 
     full_path = gn_root + string[2:] if is_gn_path else string
-    resolved_path = pathlib.Path(full_path).resolve()
+    resolved_path = Path(full_path).resolve()
 
     # GN targets exist in the out directory and have the format
     # '/path/to/directory:target_name'.
@@ -167,6 +190,23 @@
     except FileNotFoundError as err:
         _LOG.error('%s: %s', sys.argv[0], err)
         return 1
+    except TooManyFilesError as err:
+        _LOG.error('%s: %s', sys.argv[0], err)
+        _LOG.error('Files found for %s target:\n%s', err.target.name,
+                   '\n'.join(str(f) for f in err.files))
+        _LOG.error('Exactly one file must be found for each target.')
+
+        out_dir_name = args.out_dir.strip('/').split('/', 1)[0]
+        _LOG.error(
+            'To fix this, delete and recreate the output directory. '
+            'For example:\n\n'
+            '  rm -rf %s\n'
+            '  gen gen %s\n', out_dir_name, out_dir_name)
+        _LOG.error(
+            'If clearing the output directory does not work, the file '
+            'resolution logic in this script (%s) may need updating.',
+            __file__)
+        return 1
 
     command = [sys.executable] + resolved_command
     _LOG.debug('RUN %s', ' '.join(shlex.quote(arg) for arg in command))
@@ -194,7 +234,7 @@
         # touch the stamp file to indicate a successful run of the command.
         touch_file = resolve_path(args.gn_root, args.out_dir, args.touch)
         _LOG.debug('TOUCH %s', touch_file)
-        pathlib.Path(touch_file).touch()
+        Path(touch_file).touch()
 
     return completed_process.returncode