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