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