Add a stamp option to pw_python_script

GN requires all actions to have at least one output file. However, many
Python scripts are run to process data without generating a file. Such
scripts would previously have to specify a dummy output file and touch
it on completion.

This change adds this stamp functionality to the script-runner script.
The pw_python_script template accepts a boolean "stamp" argument. If
set, the runner will touch a dummy file after it runs its sub-command.

Change-Id: Iff0408ea0d93007418a1766f1eb7ed7b30ec6913
diff --git a/pw_build/py/nop.py b/pw_build/py/nop.py
new file mode 100644
index 0000000..aaa4e8a
--- /dev/null
+++ b/pw_build/py/nop.py
@@ -0,0 +1,27 @@
+# Copyright 2019 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+
+"""Does nothing.
+
+The purpose of this script is to allow for source file dependencies within GN
+to be attached to targets that do not typically support them, such as groups.
+
+For example, instead of creating a group target, a pw_python_script target to
+run this script can be created. The script can be given a list of input files,
+causing GN to rebuild the target and everything that depends on it whenever any
+input file is modified.
+
+This is useful in the case where metadata is attached to a group of files but
+not collected into a generated_file until a later target.
+"""
diff --git a/pw_build/py/python_runner.py b/pw_build/py/python_runner.py
index 5db452d..49396a5 100644
--- a/pw_build/py/python_runner.py
+++ b/pw_build/py/python_runner.py
@@ -16,11 +16,10 @@
 
 import argparse
 import os
+import pathlib
 import subprocess
 import sys
 
-from typing import List
-
 
 def parse_args() -> argparse.Namespace:
     """Parses arguments for this script, splitting out the command to run."""
@@ -30,6 +29,8 @@
                         help='Path to the root of the GN tree')
     parser.add_argument('--out-dir', type=str, required=True,
                         help='Path to the GN build output directory')
+    parser.add_argument('--touch', type=str,
+                        help='File to touch after command is run')
     parser.add_argument('command', nargs=argparse.REMAINDER,
                         help='Python script with arguments to run')
     return parser.parse_args()
@@ -51,8 +52,7 @@
 
     target_path, target_name = target.split(':')
 
-    extensions = ['', '.elf']
-    for extension in extensions:
+    for extension in ['', '.elf', '.exe']:
         potential_filename = f'{target_path}/{target_name}{extension}'
         if os.path.isfile(potential_filename):
             return potential_filename
@@ -61,27 +61,28 @@
         f'Could not find output binary for build target {target}')
 
 
-def resolve_paths(gn_root: str, out_dir: str, command: List[str]) -> None:
-    """Runs through an argument list, replacing GN paths with filesystem paths.
+def resolve_path(gn_root: str, out_dir: str, string: str) -> str:
+    """Resolves a string to a filesystem path if it is a GN path.
 
     GN paths are assumed to be absolute, starting with "//". This is replaced
     with the relative filesystem path of the GN root directory.
 
+    If the string is not a GN path, it is returned unmodified.
+
     If a path refers to the GN output directory and a target name is defined,
     attempts to locate a binary file for the target within the out directory.
     """
 
-    for i, arg in enumerate(command):
-        if not arg.startswith('//'):
-            continue
+    if not string.startswith('//'):
+        return string
 
-        resolved_path = gn_root + arg[2:]
+    resolved_path = gn_root + string[2:]
 
-        # GN targets have the format '/path/to/directory:target_name'.
-        if arg.startswith(out_dir) and ':' in arg:
-            command[i] = find_binary(resolved_path)
-        else:
-            command[i] = resolved_path
+    # GN targets have the format '/path/to/directory:target_name'.
+    if string.startswith(out_dir) and ':' in string:
+        return find_binary(resolved_path)
+
+    return resolved_path
 
 
 def main() -> int:
@@ -92,15 +93,30 @@
         print(f'{sys.argv[0]} requires a command to run', file=sys.stderr)
         return 1
 
-    command = [sys.executable] + args.command[1:]
-
     try:
-        resolve_paths(args.gn_root, args.out_dir, command)
+        resolved_command = [resolve_path(
+            args.gn_root, args.out_dir, arg) for arg in args.command[1:]]
     except FileNotFoundError as err:
-        print(f'{sys.argv[0]} {err}', file=sys.stderr)
+        print(f'{sys.argv[0]}: {err}', file=sys.stderr)
         return 1
 
-    return subprocess.call(command)
+    command = [sys.executable] + resolved_command
+    print('RUN', ' '.join(command), flush=True)
+
+    try:
+        status = subprocess.call(command)
+    except subprocess.CalledProcessError as err:
+        print(f'{sys.argv[0]}: {err}', file=sys.stderr)
+        return 1
+
+    if status == 0 and args.touch:
+        # If a touch file is provided, touch it to indicate a successful run of
+        # the command.
+        touch_file = resolve_path(args.gn_root, args.out_dir, args.touch)
+        print('TOUCH', touch_file)
+        pathlib.Path(touch_file).touch()
+
+    return status
 
 
 if __name__ == '__main__':
diff --git a/pw_build/python_script.gni b/pw_build/python_script.gni
index 1fd340a..ced7a10 100644
--- a/pw_build/python_script.gni
+++ b/pw_build/python_script.gni
@@ -39,6 +39,11 @@
 #      assumes that the target refers to a file built by Ninja and tries to
 #      locate it within the output directory.
 #
+# Additionally, this template can accept a boolean "stamp" argument. If set to
+# true, the script runner will touch a file to indicate the success of the run.
+# This is provided so that individual Python scripts are not required to define
+# an output file if they do not have one.
+#
 # Path resolution examples (assuming the build directory is //out):
 #
 #           BEFORE                     AFTER
@@ -62,13 +67,29 @@
     # Output directory root, used to determine whether to search for a binary.
     "--out-dir",
     root_out_dir,
-
-    # "--" indicates the end of arguments to the runner script.
-    # Everything beyond this point is interpreted as the command and arguments
-    # of the Python script to run.
-    "--",
   ]
 
+  if (defined(invoker.outputs)) {
+    _outputs = invoker.outputs
+  } else {
+    _outputs = []
+  }
+
+  # If a stamp file is requested, add it as an output of the runner script.
+  if (defined(invoker.stamp) && invoker.stamp) {
+    _stamp_file = "$target_gen_dir/$target_name.pw_pystamp"
+    _outputs += [ _stamp_file ]
+    _script_args += [
+      "--touch",
+      _stamp_file,
+    ]
+  }
+
+  # "--" indicates the end of arguments to the runner script.
+  # Everything beyond this point is interpreted as the command and arguments
+  # of the Python script to run.
+  _script_args += [ "--" ]
+
   _script_args += [ get_path_info(invoker.script, "abspath") ]
   if (defined(invoker.args)) {
     _script_args += invoker.args
@@ -78,10 +99,12 @@
     _ignore_vars = [
       "script",
       "args",
+      "outputs",
     ]
     forward_variables_from(invoker, "*", _ignore_vars)
 
     script = "$dir_pw_build/py/python_runner.py"
     args = _script_args
+    outputs = _outputs
   }
 }
diff --git a/pw_unit_test/py/test_runner.py b/pw_unit_test/py/test_runner.py
index 9bbe150..019a8ce 100644
--- a/pw_unit_test/py/test_runner.py
+++ b/pw_unit_test/py/test_runner.py
@@ -29,8 +29,6 @@
     """Parses command-line arguments."""
 
     parser = argparse.ArgumentParser('Run Pigweed unit tests')
-    parser.add_argument('--touch', type=str,
-                        help='File to touch after test run')
     parser.add_argument('test', type=str, help='Path to unit test binary')
     return parser.parse_args()
 
@@ -46,13 +44,6 @@
         print(f'{sys.argv[0]}: {err}', file=sys.stderr)
         return 1
 
-    # GN expects "action" targets to output a file, and uses that to determine
-    # whether the target should be run again. Touching an empty file allows GN
-    # to only run unit tests which have been affected by code changes since the
-    # previous run, taking advantage of its dependency resolution.
-    if args.touch is not None:
-        pathlib.Path(args.touch).touch()
-
     return exit_status
 
 
diff --git a/pw_unit_test/test.gni b/pw_unit_test/test.gni
index 8e3d27c..2a8629a 100644
--- a/pw_unit_test/test.gni
+++ b/pw_unit_test/test.gni
@@ -84,22 +84,13 @@
     # executable using the test runner script.
     _run_action_name = _test_target_name + "_run"
 
-    # The runner script touches this file to indicate that the test has run.
-    _stamp_file = "$target_gen_dir/${_test_target_name}.test_completion.stamp"
-
     pw_python_script(_run_action_name) {
       deps = [
         ":$_test_target_name",
       ]
       script = "$dir_pw_unit_test/py/test_runner.py"
-      args = [
-        "--touch",
-        get_path_info(_stamp_file, "abspath"),
-        get_path_info("$target_out_dir:$_test_target_name", "abspath"),
-      ]
-      outputs = [
-        _stamp_file,
-      ]
+      args = [ get_path_info("$target_out_dir:$_test_target_name", "abspath") ]
+      stamp = true
     }
   }
 }