Add a Str() function to gclient for use in DEPS files.

gclient's existing functionality for handling variables is
ambiguous: the value of a variable can either be a string literal
or an expression fragment. The implementation is required to
parse a value as an expression, and, if it is legal, treat it
as an expression instead of a literal. This means that

  gclient_gn_args_file = 'src/build/args.gni'
  gclient_gn_args = ['xcode_version']
  vars = {
    'xcode_version': 'xcode-12'
  }

would cause a problem because gclient would try to parse the
variable as an expression, and 'xcode' would not be defined.

This patch adds a workaround for this, where you can instead
use the Str() function to explicitly tell gclient to treat the
value as a string and not a potential expression.

The above example would be changed to:

  gclient_gn_args_file = 'src/build/args.gni'
  gclient_gn_args = ['xcode_version']
  vars = {
    'xcode_version': Str('xcode-12')
  }

The variable may still be used in every context where it was legal
to be used before.

Bug: 1099242

Change-Id: Ic2a17eea5f7098113bdba0557fe29e1a931a74b8
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/tools/depot_tools/+/2268406
Reviewed-by: Ben Pastene <bpastene@chromium.org>
Reviewed-by: Edward Lesmes <ehmaldonado@chromium.org>
Commit-Queue: Dirk Pranke <dpranke@google.com>
diff --git a/gclient_eval.py b/gclient_eval.py
index a6dd03b..c0098e4 100644
--- a/gclient_eval.py
+++ b/gclient_eval.py
@@ -24,6 +24,27 @@
   basestring = str
 
 
+class ConstantString(object):
+  def __init__(self, value):
+    self.value = value
+
+  def __format__(self, format_spec):
+    del format_spec
+    return self.value
+
+  def __repr__(self):
+    return "Str('" + self.value + "')"
+
+  def __eq__(self, other):
+    if isinstance(other, ConstantString):
+      return self.value == other.value
+    else:
+      return self.value == other
+
+  def __hash__(self):
+      return self.value.__hash__()
+
+
 class _NodeDict(collections_abc.MutableMapping):
   """Dict-like type that also stores information on AST nodes and tokens."""
   def __init__(self, data=None, tokens=None):
@@ -114,7 +135,7 @@
 _GCLIENT_HOOKS_SCHEMA = [
     _NodeDictSchema({
         # Hook action: list of command-line arguments to invoke.
-        'action': [basestring],
+        'action': [schema.Or(basestring)],
 
         # Name of the hook. Doesn't affect operation.
         schema.Optional('name'): basestring,
@@ -220,7 +241,9 @@
 
         # Variables that can be referenced using Var() - see 'deps'.
         schema.Optional('vars'): _NodeDictSchema({
-            schema.Optional(basestring): schema.Or(basestring, bool),
+            schema.Optional(basestring): schema.Or(ConstantString,
+                                                   basestring,
+                                                   bool),
         }),
     }))
 
@@ -228,6 +251,8 @@
 def _gclient_eval(node_or_string, filename='<unknown>', vars_dict=None):
   """Safely evaluates a single expression. Returns the result."""
   _allowed_names = {'None': None, 'True': True, 'False': False}
+  if isinstance(node_or_string, ConstantString):
+    return node_or_string.value
   if isinstance(node_or_string, basestring):
     node_or_string = ast.parse(node_or_string, filename=filename, mode='eval')
   if isinstance(node_or_string, ast.Expression):
@@ -269,16 +294,23 @@
         node, ast.NameConstant):  # Since Python 3.4
       return node.value
     elif isinstance(node, ast.Call):
-      if not isinstance(node.func, ast.Name) or node.func.id != 'Var':
+      if (not isinstance(node.func, ast.Name) or
+          (node.func.id not in ('Str', 'Var'))):
         raise ValueError(
-            'Var is the only allowed function (file %r, line %s)' % (
+            'Str and Var are the only allowed functions (file %r, line %s)' % (
                 filename, getattr(node, 'lineno', '<unknown>')))
       if node.keywords or getattr(node, 'starargs', None) or getattr(
           node, 'kwargs', None) or len(node.args) != 1:
         raise ValueError(
-            'Var takes exactly one argument (file %r, line %s)' % (
-                filename, getattr(node, 'lineno', '<unknown>')))
-      arg = _convert(node.args[0])
+            '%s takes exactly one argument (file %r, line %s)' % (
+                node.func.id, filename, getattr(node, 'lineno', '<unknown>')))
+      if node.func.id == 'Str':
+        if isinstance(node.args[0], ast.Str):
+          return ConstantString(node.args[0].s)
+        raise ValueError('Passed a non-string to Str() (file %r, line%s)' % (
+            filename, getattr(node, 'lineno', '<unknown>')))
+      else:
+        arg = _convert(node.args[0])
       if not isinstance(arg, basestring):
         raise ValueError(
             'Var\'s argument must be a variable name (file %r, line %s)' % (
@@ -290,7 +322,10 @@
             '%s was used as a variable, but was not declared in the vars dict '
             '(file %r, line %s)' % (
                 arg, filename, getattr(node, 'lineno', '<unknown>')))
-      return vars_dict[arg]
+      val = vars_dict[arg]
+      if isinstance(val, ConstantString):
+        val = val.value
+      return val
     elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
       return _convert(node.left) + _convert(node.right)
     elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mod):
@@ -601,6 +636,8 @@
 
 
 def _UpdateAstString(tokens, node, value):
+  if isinstance(node, ast.Call):
+    node = node.args[0]
   position = node.lineno, node.col_offset
   quote_char = ''
   if isinstance(node, ast.Str):
@@ -810,7 +847,10 @@
     raise KeyError(
         "Could not find any variable called %s." % var_name)
 
-  return gclient_dict['vars'][var_name]
+  val = gclient_dict['vars'][var_name]
+  if isinstance(val, ConstantString):
+    return val.value
+  return val
 
 
 def GetCIPD(gclient_dict, dep_name, package_name):