Copy some build_utils helpers to a non-android location

Specifically, moves:
 * atomic_output
 * add_depfile_arg()
 * parse_gn_list()
 * write_depfile()

Into //build/action_helpers.py

This will remove some oddness of non-Android scripts importing a file
from //build/android.

I will remove the copies in build_utils.py in a follow-up.

Bug: 1428082
Change-Id: If7dfe5306a7907987417e345637758c716a75ab5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4382406
Commit-Queue: Andrew Grieve <[email protected]>
Reviewed-by: Sam Maier <[email protected]>
Auto-Submit: Andrew Grieve <[email protected]>
Owners-Override: Andrew Grieve <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1124347}
diff --git a/build/action_helpers.py b/build/action_helpers.py
new file mode 100644
index 0000000..7a9db14
--- /dev/null
+++ b/build/action_helpers.py
@@ -0,0 +1,125 @@
+# Copyright 2023 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Helper functions useful when writing scripts used by action() targets."""
+
+import contextlib
+import filecmp
+import os
+import pathlib
+import shutil
+import tempfile
+
+import gn_helpers
+
+
[email protected]
+def atomic_output(path, mode='w+b', only_if_changed=True):
+  """Prevent half-written files and dirty mtimes for unchanged files.
+
+  Args:
+    path: Path to the final output file, which will be written atomically.
+    mode: The mode to open the file in (str).
+    only_if_changed: Whether to maintain the mtime if the file has not changed.
+  Returns:
+    A Context Manager that yields a NamedTemporaryFile instance. On exit, the
+    manager will check if the file contents is different from the destination
+    and if so, move it into place.
+
+  Example:
+    with action_helpers.atomic_output(output_path) as tmp_file:
+      subprocess.check_call(['prog', '--output', tmp_file.name])
+  """
+  # Create in same directory to ensure same filesystem when moving.
+  dirname = os.path.dirname(path) or '.'
+  os.makedirs(dirname, exist_ok=True)
+  with tempfile.NamedTemporaryFile(mode,
+                                   suffix=os.path.basename(path),
+                                   dir=dirname,
+                                   delete=False) as f:
+    try:
+      yield f
+
+      # file should be closed before comparison/move.
+      f.close()
+      if not (only_if_changed and os.path.exists(path)
+              and filecmp.cmp(f.name, path)):
+        shutil.move(f.name, path)
+    finally:
+      if os.path.exists(f.name):
+        os.unlink(f.name)
+
+
+def add_depfile_arg(parser):
+  if hasattr(parser, 'add_option'):
+    func = parser.add_option
+  else:
+    func = parser.add_argument
+  func('--depfile', help='Path to depfile (refer to "gn help depfile")')
+
+
+def write_depfile(depfile_path, first_gn_output, inputs=None):
+  """Writes a ninja depfile.
+
+  See notes about how to use depfiles in //build/docs/writing_gn_templates.md.
+
+  Args:
+    depfile_path: Path to file to write.
+    first_gn_output: Path of first entry in action's outputs.
+    inputs: List of inputs to add to depfile.
+  """
+  assert depfile_path != first_gn_output  # http://crbug.com/646165
+  assert not isinstance(inputs, str)  # Easy mistake to make
+
+  if inputs:
+    for path in inputs:
+      # Ensure relative paths os that build dirs are hermetic.
+      assert not os.path.isabs(path), f'Found abs path in depfile: {path}'
+
+  def _escape(value):
+    return value.replace(' ', '\\ ')
+
+  sb = []
+  sb.append(_escape(first_gn_output))
+  if inputs:
+    # Sort and uniquify to ensure file is hermetic.
+    # One path per line to keep it human readable.
+    sb.append(': \\\n ')
+    sb.append(' \\\n '.join(sorted(_escape(p) for p in set(inputs))))
+  else:
+    sb.append(': ')
+  sb.append('\n')
+
+  path = pathlib.Path(depfile_path)
+  path.parent.mkdir(parents=True, exist_ok=True)
+  path.write_text(''.join(sb))
+
+
+def parse_gn_list(value):
+  """Converts a "GN-list" command-line parameter into a list.
+
+  Conversions handled:
+    * None -> []
+    * '' -> []
+    * 'asdf' -> ['asdf']
+    * '["a", "b"]' -> ['a', 'b']
+    * ['["a", "b"]', 'c'] -> ['a', 'b', 'c']  (action='append')
+
+  This allows passing args like:
+  gn_list = [ "one", "two", "three" ]
+  args = [ "--items=$gn_list" ]
+  """
+  # Convert None to [].
+  if not value:
+    return []
+  # Convert a list of GN lists to a flattened list.
+  if isinstance(value, list):
+    ret = []
+    for arg in value:
+      ret.extend(parse_gn_list(arg))
+    return ret
+  # Convert normal GN list.
+  if value.startswith('['):
+    return gn_helpers.GNValueParser(value).ParseList()
+  # Convert a single string value to a list.
+  return [value]