pw_build: Add working_directory arg to pw_exec

When executing external programs, allow for setting a working directory
the program will be started with.

Also document pw_exec.

Change-Id: I977060b51823c62f254f56dc3af36ea3ca9c88b9
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/78480
Reviewed-by: Alexei Frolov <frolv@google.com>
Commit-Queue: Austin Foxley <afoxley@google.com>
diff --git a/pw_build/docs.rst b/pw_build/docs.rst
index 72fdbd9..44d1fa5 100644
--- a/pw_build/docs.rst
+++ b/pw_build/docs.rst
@@ -293,6 +293,56 @@
     stamp = true
   }
 
+pw_exec
+-------
+``pw_exec`` allows for execution of arbitrary programs. It is a wrapper around
+``pw_python_action`` but allows for specifying the program to execute.
+
+.. note:: Prefer to use ``pw_python_action`` instead of calling out to shell
+  scripts, as the python will be more portable. ``pw_exec`` should generally
+  only be used for interacting with legacy/existing scripts.
+
+**Arguments**
+
+* ``program``: The program to run. Can be a full path or just a name (in which
+  case $PATH is searched).
+* ``args``: Optional list of arguments to the program.
+* ``deps``: Dependencies for this target.
+* ``inputs``: Optional list of build inputs to the program.
+* ``outputs``: Optional list of artifacts produced by the program's execution.
+* ``env``: Optional list of key-value pairs defining environment variables for
+  the program.
+* ``env_file``: Optional path to a file containing a list of newline-separated
+  key-value pairs defining environment variables for the program.
+* ``args_file``: Optional path to a file containing additional positional
+  arguments to the program. Each line of the file is appended to the
+  invocation. Useful for specifying arguments from GN metadata.
+* ``skip_empty_args``: If args_file is provided, boolean indicating whether to
+  skip running the program if the file is empty. Used to avoid running
+  commands which error when called without arguments.
+* ``capture_output``: If true, output from the program is hidden unless the
+  program exits with an error. Defaults to true.
+* ``working_directory``: The working directory to execute the subprocess with.
+  If not specified it will not be set and the subprocess will have whatever
+  the parent current working directory is.
+
+**Example**
+
+.. code-block::
+
+  import("$dir_pw_build/exec.gni")
+
+  pw_exec("hello_world") {
+    program = "/bin/sh"
+    args = [
+      "-c",
+      "echo hello \$WORLD",
+    ]
+    env = [
+      "WORLD=world",
+    ]
+  }
+
 pw_input_group
 --------------
 ``pw_input_group`` defines a group of input files which are not directly
diff --git a/pw_build/exec.gni b/pw_build/exec.gni
index 269709a..26f3d62 100644
--- a/pw_build/exec.gni
+++ b/pw_build/exec.gni
@@ -48,6 +48,10 @@
 #  capture_output: If true, output from the program is hidden unless the program
 #    exits with an error. Defaults to true.
 #
+#  working_directory: The working directory to execute the subprocess with. If
+#    not specified it will not be set and the subprocess will have whatever the
+#    parent current working directory is.
+#
 # Example:
 #
 #   pw_exec("hello_world") {
@@ -103,6 +107,13 @@
     _capture_output = false
   }
 
+  if (defined(invoker.working_directory)) {
+    _script_args += [
+      "--working-directory",
+      invoker.working_directory,
+    ]
+  }
+
   _script_args += [
     "--",
     invoker.program,
diff --git a/pw_build/py/pw_build/exec.py b/pw_build/py/pw_build/exec.py
index b956c13..aac800b 100644
--- a/pw_build/py/pw_build/exec.py
+++ b/pw_build/py/pw_build/exec.py
@@ -20,6 +20,7 @@
 import shlex
 import subprocess
 import sys
+import pathlib
 from typing import Dict, Optional
 
 # Need to be able to run without pw_cli installed in the virtualenv.
@@ -70,6 +71,9 @@
         '--target',
         help='GN build target that runs the program',
     )
+    parser.add_argument('--working-directory',
+                        type=pathlib.Path,
+                        help='Directory to execute program in')
     parser.add_argument(
         'command',
         nargs=argparse.REMAINDER,
@@ -113,6 +117,7 @@
 
     # Command starts after the "--".
     command = args.command[1:]
+    extra_kw_args = {}
 
     if args.args_file is not None:
         empty = True
@@ -132,11 +137,13 @@
         apply_env_var(string, env)
 
     if args.capture_output:
-        output_args = {'stdout': subprocess.PIPE, 'stderr': subprocess.STDOUT}
-    else:
-        output_args = {}
+        extra_kw_args['stdout'] = subprocess.PIPE
+        extra_kw_args['stderr'] = subprocess.STDOUT
 
-    process = subprocess.run(command, env=env, **output_args)  # type: ignore
+    if args.working_directory:
+        extra_kw_args['cwd'] = args.working_directory
+
+    process = subprocess.run(command, env=env, **extra_kw_args)  # type: ignore
 
     if process.returncode != 0 and args.capture_output:
         _LOG.error('')