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