OLD | NEW |
(Empty) | |
| 1 #!/usr/bin/env python |
| 2 # Copyright 2015 The Chromium Authors. All rights reserved. |
| 3 # Use of this source code is governed by a BSD-style license that can be |
| 4 # found in the LICENSE file. |
| 5 |
| 6 """MB - the Meta-Build wrapper around GYP and GN |
| 7 |
| 8 MB is a wrapper script for GYP and GN that can be used to generate build files |
| 9 for sets of canned configurations and analyze them. |
| 10 """ |
| 11 |
| 12 from __future__ import print_function |
| 13 |
| 14 import argparse |
| 15 import ast |
| 16 import json |
| 17 import os |
| 18 import pipes |
| 19 import shlex |
| 20 import shutil |
| 21 import sys |
| 22 import subprocess |
| 23 |
| 24 |
| 25 def main(args): |
| 26 mb = MetaBuildWrapper() |
| 27 mb.ParseArgs(args) |
| 28 return mb.args.func() |
| 29 |
| 30 |
| 31 class MetaBuildWrapper(object): |
| 32 def __init__(self): |
| 33 p = os.path |
| 34 d = os.path.dirname |
| 35 self.chromium_src_dir = p.normpath(d(d(d(p.abspath(__file__))))) |
| 36 self.default_config = p.join(self.chromium_src_dir, 'tools', 'mb', |
| 37 'mb_config.pyl') |
| 38 self.args = argparse.Namespace() |
| 39 self.configs = {} |
| 40 self.masters = {} |
| 41 self.mixins = {} |
| 42 self.private_configs = [] |
| 43 self.common_dev_configs = [] |
| 44 self.unsupported_configs = [] |
| 45 |
| 46 def ParseArgs(self, argv): |
| 47 def AddCommonOptions(subp): |
| 48 subp.add_argument('-b', '--builder', |
| 49 help='builder name to look up config from') |
| 50 subp.add_argument('-m', '--master', |
| 51 help='master name to look up config from') |
| 52 subp.add_argument('-c', '--config', |
| 53 help='configuration to analyze') |
| 54 subp.add_argument('-f', '--config-file', metavar='PATH', |
| 55 default=self.default_config, |
| 56 help='path to config file ' |
| 57 '(default is //tools/mb/mb_config.pyl)') |
| 58 subp.add_argument('-g', '--goma-dir', default=self.ExpandUser('~/goma'), |
| 59 help='path to goma directory (default is %(default)s).') |
| 60 subp.add_argument('-n', '--dryrun', action='store_true', |
| 61 help='Do a dry run (i.e., do nothing, just print ' |
| 62 'the commands that will run)') |
| 63 subp.add_argument('-q', '--quiet', action='store_true', |
| 64 help='Do not print anything, just return an exit ' |
| 65 'code.') |
| 66 subp.add_argument('-v', '--verbose', action='count', |
| 67 help='verbose logging (may specify multiple times).') |
| 68 |
| 69 parser = argparse.ArgumentParser(prog='mb') |
| 70 subps = parser.add_subparsers() |
| 71 |
| 72 subp = subps.add_parser('analyze', |
| 73 help='analyze whether changes to a set of files ' |
| 74 'will cause a set of binaries to be rebuilt.') |
| 75 AddCommonOptions(subp) |
| 76 subp.add_argument('path', type=str, nargs=1, |
| 77 help='path build was generated into.') |
| 78 subp.add_argument('input_path', nargs=1, |
| 79 help='path to a file containing the input arguments ' |
| 80 'as a JSON object.') |
| 81 subp.add_argument('output_path', nargs=1, |
| 82 help='path to a file containing the output arguments ' |
| 83 'as a JSON object.') |
| 84 subp.set_defaults(func=self.CmdAnalyze) |
| 85 |
| 86 subp = subps.add_parser('gen', |
| 87 help='generate a new set of build files') |
| 88 AddCommonOptions(subp) |
| 89 subp.add_argument('path', type=str, nargs=1, |
| 90 help='path to generate build into') |
| 91 subp.set_defaults(func=self.CmdGen) |
| 92 |
| 93 subp = subps.add_parser('lookup', |
| 94 help='look up the command for a given config or ' |
| 95 'builder') |
| 96 AddCommonOptions(subp) |
| 97 subp.set_defaults(func=self.CmdLookup) |
| 98 |
| 99 subp = subps.add_parser('validate', |
| 100 help='validate the config file') |
| 101 AddCommonOptions(subp) |
| 102 subp.set_defaults(func=self.CmdValidate) |
| 103 |
| 104 subp = subps.add_parser('help', |
| 105 help='Get help on a subcommand.') |
| 106 subp.add_argument(nargs='?', action='store', dest='subcommand', |
| 107 help='The command to get help for.') |
| 108 subp.set_defaults(func=self.CmdHelp) |
| 109 |
| 110 self.args = parser.parse_args(argv) |
| 111 |
| 112 def CmdAnalyze(self): |
| 113 vals = self.GetConfig() |
| 114 if vals['type'] == 'gn': |
| 115 return self.RunGNAnalyze(vals) |
| 116 elif vals['type'] == 'gyp': |
| 117 return self.RunGYPAnalyze(vals) |
| 118 else: |
| 119 raise MBErr('Unknown meta-build type "%s"' % vals['type']) |
| 120 |
| 121 def CmdGen(self): |
| 122 vals = self.GetConfig() |
| 123 if vals['type'] == 'gn': |
| 124 self.RunGNGen(self.args.path[0], vals) |
| 125 elif vals['type'] == 'gyp': |
| 126 self.RunGYPGen(self.args.path[0], vals) |
| 127 else: |
| 128 raise MBErr('Unknown meta-build type "%s"' % vals['type']) |
| 129 return 0 |
| 130 |
| 131 def CmdLookup(self): |
| 132 vals = self.GetConfig() |
| 133 if vals['type'] == 'gn': |
| 134 cmd = self.GNCmd('<path>', vals['gn_args']) |
| 135 elif vals['type'] == 'gyp': |
| 136 cmd = self.GYPCmd('<path>', vals['gyp_defines'], vals['gyp_config']) |
| 137 else: |
| 138 raise MBErr('Unknown meta-build type "%s"' % vals['type']) |
| 139 |
| 140 self.PrintCmd(cmd) |
| 141 return 0 |
| 142 |
| 143 def CmdHelp(self): |
| 144 if self.args.subcommand: |
| 145 self.ParseArgs([self.args.subcommand, '--help']) |
| 146 else: |
| 147 self.ParseArgs(['--help']) |
| 148 |
| 149 def CmdValidate(self): |
| 150 errs = [] |
| 151 |
| 152 # Read the file to make sure it parses. |
| 153 self.ReadConfigFile() |
| 154 |
| 155 # Figure out the whole list of configs and ensure that no config is |
| 156 # listed in more than one category. |
| 157 all_configs = {} |
| 158 for config in self.common_dev_configs: |
| 159 all_configs[config] = 'common_dev_configs' |
| 160 for config in self.private_configs: |
| 161 if config in all_configs: |
| 162 errs.append('config "%s" listed in "private_configs" also ' |
| 163 'listed in "%s"' % (config, all_configs['config'])) |
| 164 else: |
| 165 all_configs[config] = 'private_configs' |
| 166 for config in self.unsupported_configs: |
| 167 if config in all_configs: |
| 168 errs.append('config "%s" listed in "unsupported_configs" also ' |
| 169 'listed in "%s"' % (config, all_configs['config'])) |
| 170 else: |
| 171 all_configs[config] = 'unsupported_configs' |
| 172 |
| 173 for master in self.masters: |
| 174 for builder in self.masters[master]: |
| 175 config = self.masters[master][builder] |
| 176 if config in all_configs and all_configs[config] not in self.masters: |
| 177 errs.append('Config "%s" used by a bot is also listed in "%s".' % |
| 178 (config, all_configs[config])) |
| 179 else: |
| 180 all_configs[config] = master |
| 181 |
| 182 # Check that every referenced config actually exists. |
| 183 for config, loc in all_configs.items(): |
| 184 if not config in self.configs: |
| 185 errs.append('Unknown config "%s" referenced from "%s".' % |
| 186 (config, loc)) |
| 187 |
| 188 # Check that every actual config is actually referenced. |
| 189 for config in self.configs: |
| 190 if not config in all_configs: |
| 191 errs.append('Unused config "%s".' % config) |
| 192 |
| 193 # Figure out the whole list of mixins, and check that every mixin |
| 194 # listed by a config or another mixin actually exists. |
| 195 referenced_mixins = set() |
| 196 for config, mixins in self.configs.items(): |
| 197 for mixin in mixins: |
| 198 if not mixin in self.mixins: |
| 199 errs.append('Unknown mixin "%s" referenced by config "%s".' % |
| 200 (mixin, config)) |
| 201 referenced_mixins.add(mixin) |
| 202 |
| 203 for mixin in self.mixins: |
| 204 for sub_mixin in self.mixins[mixin].get('mixins', []): |
| 205 if not sub_mixin in self.mixins: |
| 206 errs.append('Unknown mixin "%s" referenced by mixin "%s".' % |
| 207 (sub_mixin, mixin)) |
| 208 referenced_mixins.add(sub_mixin) |
| 209 |
| 210 # Check that every mixin defined is actually referenced somewhere. |
| 211 for mixin in self.mixins: |
| 212 if not mixin in referenced_mixins: |
| 213 errs.append('Unreferenced mixin "%s".' % mixin) |
| 214 |
| 215 if errs: |
| 216 raise MBErr('mb config file %s has problems:\n ' + '\n '.join(errs)) |
| 217 |
| 218 if not self.args.quiet: |
| 219 self.Print('mb config file %s looks ok.' % self.args.config_file) |
| 220 return 0 |
| 221 |
| 222 def GetConfig(self): |
| 223 self.ReadConfigFile() |
| 224 config = self.ConfigFromArgs() |
| 225 if not config in self.configs: |
| 226 raise MBErr('Config "%s" not found in %s' % |
| 227 (config, self.args.config_file)) |
| 228 |
| 229 return self.FlattenConfig(config) |
| 230 |
| 231 def ReadConfigFile(self): |
| 232 if not self.Exists(self.args.config_file): |
| 233 raise MBErr('config file not found at %s' % self.args.config_file) |
| 234 |
| 235 try: |
| 236 contents = ast.literal_eval(self.ReadFile(self.args.config_file)) |
| 237 except SyntaxError as e: |
| 238 raise MBErr('Failed to parse config file "%s": %s' % |
| 239 (self.args.config_file, e)) |
| 240 |
| 241 self.common_dev_configs = contents['common_dev_configs'] |
| 242 self.configs = contents['configs'] |
| 243 self.masters = contents['masters'] |
| 244 self.mixins = contents['mixins'] |
| 245 self.private_configs = contents['private_configs'] |
| 246 self.unsupported_configs = contents['unsupported_configs'] |
| 247 |
| 248 def ConfigFromArgs(self): |
| 249 if self.args.config: |
| 250 if self.args.master or self.args.builder: |
| 251 raise MBErr('Can not specific both -c/--config and -m/--master or ' |
| 252 '-b/--builder') |
| 253 |
| 254 return self.args.config |
| 255 |
| 256 if not self.args.master or not self.args.builder: |
| 257 raise MBErr('Must specify either -c/--config or ' |
| 258 '(-m/--master and -b/--builder)') |
| 259 |
| 260 if not self.args.master in self.masters: |
| 261 raise MBErr('Master name "%s" not found in "%s"' % |
| 262 (self.args.master, self.args.config_file)) |
| 263 |
| 264 if not self.args.builder in self.masters[self.args.master]: |
| 265 raise MBErr('Builder name "%s" not found under masters[%s] in "%s"' % |
| 266 (self.args.builder, self.args.master, self.args.config_file)) |
| 267 |
| 268 return self.masters[self.args.master][self.args.builder] |
| 269 |
| 270 def FlattenConfig(self, config): |
| 271 mixins = self.configs[config] |
| 272 vals = { |
| 273 'type': None, |
| 274 'gn_args': [], |
| 275 'gyp_config': [], |
| 276 'gyp_defines': [], |
| 277 } |
| 278 |
| 279 visited = [] |
| 280 self.FlattenMixins(mixins, vals, visited) |
| 281 return vals |
| 282 |
| 283 def FlattenMixins(self, mixins, vals, visited): |
| 284 for m in mixins: |
| 285 if m not in self.mixins: |
| 286 raise MBErr('Unknown mixin "%s"' % m) |
| 287 if m in visited: |
| 288 raise MBErr('Cycle in mixins for "%s": %s' % (m, visited)) |
| 289 |
| 290 visited.append(m) |
| 291 |
| 292 mixin_vals = self.mixins[m] |
| 293 if 'type' in mixin_vals: |
| 294 vals['type'] = mixin_vals['type'] |
| 295 if 'gn_args' in mixin_vals: |
| 296 if vals['gn_args']: |
| 297 vals['gn_args'] += ' ' + mixin_vals['gn_args'] |
| 298 else: |
| 299 vals['gn_args'] = mixin_vals['gn_args'] |
| 300 if 'gyp_config' in mixin_vals: |
| 301 vals['gyp_config'] = mixin_vals['gyp_config'] |
| 302 if 'gyp_defines' in mixin_vals: |
| 303 if vals['gyp_defines']: |
| 304 vals['gyp_defines'] += ' ' + mixin_vals['gyp_defines'] |
| 305 else: |
| 306 vals['gyp_defines'] = mixin_vals['gyp_defines'] |
| 307 if 'mixins' in mixin_vals: |
| 308 self.FlattenMixins(mixin_vals['mixins'], vals, visited) |
| 309 return vals |
| 310 |
| 311 def RunGNGen(self, path, vals): |
| 312 cmd = self.GNCmd(path, vals['gn_args']) |
| 313 ret, _, _ = self.Run(cmd) |
| 314 return ret |
| 315 |
| 316 def GNCmd(self, path, gn_args): |
| 317 # TODO(dpranke): Find gn explicitly in the path ... |
| 318 cmd = ['gn', 'gen', path] |
| 319 gn_args = gn_args.replace("$(goma_dir)", gn_args) |
| 320 if gn_args: |
| 321 cmd.append('--args=%s' % gn_args) |
| 322 return cmd |
| 323 |
| 324 def RunGYPGen(self, path, vals): |
| 325 output_dir, gyp_config = self.ParseGYPConfigPath(path) |
| 326 if gyp_config != vals['gyp_config']: |
| 327 raise MBErr('The last component of the path (%s) must match the ' |
| 328 'GYP configuration specified in the config (%s), and ' |
| 329 'it does not.' % (gyp_config, vals['gyp_config'])) |
| 330 cmd = self.GYPCmd(output_dir, vals['gyp_defines'], config=gyp_config) |
| 331 ret, _, _ = self.Run(cmd) |
| 332 return ret |
| 333 |
| 334 def RunGYPAnalyze(self, vals): |
| 335 output_dir, gyp_config = self.ParseGYPConfigPath(self.args.path[0]) |
| 336 if gyp_config != vals['gyp_config']: |
| 337 raise MBErr('The last component of the path (%s) must match the ' |
| 338 'GYP configuration specified in the config (%s), and ' |
| 339 'it does not.' % (gyp_config, vals['gyp_config'])) |
| 340 cmd = self.GYPCmd(output_dir, vals['gyp_defines'], config=gyp_config) |
| 341 cmd.extend(['-G', 'config_path=%s' % self.args.input_path[0], |
| 342 '-G', 'analyzer_output_path=%s' % self.args.output_path[0]]) |
| 343 ret, _, _ = self.Run(cmd) |
| 344 return ret |
| 345 |
| 346 def ParseGYPOutputPath(self, path): |
| 347 assert(path.startswith('//')) |
| 348 return path[2:] |
| 349 |
| 350 def ParseGYPConfigPath(self, path): |
| 351 assert(path.startswith('//')) |
| 352 output_dir, _, config = path[2:].rpartition('/') |
| 353 self.CheckGYPConfigIsSupported(config, path) |
| 354 return output_dir, config |
| 355 |
| 356 def CheckGYPConfigIsSupported(self, config, path): |
| 357 if config not in ('Debug', 'Release'): |
| 358 if (sys.platform in ('win32', 'cygwin') and |
| 359 config not in ('Debug_x64', 'Release_x64')): |
| 360 raise MBErr('Unknown or unsupported config type "%s" in "%s"' % |
| 361 config, path) |
| 362 |
| 363 def GYPCmd(self, output_dir, gyp_defines, config): |
| 364 gyp_defines = gyp_defines.replace("$(goma_dir)", self.args.goma_dir) |
| 365 cmd = [ |
| 366 sys.executable, |
| 367 os.path.join('build', 'gyp_chromium'), |
| 368 '-G', |
| 369 'output_dir=' + output_dir, |
| 370 '-G', |
| 371 'config=' + config, |
| 372 ] |
| 373 for d in shlex.split(gyp_defines): |
| 374 cmd += ['-D', d] |
| 375 return cmd |
| 376 |
| 377 def RunGNAnalyze(self, _vals): |
| 378 inp = self.GetAnalyzeInput() |
| 379 |
| 380 # Bail out early if a GN file was modified, since 'gn refs' won't know |
| 381 # what to do about it. |
| 382 if any(f.endswith('.gn') or f.endswith('.gni') for f in inp['files']): |
| 383 self.WriteJSONOutput({'status': 'Found dependency (all)'}) |
| 384 return 0 |
| 385 |
| 386 # TODO: Break long lists of files that might exceed the max command line |
| 387 # up into chunks so that we can return more accurate info. |
| 388 if len(' '.join(inp['files'])) > 1024: |
| 389 self.WriteJSONOutput({'status': 'Found dependency (all)'}) |
| 390 return 0 |
| 391 |
| 392 cmd = (['gn', 'refs', self.args.path[0]] + inp['files'] + |
| 393 ['--type=executable', '--all', '--as=output']) |
| 394 needed_targets = [] |
| 395 ret, out, _ = self.Run(cmd) |
| 396 |
| 397 if ret: |
| 398 self.WriteFailureAndRaise('gn refs returned %d: %s' % (ret, out)) |
| 399 |
| 400 rpath = os.path.relpath(self.args.path[0], self.chromium_src_dir) + os.sep |
| 401 needed_targets = [t.replace(rpath, '') for t in out.splitlines()] |
| 402 needed_targets = [nt for nt in needed_targets if nt in inp['targets']] |
| 403 |
| 404 for nt in needed_targets: |
| 405 self.Print(nt) |
| 406 |
| 407 if needed_targets: |
| 408 # TODO: it could be that a target X might depend on a target Y |
| 409 # and both would be listed in the input, but we would only need |
| 410 # to specify target X as a build_target (whereas both X and Y are |
| 411 # targets). I'm not sure if that optimization is generally worth it. |
| 412 self.WriteJSON({'targets': needed_targets, |
| 413 'build_targets': needed_targets, |
| 414 'status': 'Found dependency'}) |
| 415 else: |
| 416 self.WriteJSON({'targets': [], |
| 417 'build_targets': [], |
| 418 'status': 'No dependency'}) |
| 419 |
| 420 return 0 |
| 421 |
| 422 def GetAnalyzeInput(self): |
| 423 path = self.args.input_path[0] |
| 424 if not self.Exists(path): |
| 425 self.WriteFailureAndRaise('"%s" does not exist' % path) |
| 426 |
| 427 try: |
| 428 inp = json.loads(self.ReadFile(path)) |
| 429 except Exception as e: |
| 430 self.WriteFailureAndRaise('Failed to read JSON input from "%s": %s' % |
| 431 (path, e)) |
| 432 if not 'files' in inp: |
| 433 self.WriteFailureAndRaise('input file is missing a "files" key') |
| 434 if not 'targets' in inp: |
| 435 self.WriteFailureAndRaise('input file is missing a "targets" key') |
| 436 |
| 437 return inp |
| 438 |
| 439 def WriteFailureAndRaise(self, msg): |
| 440 self.WriteJSON({'error': msg}) |
| 441 raise MBErr(msg) |
| 442 |
| 443 def WriteJSON(self, obj): |
| 444 output_path = self.args.output_path[0] |
| 445 if output_path: |
| 446 try: |
| 447 self.WriteFile(output_path, json.dumps(obj)) |
| 448 except Exception as e: |
| 449 raise MBErr('Error %s writing to the output path "%s"' % |
| 450 (e, output_path)) |
| 451 |
| 452 def PrintCmd(self, cmd): |
| 453 if cmd[0] == sys.executable: |
| 454 cmd = ['python'] + cmd[1:] |
| 455 self.Print(*[pipes.quote(c) for c in cmd]) |
| 456 |
| 457 def Print(self, *args, **kwargs): |
| 458 # This function largely exists so it can be overridden for testing. |
| 459 print(*args, **kwargs) |
| 460 |
| 461 def Run(self, cmd): |
| 462 # This function largely exists so it can be overridden for testing. |
| 463 if self.args.dryrun or self.args.verbose: |
| 464 self.PrintCmd(cmd) |
| 465 if self.args.dryrun: |
| 466 return 0, '', '' |
| 467 ret, out, err = self.Call(cmd) |
| 468 if self.args.verbose: |
| 469 if out: |
| 470 self.Print(out) |
| 471 if err: |
| 472 self.Print(err, file=sys.stderr) |
| 473 return ret, out, err |
| 474 |
| 475 def Call(self, cmd): |
| 476 p = subprocess.Popen(cmd, shell=False, cwd=self.chromium_src_dir, |
| 477 stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
| 478 out, err = p.communicate() |
| 479 return p.returncode, out, err |
| 480 |
| 481 def ExpandUser(self, path): |
| 482 # This function largely exists so it can be overridden for testing. |
| 483 return os.path.expanduser(path) |
| 484 |
| 485 def Exists(self, path): |
| 486 # This function largely exists so it can be overridden for testing. |
| 487 return os.path.exists(path) |
| 488 |
| 489 def ReadFile(self, path): |
| 490 # This function largely exists so it can be overriden for testing. |
| 491 with open(path) as fp: |
| 492 return fp.read() |
| 493 |
| 494 def WriteFile(self, path, contents): |
| 495 # This function largely exists so it can be overriden for testing. |
| 496 with open(path, 'w') as fp: |
| 497 return fp.write(contents) |
| 498 |
| 499 class MBErr(Exception): |
| 500 pass |
| 501 |
| 502 |
| 503 if __name__ == '__main__': |
| 504 try: |
| 505 sys.exit(main(sys.argv[1:])) |
| 506 except MBErr as e: |
| 507 print(e) |
| 508 sys.exit(1) |
| 509 except KeyboardInterrupt: |
| 510 print("interrupted, exiting", stream=sys.stderr) |
| 511 sys.exit(130) |
OLD | NEW |