tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

gn_helpers.py (18934B)


      1 # Copyright 2014 The Chromium Authors
      2 # Use of this source code is governed by a BSD-style license that can be
      3 # found in the LICENSE file.
      4 
      5 """Helper functions useful when writing scripts that integrate with GN.
      6 
      7 The main functions are ToGNString() and FromGNString(), to convert between
      8 serialized GN veriables and Python variables.
      9 
     10 To use in an arbitrary Python file in the build:
     11 
     12  import os
     13  import sys
     14 
     15  sys.path.append(os.path.join(os.path.dirname(__file__),
     16                               os.pardir, os.pardir, 'build'))
     17  import gn_helpers
     18 
     19 Where the sequence of parameters to join is the relative path from your source
     20 file to the build directory.
     21 """
     22 
     23 import json
     24 import os
     25 import re
     26 import shutil
     27 import sys
     28 
     29 
     30 _CHROMIUM_ROOT = os.path.abspath(
     31    os.path.join(os.path.dirname(__file__), os.pardir))
     32 
     33 ARGS_GN_FILENAME = 'args.gn'
     34 BUILD_VARS_FILENAME = 'build_vars.json'
     35 IMPORT_RE = re.compile(r'^import\("(\S+)"\)')
     36 
     37 
     38 class GNError(Exception):
     39  pass
     40 
     41 
     42 # Computes ASCII code of an element of encoded Python 2 str / Python 3 bytes.
     43 _Ord = ord if sys.version_info.major < 3 else lambda c: c
     44 
     45 
     46 def _TranslateToGnChars(s):
     47  for decoded_ch in s.encode('utf-8'):  # str in Python 2, bytes in Python 3.
     48    code = _Ord(decoded_ch)  # int
     49    if code in (34, 36, 92):  # For '"', '$', or '\\'.
     50      yield '\\' + chr(code)
     51    elif 32 <= code < 127:
     52      yield chr(code)
     53    else:
     54      yield '$0x%02X' % code
     55 
     56 
     57 def ToGNString(value, pretty=False):
     58  """Returns a stringified GN equivalent of a Python value.
     59 
     60  Args:
     61    value: The Python value to convert.
     62    pretty: Whether to pretty print. If true, then non-empty lists are rendered
     63        recursively with one item per line, with indents. Otherwise lists are
     64        rendered without new line.
     65  Returns:
     66    The stringified GN equivalent to |value|.
     67 
     68  Raises:
     69    GNError: |value| cannot be printed to GN.
     70  """
     71 
     72  if sys.version_info.major < 3:
     73    basestring_compat = basestring
     74  else:
     75    basestring_compat = str
     76 
     77  # Emits all output tokens without intervening whitespaces.
     78  def GenerateTokens(v, level):
     79    if isinstance(v, basestring_compat):
     80      yield '"' + ''.join(_TranslateToGnChars(v)) + '"'
     81 
     82    elif isinstance(v, bool):
     83      yield 'true' if v else 'false'
     84 
     85    elif isinstance(v, int):
     86      yield str(v)
     87 
     88    elif isinstance(v, list):
     89      yield '['
     90      for i, item in enumerate(v):
     91        if i > 0:
     92          yield ','
     93        for tok in GenerateTokens(item, level + 1):
     94          yield tok
     95      yield ']'
     96 
     97    elif isinstance(v, dict):
     98      if level > 0:
     99        yield '{'
    100      for key in sorted(v):
    101        if not isinstance(key, basestring_compat):
    102          raise GNError('Dictionary key is not a string.')
    103        if not key or key[0].isdigit() or not key.replace('_', '').isalnum():
    104          raise GNError('Dictionary key is not a valid GN identifier.')
    105        yield key  # No quotations.
    106        yield '='
    107        for tok in GenerateTokens(v[key], level + 1):
    108          yield tok
    109      if level > 0:
    110        yield '}'
    111 
    112    else:  # Not supporting float: Add only when needed.
    113      raise GNError('Unsupported type when printing to GN.')
    114 
    115  can_start = lambda tok: tok and tok not in ',}]='
    116  can_end = lambda tok: tok and tok not in ',{[='
    117 
    118  # Adds whitespaces, trying to keep everything (except dicts) in 1 line.
    119  def PlainGlue(gen):
    120    prev_tok = None
    121    for i, tok in enumerate(gen):
    122      if i > 0:
    123        if can_end(prev_tok) and can_start(tok):
    124          yield '\n'  # New dict item.
    125        elif prev_tok == '[' and tok == ']':
    126          yield '  '  # Special case for [].
    127        elif tok != ',':
    128          yield ' '
    129      yield tok
    130      prev_tok = tok
    131 
    132  # Adds whitespaces so non-empty lists can span multiple lines, with indent.
    133  def PrettyGlue(gen):
    134    prev_tok = None
    135    level = 0
    136    for i, tok in enumerate(gen):
    137      if i > 0:
    138        if can_end(prev_tok) and can_start(tok):
    139          yield '\n' + '  ' * level  # New dict item.
    140        elif tok == '=' or prev_tok in '=':
    141          yield ' '  # Separator before and after '=', on same line.
    142      if tok in ']}':
    143        level -= 1
    144      # Exclude '[]' and '{}' cases.
    145      if int(prev_tok == '[') + int(tok == ']') == 1 or \
    146         int(prev_tok == '{') + int(tok == '}') == 1:
    147        yield '\n' + '  ' * level
    148      yield tok
    149      if tok in '[{':
    150        level += 1
    151      if tok == ',':
    152        yield '\n' + '  ' * level
    153      prev_tok = tok
    154 
    155  token_gen = GenerateTokens(value, 0)
    156  ret = ''.join((PrettyGlue if pretty else PlainGlue)(token_gen))
    157  # Add terminating '\n' for dict |value| or multi-line output.
    158  if isinstance(value, dict) or '\n' in ret:
    159    return ret + '\n'
    160  return ret
    161 
    162 
    163 def FromGNString(input_string):
    164  """Converts the input string from a GN serialized value to Python values.
    165 
    166  For details on supported types see GNValueParser.Parse() below.
    167 
    168  If your GN script did:
    169    something = [ "file1", "file2" ]
    170    args = [ "--values=$something" ]
    171  The command line would look something like:
    172    --values="[ \"file1\", \"file2\" ]"
    173  Which when interpreted as a command line gives the value:
    174    [ "file1", "file2" ]
    175 
    176  You can parse this into a Python list using GN rules with:
    177    input_values = FromGNValues(options.values)
    178  Although the Python 'ast' module will parse many forms of such input, it
    179  will not handle GN escaping properly, nor GN booleans. You should use this
    180  function instead.
    181 
    182 
    183  A NOTE ON STRING HANDLING:
    184 
    185  If you just pass a string on the command line to your Python script, or use
    186  string interpolation on a string variable, the strings will not be quoted:
    187    str = "asdf"
    188    args = [ str, "--value=$str" ]
    189  Will yield the command line:
    190    asdf --value=asdf
    191  The unquoted asdf string will not be valid input to this function, which
    192  accepts only quoted strings like GN scripts. In such cases, you can just use
    193  the Python string literal directly.
    194 
    195  The main use cases for this is for other types, in particular lists. When
    196  using string interpolation on a list (as in the top example) the embedded
    197  strings will be quoted and escaped according to GN rules so the list can be
    198  re-parsed to get the same result.
    199  """
    200  parser = GNValueParser(input_string)
    201  return parser.Parse()
    202 
    203 
    204 def FromGNArgs(input_string):
    205  """Converts a string with a bunch of gn arg assignments into a Python dict.
    206 
    207  Given a whitespace-separated list of
    208 
    209    <ident> = (integer | string | boolean | <list of the former>)
    210 
    211  gn assignments, this returns a Python dict, i.e.:
    212 
    213    FromGNArgs('foo=true\nbar=1\n') -> { 'foo': True, 'bar': 1 }.
    214 
    215  Only simple types and lists supported; variables, structs, calls
    216  and other, more complicated things are not.
    217 
    218  This routine is meant to handle only the simple sorts of values that
    219  arise in parsing --args.
    220  """
    221  parser = GNValueParser(input_string)
    222  return parser.ParseArgs()
    223 
    224 
    225 def UnescapeGNString(value):
    226  """Given a string with GN escaping, returns the unescaped string.
    227 
    228  Be careful not to feed with input from a Python parsing function like
    229  'ast' because it will do Python unescaping, which will be incorrect when
    230  fed into the GN unescaper.
    231 
    232  Args:
    233    value: Input string to unescape.
    234  """
    235  result = ''
    236  i = 0
    237  while i < len(value):
    238    if value[i] == '\\':
    239      if i < len(value) - 1:
    240        next_char = value[i + 1]
    241        if next_char in ('$', '"', '\\'):
    242          # These are the escaped characters GN supports.
    243          result += next_char
    244          i += 1
    245        else:
    246          # Any other backslash is a literal.
    247          result += '\\'
    248    else:
    249      result += value[i]
    250    i += 1
    251  return result
    252 
    253 
    254 def _IsDigitOrMinus(char):
    255  return char in '-0123456789'
    256 
    257 
    258 class GNValueParser(object):
    259  """Duplicates GN parsing of values and converts to Python types.
    260 
    261  Normally you would use the wrapper function FromGNValue() below.
    262 
    263  If you expect input as a specific type, you can also call one of the Parse*
    264  functions directly. All functions throw GNError on invalid input.
    265  """
    266 
    267  def __init__(self, string, checkout_root=_CHROMIUM_ROOT):
    268    self.input = string
    269    self.cur = 0
    270    self.checkout_root = checkout_root
    271 
    272  def IsDone(self):
    273    return self.cur == len(self.input)
    274 
    275  def ReplaceImports(self):
    276    """Replaces import(...) lines with the contents of the imports.
    277 
    278    Recurses on itself until there are no imports remaining, in the case of
    279    nested imports.
    280    """
    281    lines = self.input.splitlines()
    282    if not any(line.startswith('import(') for line in lines):
    283      return
    284    for line in lines:
    285      if not line.startswith('import('):
    286        continue
    287      regex_match = IMPORT_RE.match(line)
    288      if not regex_match:
    289        raise GNError('Not a valid import string: %s' % line)
    290      import_path = regex_match.group(1)
    291 
    292      if import_path.startswith("//"):
    293        import_path = os.path.join(self.checkout_root, import_path[2:])
    294      elif sys.platform.startswith('win32'):
    295        if import_path.startswith("/"):
    296          # gn users '/C:/path/to/foo.gn', not 'C:/path/to/foo.gn' on windows
    297          import_path = import_path[1:]
    298        else:
    299          raise GNError('Need /-prefix for an absolute path: %s' % import_path)
    300 
    301      if not os.path.isabs(import_path):
    302        raise GNError('Unable to use relative path in import path: %s' %
    303                      import_path)
    304      with open(import_path) as f:
    305        imported_args = f.read()
    306      self.input = self.input.replace(line, imported_args)
    307    # Call ourselves again if we've just replaced an import() with additional
    308    # imports.
    309    self.ReplaceImports()
    310 
    311 
    312  def _ConsumeWhitespace(self):
    313    while not self.IsDone() and self.input[self.cur] in ' \t\n':
    314      self.cur += 1
    315 
    316  def ConsumeCommentAndWhitespace(self):
    317    self._ConsumeWhitespace()
    318 
    319    # Consume each comment, line by line.
    320    while not self.IsDone() and self.input[self.cur] == '#':
    321      # Consume the rest of the comment, up until the end of the line.
    322      while not self.IsDone() and self.input[self.cur] != '\n':
    323        self.cur += 1
    324      # Move the cursor to the next line (if there is one).
    325      if not self.IsDone():
    326        self.cur += 1
    327 
    328      self._ConsumeWhitespace()
    329 
    330  def Parse(self):
    331    """Converts a string representing a printed GN value to the Python type.
    332 
    333    See additional usage notes on FromGNString() above.
    334 
    335    * GN booleans ('true', 'false') will be converted to Python booleans.
    336 
    337    * GN numbers ('123') will be converted to Python numbers.
    338 
    339    * GN strings (double-quoted as in '"asdf"') will be converted to Python
    340      strings with GN escaping rules. GN string interpolation (embedded
    341      variables preceded by $) are not supported and will be returned as
    342      literals.
    343 
    344    * GN lists ('[1, "asdf", 3]') will be converted to Python lists.
    345 
    346    * GN scopes ('{ ... }') are not supported.
    347 
    348    Raises:
    349      GNError: Parse fails.
    350    """
    351    result = self._ParseAllowTrailing()
    352    self.ConsumeCommentAndWhitespace()
    353    if not self.IsDone():
    354      raise GNError("Trailing input after parsing:\n  " + self.input[self.cur:])
    355    return result
    356 
    357  def ParseArgs(self):
    358    """Converts a whitespace-separated list of ident=literals to a dict.
    359 
    360    See additional usage notes on FromGNArgs(), above.
    361 
    362    Raises:
    363      GNError: Parse fails.
    364    """
    365    d = {}
    366 
    367    self.ReplaceImports()
    368    self.ConsumeCommentAndWhitespace()
    369 
    370    while not self.IsDone():
    371      ident = self._ParseIdent()
    372      self.ConsumeCommentAndWhitespace()
    373      if self.input[self.cur] != '=':
    374        raise GNError("Unexpected token: " + self.input[self.cur:])
    375      self.cur += 1
    376      self.ConsumeCommentAndWhitespace()
    377      val = self._ParseAllowTrailing()
    378      self.ConsumeCommentAndWhitespace()
    379      d[ident] = val
    380 
    381    return d
    382 
    383  def _ParseAllowTrailing(self):
    384    """Internal version of Parse() that doesn't check for trailing stuff."""
    385    self.ConsumeCommentAndWhitespace()
    386    if self.IsDone():
    387      raise GNError("Expected input to parse.")
    388 
    389    next_char = self.input[self.cur]
    390    if next_char == '[':
    391      return self.ParseList()
    392    elif next_char == '{':
    393      return self.ParseScope()
    394    elif _IsDigitOrMinus(next_char):
    395      return self.ParseNumber()
    396    elif next_char == '"':
    397      return self.ParseString()
    398    elif self._ConstantFollows('true'):
    399      return True
    400    elif self._ConstantFollows('false'):
    401      return False
    402    else:
    403      raise GNError("Unexpected token: " + self.input[self.cur:])
    404 
    405  def _ParseIdent(self):
    406    ident = ''
    407 
    408    next_char = self.input[self.cur]
    409    if not next_char.isalpha() and not next_char=='_':
    410      raise GNError("Expected an identifier: " + self.input[self.cur:])
    411 
    412    ident += next_char
    413    self.cur += 1
    414 
    415    next_char = self.input[self.cur]
    416    while next_char.isalpha() or next_char.isdigit() or next_char=='_':
    417      ident += next_char
    418      self.cur += 1
    419      next_char = self.input[self.cur]
    420 
    421    return ident
    422 
    423  def ParseNumber(self):
    424    self.ConsumeCommentAndWhitespace()
    425    if self.IsDone():
    426      raise GNError('Expected number but got nothing.')
    427 
    428    begin = self.cur
    429 
    430    # The first character can include a negative sign.
    431    if not self.IsDone() and _IsDigitOrMinus(self.input[self.cur]):
    432      self.cur += 1
    433    while not self.IsDone() and self.input[self.cur].isdigit():
    434      self.cur += 1
    435 
    436    number_string = self.input[begin:self.cur]
    437    if not len(number_string) or number_string == '-':
    438      raise GNError('Not a valid number.')
    439    return int(number_string)
    440 
    441  def ParseString(self):
    442    self.ConsumeCommentAndWhitespace()
    443    if self.IsDone():
    444      raise GNError('Expected string but got nothing.')
    445 
    446    if self.input[self.cur] != '"':
    447      raise GNError('Expected string beginning in a " but got:\n  ' +
    448                    self.input[self.cur:])
    449    self.cur += 1  # Skip over quote.
    450 
    451    begin = self.cur
    452    while not self.IsDone() and self.input[self.cur] != '"':
    453      if self.input[self.cur] == '\\':
    454        self.cur += 1  # Skip over the backslash.
    455        if self.IsDone():
    456          raise GNError('String ends in a backslash in:\n  ' + self.input)
    457      self.cur += 1
    458 
    459    if self.IsDone():
    460      raise GNError('Unterminated string:\n  ' + self.input[begin:])
    461 
    462    end = self.cur
    463    self.cur += 1  # Consume trailing ".
    464 
    465    return UnescapeGNString(self.input[begin:end])
    466 
    467  def ParseList(self):
    468    self.ConsumeCommentAndWhitespace()
    469    if self.IsDone():
    470      raise GNError('Expected list but got nothing.')
    471 
    472    # Skip over opening '['.
    473    if self.input[self.cur] != '[':
    474      raise GNError('Expected [ for list but got:\n  ' + self.input[self.cur:])
    475    self.cur += 1
    476    self.ConsumeCommentAndWhitespace()
    477    if self.IsDone():
    478      raise GNError('Unterminated list:\n  ' + self.input)
    479 
    480    list_result = []
    481    previous_had_trailing_comma = True
    482    while not self.IsDone():
    483      if self.input[self.cur] == ']':
    484        self.cur += 1  # Skip over ']'.
    485        return list_result
    486 
    487      if not previous_had_trailing_comma:
    488        raise GNError('List items not separated by comma.')
    489 
    490      list_result += [ self._ParseAllowTrailing() ]
    491      self.ConsumeCommentAndWhitespace()
    492      if self.IsDone():
    493        break
    494 
    495      # Consume comma if there is one.
    496      previous_had_trailing_comma = self.input[self.cur] == ','
    497      if previous_had_trailing_comma:
    498        # Consume comma.
    499        self.cur += 1
    500        self.ConsumeCommentAndWhitespace()
    501 
    502    raise GNError('Unterminated list:\n  ' + self.input)
    503 
    504  def ParseScope(self):
    505    self.ConsumeCommentAndWhitespace()
    506    if self.IsDone():
    507      raise GNError('Expected scope but got nothing.')
    508 
    509    # Skip over opening '{'.
    510    if self.input[self.cur] != '{':
    511      raise GNError('Expected { for scope but got:\n ' + self.input[self.cur:])
    512    self.cur += 1
    513    self.ConsumeCommentAndWhitespace()
    514    if self.IsDone():
    515      raise GNError('Unterminated scope:\n ' + self.input)
    516 
    517    scope_result = {}
    518    while not self.IsDone():
    519      if self.input[self.cur] == '}':
    520        self.cur += 1
    521        return scope_result
    522 
    523      ident = self._ParseIdent()
    524      self.ConsumeCommentAndWhitespace()
    525      if self.input[self.cur] != '=':
    526        raise GNError("Unexpected token: " + self.input[self.cur:])
    527      self.cur += 1
    528      self.ConsumeCommentAndWhitespace()
    529      val = self._ParseAllowTrailing()
    530      self.ConsumeCommentAndWhitespace()
    531      scope_result[ident] = val
    532 
    533    raise GNError('Unterminated scope:\n ' + self.input)
    534 
    535  def _ConstantFollows(self, constant):
    536    """Checks and maybe consumes a string constant at current input location.
    537 
    538    Param:
    539      constant: The string constant to check.
    540 
    541    Returns:
    542      True if |constant| follows immediately at the current location in the
    543      input. In this case, the string is consumed as a side effect. Otherwise,
    544      returns False and the current position is unchanged.
    545    """
    546    end = self.cur + len(constant)
    547    if end > len(self.input):
    548      return False  # Not enough room.
    549    if self.input[self.cur:end] == constant:
    550      self.cur = end
    551      return True
    552    return False
    553 
    554 
    555 def ReadBuildVars(output_directory):
    556  """Parses $output_directory/build_vars.json into a dict."""
    557  with open(os.path.join(output_directory, BUILD_VARS_FILENAME)) as f:
    558    return json.load(f)
    559 
    560 
    561 def ReadArgsGN(output_directory):
    562  """Parses $output_directory/args.gn into a dict."""
    563  fname = os.path.join(output_directory, ARGS_GN_FILENAME)
    564  if not os.path.exists(fname):
    565    return {}
    566  with open(fname) as f:
    567    return FromGNArgs(f.read())
    568 
    569 
    570 def CreateBuildCommand(output_directory):
    571  """Returns [cmd, -C, output_directory].
    572 
    573  Where |cmd| is one of: siso ninja, ninja, or autoninja.
    574  """
    575  suffix = '.bat' if sys.platform.startswith('win32') else ''
    576  # Prefer the version on PATH, but fallback to known version if PATH doesn't
    577  # have one (e.g. on bots).
    578  if not shutil.which(f'autoninja{suffix}'):
    579    third_party_prefix = os.path.join(_CHROMIUM_ROOT, 'third_party')
    580    ninja_prefix = os.path.join(third_party_prefix, 'ninja', '')
    581    siso_prefix = os.path.join(third_party_prefix, 'siso', 'cipd', '')
    582    # Also - bots configure reclient manually, and so do not use the "auto"
    583    # wrappers.
    584    ninja_cmd = [f'{ninja_prefix}ninja{suffix}']
    585    siso_cmd = [f'{siso_prefix}siso{suffix}', 'ninja']
    586  else:
    587    ninja_cmd = [f'autoninja{suffix}']
    588    siso_cmd = list(ninja_cmd)
    589 
    590  if output_directory and os.path.abspath(output_directory) != os.path.abspath(
    591      os.curdir):
    592    ninja_cmd += ['-C', output_directory]
    593    siso_cmd += ['-C', output_directory]
    594  siso_deps = os.path.exists(os.path.join(output_directory, '.siso_deps'))
    595  ninja_deps = os.path.exists(os.path.join(output_directory, '.ninja_deps'))
    596  if siso_deps and ninja_deps:
    597    raise Exception('Found both .siso_deps and .ninja_deps in '
    598                    f'{output_directory}. Not sure which build tool to use. '
    599                    'Please delete one, or better, run "gn clean".')
    600  if siso_deps:
    601    return siso_cmd
    602  return ninja_cmd