xref: /aosp_15_r20/external/cronet/build/gn_helpers.py (revision 6777b5387eb2ff775bb5750e3f5d96f37fb7352b)
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
7The main functions are ToGNString() and FromGNString(), to convert between
8serialized GN veriables and Python variables.
9
10To 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
19Where the sequence of parameters to join is the relative path from your source
20file to the build directory.
21"""
22
23import json
24import os
25import re
26import shlex
27import shutil
28import sys
29
30
31_CHROMIUM_ROOT = os.path.abspath(
32    os.path.join(os.path.dirname(__file__), os.pardir))
33
34BUILD_VARS_FILENAME = 'build_vars.json'
35IMPORT_RE = re.compile(r'^import\("//(\S+)"\)')
36
37
38class 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
46def _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
57def 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
163def 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
204def 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
225def 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
254def _IsDigitOrMinus(char):
255  return char in '-0123456789'
256
257
258class 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 = os.path.join(self.checkout_root, regex_match.group(1))
291      with open(import_path) as f:
292        imported_args = f.read()
293      self.input = self.input.replace(line, imported_args)
294    # Call ourselves again if we've just replaced an import() with additional
295    # imports.
296    self.ReplaceImports()
297
298
299  def _ConsumeWhitespace(self):
300    while not self.IsDone() and self.input[self.cur] in ' \t\n':
301      self.cur += 1
302
303  def ConsumeCommentAndWhitespace(self):
304    self._ConsumeWhitespace()
305
306    # Consume each comment, line by line.
307    while not self.IsDone() and self.input[self.cur] == '#':
308      # Consume the rest of the comment, up until the end of the line.
309      while not self.IsDone() and self.input[self.cur] != '\n':
310        self.cur += 1
311      # Move the cursor to the next line (if there is one).
312      if not self.IsDone():
313        self.cur += 1
314
315      self._ConsumeWhitespace()
316
317  def Parse(self):
318    """Converts a string representing a printed GN value to the Python type.
319
320    See additional usage notes on FromGNString() above.
321
322    * GN booleans ('true', 'false') will be converted to Python booleans.
323
324    * GN numbers ('123') will be converted to Python numbers.
325
326    * GN strings (double-quoted as in '"asdf"') will be converted to Python
327      strings with GN escaping rules. GN string interpolation (embedded
328      variables preceded by $) are not supported and will be returned as
329      literals.
330
331    * GN lists ('[1, "asdf", 3]') will be converted to Python lists.
332
333    * GN scopes ('{ ... }') are not supported.
334
335    Raises:
336      GNError: Parse fails.
337    """
338    result = self._ParseAllowTrailing()
339    self.ConsumeCommentAndWhitespace()
340    if not self.IsDone():
341      raise GNError("Trailing input after parsing:\n  " + self.input[self.cur:])
342    return result
343
344  def ParseArgs(self):
345    """Converts a whitespace-separated list of ident=literals to a dict.
346
347    See additional usage notes on FromGNArgs(), above.
348
349    Raises:
350      GNError: Parse fails.
351    """
352    d = {}
353
354    self.ReplaceImports()
355    self.ConsumeCommentAndWhitespace()
356
357    while not self.IsDone():
358      ident = self._ParseIdent()
359      self.ConsumeCommentAndWhitespace()
360      if self.input[self.cur] != '=':
361        raise GNError("Unexpected token: " + self.input[self.cur:])
362      self.cur += 1
363      self.ConsumeCommentAndWhitespace()
364      val = self._ParseAllowTrailing()
365      self.ConsumeCommentAndWhitespace()
366      d[ident] = val
367
368    return d
369
370  def _ParseAllowTrailing(self):
371    """Internal version of Parse() that doesn't check for trailing stuff."""
372    self.ConsumeCommentAndWhitespace()
373    if self.IsDone():
374      raise GNError("Expected input to parse.")
375
376    next_char = self.input[self.cur]
377    if next_char == '[':
378      return self.ParseList()
379    elif next_char == '{':
380      return self.ParseScope()
381    elif _IsDigitOrMinus(next_char):
382      return self.ParseNumber()
383    elif next_char == '"':
384      return self.ParseString()
385    elif self._ConstantFollows('true'):
386      return True
387    elif self._ConstantFollows('false'):
388      return False
389    else:
390      raise GNError("Unexpected token: " + self.input[self.cur:])
391
392  def _ParseIdent(self):
393    ident = ''
394
395    next_char = self.input[self.cur]
396    if not next_char.isalpha() and not next_char=='_':
397      raise GNError("Expected an identifier: " + self.input[self.cur:])
398
399    ident += next_char
400    self.cur += 1
401
402    next_char = self.input[self.cur]
403    while next_char.isalpha() or next_char.isdigit() or next_char=='_':
404      ident += next_char
405      self.cur += 1
406      next_char = self.input[self.cur]
407
408    return ident
409
410  def ParseNumber(self):
411    self.ConsumeCommentAndWhitespace()
412    if self.IsDone():
413      raise GNError('Expected number but got nothing.')
414
415    begin = self.cur
416
417    # The first character can include a negative sign.
418    if not self.IsDone() and _IsDigitOrMinus(self.input[self.cur]):
419      self.cur += 1
420    while not self.IsDone() and self.input[self.cur].isdigit():
421      self.cur += 1
422
423    number_string = self.input[begin:self.cur]
424    if not len(number_string) or number_string == '-':
425      raise GNError('Not a valid number.')
426    return int(number_string)
427
428  def ParseString(self):
429    self.ConsumeCommentAndWhitespace()
430    if self.IsDone():
431      raise GNError('Expected string but got nothing.')
432
433    if self.input[self.cur] != '"':
434      raise GNError('Expected string beginning in a " but got:\n  ' +
435                    self.input[self.cur:])
436    self.cur += 1  # Skip over quote.
437
438    begin = self.cur
439    while not self.IsDone() and self.input[self.cur] != '"':
440      if self.input[self.cur] == '\\':
441        self.cur += 1  # Skip over the backslash.
442        if self.IsDone():
443          raise GNError('String ends in a backslash in:\n  ' + self.input)
444      self.cur += 1
445
446    if self.IsDone():
447      raise GNError('Unterminated string:\n  ' + self.input[begin:])
448
449    end = self.cur
450    self.cur += 1  # Consume trailing ".
451
452    return UnescapeGNString(self.input[begin:end])
453
454  def ParseList(self):
455    self.ConsumeCommentAndWhitespace()
456    if self.IsDone():
457      raise GNError('Expected list but got nothing.')
458
459    # Skip over opening '['.
460    if self.input[self.cur] != '[':
461      raise GNError('Expected [ for list but got:\n  ' + self.input[self.cur:])
462    self.cur += 1
463    self.ConsumeCommentAndWhitespace()
464    if self.IsDone():
465      raise GNError('Unterminated list:\n  ' + self.input)
466
467    list_result = []
468    previous_had_trailing_comma = True
469    while not self.IsDone():
470      if self.input[self.cur] == ']':
471        self.cur += 1  # Skip over ']'.
472        return list_result
473
474      if not previous_had_trailing_comma:
475        raise GNError('List items not separated by comma.')
476
477      list_result += [ self._ParseAllowTrailing() ]
478      self.ConsumeCommentAndWhitespace()
479      if self.IsDone():
480        break
481
482      # Consume comma if there is one.
483      previous_had_trailing_comma = self.input[self.cur] == ','
484      if previous_had_trailing_comma:
485        # Consume comma.
486        self.cur += 1
487        self.ConsumeCommentAndWhitespace()
488
489    raise GNError('Unterminated list:\n  ' + self.input)
490
491  def ParseScope(self):
492    self.ConsumeCommentAndWhitespace()
493    if self.IsDone():
494      raise GNError('Expected scope but got nothing.')
495
496    # Skip over opening '{'.
497    if self.input[self.cur] != '{':
498      raise GNError('Expected { for scope but got:\n ' + self.input[self.cur:])
499    self.cur += 1
500    self.ConsumeCommentAndWhitespace()
501    if self.IsDone():
502      raise GNError('Unterminated scope:\n ' + self.input)
503
504    scope_result = {}
505    while not self.IsDone():
506      if self.input[self.cur] == '}':
507        self.cur += 1
508        return scope_result
509
510      ident = self._ParseIdent()
511      self.ConsumeCommentAndWhitespace()
512      if self.input[self.cur] != '=':
513        raise GNError("Unexpected token: " + self.input[self.cur:])
514      self.cur += 1
515      self.ConsumeCommentAndWhitespace()
516      val = self._ParseAllowTrailing()
517      self.ConsumeCommentAndWhitespace()
518      scope_result[ident] = val
519
520    raise GNError('Unterminated scope:\n ' + self.input)
521
522  def _ConstantFollows(self, constant):
523    """Checks and maybe consumes a string constant at current input location.
524
525    Param:
526      constant: The string constant to check.
527
528    Returns:
529      True if |constant| follows immediately at the current location in the
530      input. In this case, the string is consumed as a side effect. Otherwise,
531      returns False and the current position is unchanged.
532    """
533    end = self.cur + len(constant)
534    if end > len(self.input):
535      return False  # Not enough room.
536    if self.input[self.cur:end] == constant:
537      self.cur = end
538      return True
539    return False
540
541
542def ReadBuildVars(output_directory):
543  """Parses $output_directory/build_vars.json into a dict."""
544  with open(os.path.join(output_directory, BUILD_VARS_FILENAME)) as f:
545    return json.load(f)
546
547
548def CreateBuildCommand(output_directory):
549  """Returns [cmd, -C, output_directory].
550
551  Where |cmd| is one of: siso ninja, ninja, or autoninja.
552  """
553  suffix = '.bat' if sys.platform.startswith('win32') else ''
554  # Prefer the version on PATH, but fallback to known version if PATH doesn't
555  # have one (e.g. on bots).
556  if not shutil.which(f'autoninja{suffix}'):
557    third_party_prefix = os.path.join(_CHROMIUM_ROOT, 'third_party')
558    ninja_prefix = os.path.join(third_party_prefix, 'ninja', '')
559    siso_prefix = os.path.join(third_party_prefix, 'siso', '')
560    # Also - bots configure reclient manually, and so do not use the "auto"
561    # wrappers.
562    ninja_cmd = [f'{ninja_prefix}ninja{suffix}']
563    siso_cmd = [f'{siso_prefix}siso{suffix}', 'ninja']
564  else:
565    ninja_cmd = [f'autoninja{suffix}']
566    siso_cmd = list(ninja_cmd)
567
568  if output_directory and os.path.relpath(output_directory) != '.':
569    ninja_cmd += ['-C', output_directory]
570    siso_cmd += ['-C', output_directory]
571  siso_deps = os.path.exists(os.path.join(output_directory, '.siso_deps'))
572  ninja_deps = os.path.exists(os.path.join(output_directory, '.ninja_deps'))
573  if siso_deps and ninja_deps:
574    raise Exception('Found both .siso_deps and .ninja_deps in '
575                    f'{output_directory}. Not sure which build tool to use. '
576                    'Please delete one, or better, run "gn clean".')
577  if siso_deps:
578    return siso_cmd
579  return ninja_cmd
580