1
    2
    3
    4
    5
    6
    7
    8
    9
   10
   11
   12
   13
   14
   15
   16
   17
   18
   19
   20
   21
   22
   23
   24
   25
   26
   27
   28
   29
   30
   31
   32
   33
   34
   35
   36
   37
   38
   39
   40
   41
   42
   43
   44
   45
   46
   47
   48
   49
   50
   51
   52
   53
   54
   55
   56
   57
   58
   59
   60
   61
   62
   63
   64
   65
   66
   67
   68
   69
   70
   71
   72
   73
   74
   75
   76
   77
   78
   79
   80
   81
   82
   83
   84
   85
   86
   87
   88
   89
   90
   91
   92
   93
   94
   95
   96
   97
   98
   99
  100
  101
  102
  103
  104
  105
  106
  107
  108
  109
  110
  111
  112
  113
  114
  115
  116
  117
  118
  119
  120
  121
  122
  123
  124
  125
  126
  127
  128
  129
  130
  131
  132
  133
  134
  135
  136
  137
  138
  139
  140
  141
  142
  143
  144
  145
  146
  147
  148
  149
  150
  151
  152
  153
  154
  155
  156
  157
  158
  159
  160
  161
  162
  163
  164
  165
  166
  167
  168
  169
  170
  171
  172
  173
  174
  175
  176
  177
  178
  179
  180
  181
  182
  183
  184
  185
  186
  187
  188
  189
  190
  191
  192
  193
  194
  195
  196
  197
  198
  199
  200
  201
  202
  203
  204
  205
  206
  207
  208
  209
  210
  211
  212
  213
  214
  215
  216
  217
  218
  219
  220
  221
  222
  223
  224
  225
  226
  227
  228
  229
  230
  231
  232
  233
  234
  235
  236
  237
  238
  239
  240
  241
  242
  243
  244
  245
  246
  247
  248
  249
  250
  251
  252
  253
  254
  255
  256
  257
  258
  259
  260
  261
  262
  263
  264
  265
  266
  267
  268
  269
  270
  271
  272
  273
  274
  275
  276
  277
  278
  279
  280
  281
  282
  283
  284
  285
  286
  287
  288
  289
  290
  291
  292
  293
  294
  295
  296
  297
  298
  299
  300
  301
  302
  303
  304
  305
  306
  307
  308
  309
  310
  311
  312
  313
  314
  315
  316
  317
  318
  319
  320
  321
  322
  323
  324
  325
  326
  327
  328
  329
  330
  331
  332
  333
  334
  335
  336
  337
  338
  339
  340
  341
  342
  343
  344
  345
  346
  347
  348
  349
  350
  351
  352
  353
  354
  355
  356
  357
  358
  359
  360
  361
  362
  363
  364
  365
  366
  367
  368
  369
  370
  371
  372
  373
  374
  375
  376
  377
  378
  379
  380
  381
  382
  383
  384
  385
  386
  387
  388
  389
  390
  391
  392
  393
  394
  395
  396
  397
  398
  399
  400
  401
  402
  403
  404
  405
  406
  407
  408
  409
  410
  411
  412
  413
  414
  415
  416
  417
  418
  419
  420
  421
  422
  423
  424
  425
  426
  427
  428
  429
  430
  431
  432
  433
  434
  435
  436
  437
  438
  439
  440
  441
  442
  443
  444
  445
  446
  447
  448
  449
  450
  451
  452
  453
  454
  455
  456
  457
  458
  459
  460
  461
  462
  463
  464
  465
  466
  467
  468
  469
  470
  471
  472
  473
  474
  475
  476
  477
  478
  479
  480
  481
  482
  483
  484
  485
  486
  487
  488
  489
  490
  491
  492
  493
  494
  495
  496
  497
  498
  499
  500
  501
  502
  503
  504
  505
  506
  507
  508
  509
  510
  511
  512
  513
  514
  515
  516
  517
  518
  519
  520
  521
  522
  523
  524
  525
  526
  527
  528
  529
  530
  531
  532
  533
  534
  535
  536
  537
  538
  539
  540
  541
  542
  543
  544
  545
  546
  547
  548
  549
  550
  551
  552
  553
  554
  555
  556
  557
  558
  559
  560
  561
  562
  563
  564
  565
  566
  567
  568
  569
  570
  571
  572
  573
  574
  575
  576
  577
  578
  579

build / gn_helpers.py [blame]

# Copyright 2014 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 that integrate with GN.

The main functions are ToGNString() and FromGNString(), to convert between
serialized GN veriables and Python variables.

To use in an arbitrary Python file in the build:

  import os
  import sys

  sys.path.append(os.path.join(os.path.dirname(__file__),
                               os.pardir, os.pardir, 'build'))
  import gn_helpers

Where the sequence of parameters to join is the relative path from your source
file to the build directory.
"""

import json
import os
import re
import shutil
import sys


_CHROMIUM_ROOT = os.path.abspath(
    os.path.join(os.path.dirname(__file__), os.pardir))

BUILD_VARS_FILENAME = 'build_vars.json'
IMPORT_RE = re.compile(r'^import\("//(\S+)"\)')


class GNError(Exception):
  pass


# Computes ASCII code of an element of encoded Python 2 str / Python 3 bytes.
_Ord = ord if sys.version_info.major < 3 else lambda c: c


def _TranslateToGnChars(s):
  for decoded_ch in s.encode('utf-8'):  # str in Python 2, bytes in Python 3.
    code = _Ord(decoded_ch)  # int
    if code in (34, 36, 92):  # For '"', '$', or '\\'.
      yield '\\' + chr(code)
    elif 32 <= code < 127:
      yield chr(code)
    else:
      yield '$0x%02X' % code


def ToGNString(value, pretty=False):
  """Returns a stringified GN equivalent of a Python value.

  Args:
    value: The Python value to convert.
    pretty: Whether to pretty print. If true, then non-empty lists are rendered
        recursively with one item per line, with indents. Otherwise lists are
        rendered without new line.
  Returns:
    The stringified GN equivalent to |value|.

  Raises:
    GNError: |value| cannot be printed to GN.
  """

  if sys.version_info.major < 3:
    basestring_compat = basestring
  else:
    basestring_compat = str

  # Emits all output tokens without intervening whitespaces.
  def GenerateTokens(v, level):
    if isinstance(v, basestring_compat):
      yield '"' + ''.join(_TranslateToGnChars(v)) + '"'

    elif isinstance(v, bool):
      yield 'true' if v else 'false'

    elif isinstance(v, int):
      yield str(v)

    elif isinstance(v, list):
      yield '['
      for i, item in enumerate(v):
        if i > 0:
          yield ','
        for tok in GenerateTokens(item, level + 1):
          yield tok
      yield ']'

    elif isinstance(v, dict):
      if level > 0:
        yield '{'
      for key in sorted(v):
        if not isinstance(key, basestring_compat):
          raise GNError('Dictionary key is not a string.')
        if not key or key[0].isdigit() or not key.replace('_', '').isalnum():
          raise GNError('Dictionary key is not a valid GN identifier.')
        yield key  # No quotations.
        yield '='
        for tok in GenerateTokens(v[key], level + 1):
          yield tok
      if level > 0:
        yield '}'

    else:  # Not supporting float: Add only when needed.
      raise GNError('Unsupported type when printing to GN.')

  can_start = lambda tok: tok and tok not in ',}]='
  can_end = lambda tok: tok and tok not in ',{[='

  # Adds whitespaces, trying to keep everything (except dicts) in 1 line.
  def PlainGlue(gen):
    prev_tok = None
    for i, tok in enumerate(gen):
      if i > 0:
        if can_end(prev_tok) and can_start(tok):
          yield '\n'  # New dict item.
        elif prev_tok == '[' and tok == ']':
          yield '  '  # Special case for [].
        elif tok != ',':
          yield ' '
      yield tok
      prev_tok = tok

  # Adds whitespaces so non-empty lists can span multiple lines, with indent.
  def PrettyGlue(gen):
    prev_tok = None
    level = 0
    for i, tok in enumerate(gen):
      if i > 0:
        if can_end(prev_tok) and can_start(tok):
          yield '\n' + '  ' * level  # New dict item.
        elif tok == '=' or prev_tok in '=':
          yield ' '  # Separator before and after '=', on same line.
      if tok in ']}':
        level -= 1
      # Exclude '[]' and '{}' cases.
      if int(prev_tok == '[') + int(tok == ']') == 1 or \
         int(prev_tok == '{') + int(tok == '}') == 1:
        yield '\n' + '  ' * level
      yield tok
      if tok in '[{':
        level += 1
      if tok == ',':
        yield '\n' + '  ' * level
      prev_tok = tok

  token_gen = GenerateTokens(value, 0)
  ret = ''.join((PrettyGlue if pretty else PlainGlue)(token_gen))
  # Add terminating '\n' for dict |value| or multi-line output.
  if isinstance(value, dict) or '\n' in ret:
    return ret + '\n'
  return ret


def FromGNString(input_string):
  """Converts the input string from a GN serialized value to Python values.

  For details on supported types see GNValueParser.Parse() below.

  If your GN script did:
    something = [ "file1", "file2" ]
    args = [ "--values=$something" ]
  The command line would look something like:
    --values="[ \"file1\", \"file2\" ]"
  Which when interpreted as a command line gives the value:
    [ "file1", "file2" ]

  You can parse this into a Python list using GN rules with:
    input_values = FromGNValues(options.values)
  Although the Python 'ast' module will parse many forms of such input, it
  will not handle GN escaping properly, nor GN booleans. You should use this
  function instead.


  A NOTE ON STRING HANDLING:

  If you just pass a string on the command line to your Python script, or use
  string interpolation on a string variable, the strings will not be quoted:
    str = "asdf"
    args = [ str, "--value=$str" ]
  Will yield the command line:
    asdf --value=asdf
  The unquoted asdf string will not be valid input to this function, which
  accepts only quoted strings like GN scripts. In such cases, you can just use
  the Python string literal directly.

  The main use cases for this is for other types, in particular lists. When
  using string interpolation on a list (as in the top example) the embedded
  strings will be quoted and escaped according to GN rules so the list can be
  re-parsed to get the same result.
  """
  parser = GNValueParser(input_string)
  return parser.Parse()


def FromGNArgs(input_string):
  """Converts a string with a bunch of gn arg assignments into a Python dict.

  Given a whitespace-separated list of

    <ident> = (integer | string | boolean | <list of the former>)

  gn assignments, this returns a Python dict, i.e.:

    FromGNArgs('foo=true\nbar=1\n') -> { 'foo': True, 'bar': 1 }.

  Only simple types and lists supported; variables, structs, calls
  and other, more complicated things are not.

  This routine is meant to handle only the simple sorts of values that
  arise in parsing --args.
  """
  parser = GNValueParser(input_string)
  return parser.ParseArgs()


def UnescapeGNString(value):
  """Given a string with GN escaping, returns the unescaped string.

  Be careful not to feed with input from a Python parsing function like
  'ast' because it will do Python unescaping, which will be incorrect when
  fed into the GN unescaper.

  Args:
    value: Input string to unescape.
  """
  result = ''
  i = 0
  while i < len(value):
    if value[i] == '\\':
      if i < len(value) - 1:
        next_char = value[i + 1]
        if next_char in ('$', '"', '\\'):
          # These are the escaped characters GN supports.
          result += next_char
          i += 1
        else:
          # Any other backslash is a literal.
          result += '\\'
    else:
      result += value[i]
    i += 1
  return result


def _IsDigitOrMinus(char):
  return char in '-0123456789'


class GNValueParser(object):
  """Duplicates GN parsing of values and converts to Python types.

  Normally you would use the wrapper function FromGNValue() below.

  If you expect input as a specific type, you can also call one of the Parse*
  functions directly. All functions throw GNError on invalid input.
  """

  def __init__(self, string, checkout_root=_CHROMIUM_ROOT):
    self.input = string
    self.cur = 0
    self.checkout_root = checkout_root

  def IsDone(self):
    return self.cur == len(self.input)

  def ReplaceImports(self):
    """Replaces import(...) lines with the contents of the imports.

    Recurses on itself until there are no imports remaining, in the case of
    nested imports.
    """
    lines = self.input.splitlines()
    if not any(line.startswith('import(') for line in lines):
      return
    for line in lines:
      if not line.startswith('import('):
        continue
      regex_match = IMPORT_RE.match(line)
      if not regex_match:
        raise GNError('Not a valid import string: %s' % line)
      import_path = os.path.join(self.checkout_root, regex_match.group(1))
      with open(import_path) as f:
        imported_args = f.read()
      self.input = self.input.replace(line, imported_args)
    # Call ourselves again if we've just replaced an import() with additional
    # imports.
    self.ReplaceImports()


  def _ConsumeWhitespace(self):
    while not self.IsDone() and self.input[self.cur] in ' \t\n':
      self.cur += 1

  def ConsumeCommentAndWhitespace(self):
    self._ConsumeWhitespace()

    # Consume each comment, line by line.
    while not self.IsDone() and self.input[self.cur] == '#':
      # Consume the rest of the comment, up until the end of the line.
      while not self.IsDone() and self.input[self.cur] != '\n':
        self.cur += 1
      # Move the cursor to the next line (if there is one).
      if not self.IsDone():
        self.cur += 1

      self._ConsumeWhitespace()

  def Parse(self):
    """Converts a string representing a printed GN value to the Python type.

    See additional usage notes on FromGNString() above.

    * GN booleans ('true', 'false') will be converted to Python booleans.

    * GN numbers ('123') will be converted to Python numbers.

    * GN strings (double-quoted as in '"asdf"') will be converted to Python
      strings with GN escaping rules. GN string interpolation (embedded
      variables preceded by $) are not supported and will be returned as
      literals.

    * GN lists ('[1, "asdf", 3]') will be converted to Python lists.

    * GN scopes ('{ ... }') are not supported.

    Raises:
      GNError: Parse fails.
    """
    result = self._ParseAllowTrailing()
    self.ConsumeCommentAndWhitespace()
    if not self.IsDone():
      raise GNError("Trailing input after parsing:\n  " + self.input[self.cur:])
    return result

  def ParseArgs(self):
    """Converts a whitespace-separated list of ident=literals to a dict.

    See additional usage notes on FromGNArgs(), above.

    Raises:
      GNError: Parse fails.
    """
    d = {}

    self.ReplaceImports()
    self.ConsumeCommentAndWhitespace()

    while not self.IsDone():
      ident = self._ParseIdent()
      self.ConsumeCommentAndWhitespace()
      if self.input[self.cur] != '=':
        raise GNError("Unexpected token: " + self.input[self.cur:])
      self.cur += 1
      self.ConsumeCommentAndWhitespace()
      val = self._ParseAllowTrailing()
      self.ConsumeCommentAndWhitespace()
      d[ident] = val

    return d

  def _ParseAllowTrailing(self):
    """Internal version of Parse() that doesn't check for trailing stuff."""
    self.ConsumeCommentAndWhitespace()
    if self.IsDone():
      raise GNError("Expected input to parse.")

    next_char = self.input[self.cur]
    if next_char == '[':
      return self.ParseList()
    elif next_char == '{':
      return self.ParseScope()
    elif _IsDigitOrMinus(next_char):
      return self.ParseNumber()
    elif next_char == '"':
      return self.ParseString()
    elif self._ConstantFollows('true'):
      return True
    elif self._ConstantFollows('false'):
      return False
    else:
      raise GNError("Unexpected token: " + self.input[self.cur:])

  def _ParseIdent(self):
    ident = ''

    next_char = self.input[self.cur]
    if not next_char.isalpha() and not next_char=='_':
      raise GNError("Expected an identifier: " + self.input[self.cur:])

    ident += next_char
    self.cur += 1

    next_char = self.input[self.cur]
    while next_char.isalpha() or next_char.isdigit() or next_char=='_':
      ident += next_char
      self.cur += 1
      next_char = self.input[self.cur]

    return ident

  def ParseNumber(self):
    self.ConsumeCommentAndWhitespace()
    if self.IsDone():
      raise GNError('Expected number but got nothing.')

    begin = self.cur

    # The first character can include a negative sign.
    if not self.IsDone() and _IsDigitOrMinus(self.input[self.cur]):
      self.cur += 1
    while not self.IsDone() and self.input[self.cur].isdigit():
      self.cur += 1

    number_string = self.input[begin:self.cur]
    if not len(number_string) or number_string == '-':
      raise GNError('Not a valid number.')
    return int(number_string)

  def ParseString(self):
    self.ConsumeCommentAndWhitespace()
    if self.IsDone():
      raise GNError('Expected string but got nothing.')

    if self.input[self.cur] != '"':
      raise GNError('Expected string beginning in a " but got:\n  ' +
                    self.input[self.cur:])
    self.cur += 1  # Skip over quote.

    begin = self.cur
    while not self.IsDone() and self.input[self.cur] != '"':
      if self.input[self.cur] == '\\':
        self.cur += 1  # Skip over the backslash.
        if self.IsDone():
          raise GNError('String ends in a backslash in:\n  ' + self.input)
      self.cur += 1

    if self.IsDone():
      raise GNError('Unterminated string:\n  ' + self.input[begin:])

    end = self.cur
    self.cur += 1  # Consume trailing ".

    return UnescapeGNString(self.input[begin:end])

  def ParseList(self):
    self.ConsumeCommentAndWhitespace()
    if self.IsDone():
      raise GNError('Expected list but got nothing.')

    # Skip over opening '['.
    if self.input[self.cur] != '[':
      raise GNError('Expected [ for list but got:\n  ' + self.input[self.cur:])
    self.cur += 1
    self.ConsumeCommentAndWhitespace()
    if self.IsDone():
      raise GNError('Unterminated list:\n  ' + self.input)

    list_result = []
    previous_had_trailing_comma = True
    while not self.IsDone():
      if self.input[self.cur] == ']':
        self.cur += 1  # Skip over ']'.
        return list_result

      if not previous_had_trailing_comma:
        raise GNError('List items not separated by comma.')

      list_result += [ self._ParseAllowTrailing() ]
      self.ConsumeCommentAndWhitespace()
      if self.IsDone():
        break

      # Consume comma if there is one.
      previous_had_trailing_comma = self.input[self.cur] == ','
      if previous_had_trailing_comma:
        # Consume comma.
        self.cur += 1
        self.ConsumeCommentAndWhitespace()

    raise GNError('Unterminated list:\n  ' + self.input)

  def ParseScope(self):
    self.ConsumeCommentAndWhitespace()
    if self.IsDone():
      raise GNError('Expected scope but got nothing.')

    # Skip over opening '{'.
    if self.input[self.cur] != '{':
      raise GNError('Expected { for scope but got:\n ' + self.input[self.cur:])
    self.cur += 1
    self.ConsumeCommentAndWhitespace()
    if self.IsDone():
      raise GNError('Unterminated scope:\n ' + self.input)

    scope_result = {}
    while not self.IsDone():
      if self.input[self.cur] == '}':
        self.cur += 1
        return scope_result

      ident = self._ParseIdent()
      self.ConsumeCommentAndWhitespace()
      if self.input[self.cur] != '=':
        raise GNError("Unexpected token: " + self.input[self.cur:])
      self.cur += 1
      self.ConsumeCommentAndWhitespace()
      val = self._ParseAllowTrailing()
      self.ConsumeCommentAndWhitespace()
      scope_result[ident] = val

    raise GNError('Unterminated scope:\n ' + self.input)

  def _ConstantFollows(self, constant):
    """Checks and maybe consumes a string constant at current input location.

    Param:
      constant: The string constant to check.

    Returns:
      True if |constant| follows immediately at the current location in the
      input. In this case, the string is consumed as a side effect. Otherwise,
      returns False and the current position is unchanged.
    """
    end = self.cur + len(constant)
    if end > len(self.input):
      return False  # Not enough room.
    if self.input[self.cur:end] == constant:
      self.cur = end
      return True
    return False


def ReadBuildVars(output_directory):
  """Parses $output_directory/build_vars.json into a dict."""
  with open(os.path.join(output_directory, BUILD_VARS_FILENAME)) as f:
    return json.load(f)


def CreateBuildCommand(output_directory):
  """Returns [cmd, -C, output_directory].

  Where |cmd| is one of: siso ninja, ninja, or autoninja.
  """
  suffix = '.bat' if sys.platform.startswith('win32') else ''
  # Prefer the version on PATH, but fallback to known version if PATH doesn't
  # have one (e.g. on bots).
  if not shutil.which(f'autoninja{suffix}'):
    third_party_prefix = os.path.join(_CHROMIUM_ROOT, 'third_party')
    ninja_prefix = os.path.join(third_party_prefix, 'ninja', '')
    siso_prefix = os.path.join(third_party_prefix, 'siso', 'cipd', '')
    # Also - bots configure reclient manually, and so do not use the "auto"
    # wrappers.
    ninja_cmd = [f'{ninja_prefix}ninja{suffix}']
    siso_cmd = [f'{siso_prefix}siso{suffix}', 'ninja']
  else:
    ninja_cmd = [f'autoninja{suffix}']
    siso_cmd = list(ninja_cmd)

  if output_directory and os.path.abspath(output_directory) != os.path.abspath(
      os.curdir):
    ninja_cmd += ['-C', output_directory]
    siso_cmd += ['-C', output_directory]
  siso_deps = os.path.exists(os.path.join(output_directory, '.siso_deps'))
  ninja_deps = os.path.exists(os.path.join(output_directory, '.ninja_deps'))
  if siso_deps and ninja_deps:
    raise Exception('Found both .siso_deps and .ninja_deps in '
                    f'{output_directory}. Not sure which build tool to use. '
                    'Please delete one, or better, run "gn clean".')
  if siso_deps:
    return siso_cmd
  return ninja_cmd