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.
6 """MB - the Meta-Build wrapper around GYP and GN
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.
12 from __future__
import print_function
26 mbw
= MetaBuildWrapper()
28 return mbw
.args
.func()
31 class MetaBuildWrapper(object):
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',
38 self
.platform
= sys
.platform
39 self
.args
= argparse
.Namespace()
43 self
.private_configs
= []
44 self
.common_dev_configs
= []
45 self
.unsupported_configs
= []
47 def ParseArgs(self
, argv
):
48 def AddCommonOptions(subp
):
49 subp
.add_argument('-b', '--builder',
50 help='builder name to look up config from')
51 subp
.add_argument('-m', '--master',
52 help='master name to look up config from')
53 subp
.add_argument('-c', '--config',
54 help='configuration to analyze')
55 subp
.add_argument('-f', '--config-file', metavar
='PATH',
56 default
=self
.default_config
,
57 help='path to config file '
58 '(default is //tools/mb/mb_config.pyl)')
59 subp
.add_argument('-g', '--goma-dir', default
=self
.ExpandUser('~/goma'),
60 help='path to goma directory (default is %(default)s).')
61 subp
.add_argument('-n', '--dryrun', action
='store_true',
62 help='Do a dry run (i.e., do nothing, just print '
63 'the commands that will run)')
64 subp
.add_argument('-q', '--quiet', action
='store_true',
65 help='Do not print anything, just return an exit '
67 subp
.add_argument('-v', '--verbose', action
='count',
68 help='verbose logging (may specify multiple times).')
70 parser
= argparse
.ArgumentParser(prog
='mb')
71 subps
= parser
.add_subparsers()
73 subp
= subps
.add_parser('analyze',
74 help='analyze whether changes to a set of files '
75 'will cause a set of binaries to be rebuilt.')
76 AddCommonOptions(subp
)
77 subp
.add_argument('path', type=str, nargs
=1,
78 help='path build was generated into.')
79 subp
.add_argument('input_path', nargs
=1,
80 help='path to a file containing the input arguments '
82 subp
.add_argument('output_path', nargs
=1,
83 help='path to a file containing the output arguments '
85 subp
.set_defaults(func
=self
.CmdAnalyze
)
87 subp
= subps
.add_parser('gen',
88 help='generate a new set of build files')
89 AddCommonOptions(subp
)
90 subp
.add_argument('path', type=str, nargs
=1,
91 help='path to generate build into')
92 subp
.set_defaults(func
=self
.CmdGen
)
94 subp
= subps
.add_parser('lookup',
95 help='look up the command for a given config or '
97 AddCommonOptions(subp
)
98 subp
.set_defaults(func
=self
.CmdLookup
)
100 subp
= subps
.add_parser('validate',
101 help='validate the config file')
102 AddCommonOptions(subp
)
103 subp
.set_defaults(func
=self
.CmdValidate
)
105 subp
= subps
.add_parser('help',
106 help='Get help on a subcommand.')
107 subp
.add_argument(nargs
='?', action
='store', dest
='subcommand',
108 help='The command to get help for.')
109 subp
.set_defaults(func
=self
.CmdHelp
)
111 self
.args
= parser
.parse_args(argv
)
113 def CmdAnalyze(self
):
114 vals
= self
.GetConfig()
115 if vals
['type'] == 'gn':
116 return self
.RunGNAnalyze(vals
)
117 elif vals
['type'] == 'gyp':
118 return self
.RunGYPAnalyze(vals
)
120 raise MBErr('Unknown meta-build type "%s"' % vals
['type'])
123 vals
= self
.GetConfig()
124 if vals
['type'] == 'gn':
125 return self
.RunGNGen(self
.args
.path
[0], vals
)
126 if vals
['type'] == 'gyp':
127 return self
.RunGYPGen(self
.args
.path
[0], vals
)
129 raise MBErr('Unknown meta-build type "%s"' % vals
['type'])
132 vals
= self
.GetConfig()
133 if vals
['type'] == 'gn':
134 cmd
= self
.GNCmd('gen', '<path>', vals
['gn_args'])
135 elif vals
['type'] == 'gyp':
136 cmd
= self
.GYPCmd('<path>', vals
['gyp_defines'], vals
['gyp_config'])
138 raise MBErr('Unknown meta-build type "%s"' % vals
['type'])
144 if self
.args
.subcommand
:
145 self
.ParseArgs([self
.args
.subcommand
, '--help'])
147 self
.ParseArgs(['--help'])
149 def CmdValidate(self
):
152 # Read the file to make sure it parses.
153 self
.ReadConfigFile()
155 # Figure out the whole list of configs and ensure that no config is
156 # listed in more than one category.
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']))
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']))
171 all_configs
[config
] = 'unsupported_configs'
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
]))
180 all_configs
[config
] = master
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".' %
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
)
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():
198 if not mixin
in self
.mixins
:
199 errs
.append('Unknown mixin "%s" referenced by config "%s".' %
201 referenced_mixins
.add(mixin
)
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".' %
208 referenced_mixins
.add(sub_mixin
)
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
)
216 raise MBErr('mb config file %s has problems:\n ' + '\n '.join(errs
))
218 if not self
.args
.quiet
:
219 self
.Print('mb config file %s looks ok.' % self
.args
.config_file
)
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
))
229 return self
.FlattenConfig(config
)
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
)
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
))
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']
248 def ConfigFromArgs(self
):
250 if self
.args
.master
or self
.args
.builder
:
251 raise MBErr('Can not specific both -c/--config and -m/--master or '
254 return self
.args
.config
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)')
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
))
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
))
268 return self
.masters
[self
.args
.master
][self
.args
.builder
]
270 def FlattenConfig(self
, config
):
271 mixins
= self
.configs
[config
]
280 self
.FlattenMixins(mixins
, vals
, visited
)
283 def FlattenMixins(self
, mixins
, vals
, visited
):
285 if m
not in self
.mixins
:
286 raise MBErr('Unknown mixin "%s"' % m
)
288 # TODO: check for cycles in mixins.
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
:
297 vals
['gn_args'] += ' ' + mixin_vals
['gn_args']
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']
306 vals
['gyp_defines'] = mixin_vals
['gyp_defines']
307 if 'mixins' in mixin_vals
:
308 self
.FlattenMixins(mixin_vals
['mixins'], vals
, visited
)
311 def RunGNGen(self
, path
, vals
):
312 cmd
= self
.GNCmd('gen', path
, vals
['gn_args'])
313 ret
, _
, _
= self
.Run(cmd
)
316 def GNCmd(self
, subcommand
, path
, gn_args
=''):
317 if self
.platform
== 'linux2':
318 gn_path
= os
.path
.join(self
.chromium_src_dir
, 'buildtools', 'linux64',
320 elif self
.platform
== 'darwin':
321 gn_path
= os
.path
.join(self
.chromium_src_dir
, 'buildtools', 'mac',
324 gn_path
= os
.path
.join(self
.chromium_src_dir
, 'buildtools', 'win',
327 cmd
= [gn_path
, subcommand
, path
]
328 gn_args
= gn_args
.replace("$(goma_dir)", self
.args
.goma_dir
)
330 cmd
.append('--args=%s' % gn_args
)
333 def RunGYPGen(self
, path
, vals
):
334 output_dir
, gyp_config
= self
.ParseGYPConfigPath(path
)
335 if gyp_config
!= vals
['gyp_config']:
336 raise MBErr('The last component of the path (%s) must match the '
337 'GYP configuration specified in the config (%s), and '
338 'it does not.' % (gyp_config
, vals
['gyp_config']))
339 cmd
= self
.GYPCmd(output_dir
, vals
['gyp_defines'], config
=gyp_config
)
340 ret
, _
, _
= self
.Run(cmd
)
343 def RunGYPAnalyze(self
, vals
):
344 output_dir
, gyp_config
= self
.ParseGYPConfigPath(self
.args
.path
[0])
345 if gyp_config
!= vals
['gyp_config']:
346 raise MBErr('The last component of the path (%s) must match the '
347 'GYP configuration specified in the config (%s), and '
348 'it does not.' % (gyp_config
, vals
['gyp_config']))
349 if self
.args
.verbose
:
350 inp
= self
.GetAnalyzeInput()
352 self
.Print('analyze input:')
356 cmd
= self
.GYPCmd(output_dir
, vals
['gyp_defines'], config
=gyp_config
)
357 cmd
.extend(['-G', 'config_path=%s' % self
.args
.input_path
[0],
358 '-G', 'analyzer_output_path=%s' % self
.args
.output_path
[0]])
359 ret
, _
, _
= self
.Run(cmd
)
360 if not ret
and self
.args
.verbose
:
361 outp
= json
.loads(self
.ReadFile(self
.args
.output_path
[0]))
363 self
.Print('analyze output:')
369 def ToSrcRelPath(self
, path
):
370 """Returns a relative path from the top of the repo."""
371 # TODO: Support normal paths in addition to source-absolute paths.
372 assert(path
.startswith('//'))
375 def ParseGYPConfigPath(self
, path
):
376 rpath
= self
.ToSrcRelPath(path
)
377 output_dir
, _
, config
= rpath
.rpartition('/')
378 self
.CheckGYPConfigIsSupported(config
, path
)
379 return output_dir
, config
381 def CheckGYPConfigIsSupported(self
, config
, path
):
382 if config
not in ('Debug', 'Release'):
383 if (sys
.platform
in ('win32', 'cygwin') and
384 config
not in ('Debug_x64', 'Release_x64')):
385 raise MBErr('Unknown or unsupported config type "%s" in "%s"' %
388 def GYPCmd(self
, output_dir
, gyp_defines
, config
):
389 gyp_defines
= gyp_defines
.replace("$(goma_dir)", self
.args
.goma_dir
)
392 os
.path
.join('build', 'gyp_chromium'),
394 'output_dir=' + output_dir
,
398 for d
in shlex
.split(gyp_defines
):
402 def RunGNAnalyze(self
, _vals
):
403 inp
= self
.GetAnalyzeInput()
404 if self
.args
.verbose
:
406 self
.Print('analyze input:')
410 output_path
= self
.args
.output_path
[0]
412 # Bail out early if a GN file was modified, since 'gn refs' won't know
413 # what to do about it.
414 if any(f
.endswith('.gn') or f
.endswith('.gni') for f
in inp
['files']):
415 self
.WriteJSON({'status': 'Found dependency (all)'}, output_path
)
418 # TODO: Because of the --type=executable filter below, we don't detect
419 # when files will cause 'all' or 'gn_all' or similar targets to be
420 # dirty. We need to figure out how to handle that properly, but for
421 # now we can just bail out early.
422 if 'gn_all' in inp
['targets'] or 'all' in inp
['targets']:
423 self
.WriteJSON({'status': 'Found dependency (all)'}, output_path
)
426 all_needed_targets
= set()
428 for f
in inp
['files']:
429 cmd
= self
.GNCmd('refs', self
.args
.path
[0]) + [
430 '//' + f
, '--type=executable', '--all', '--as=output']
431 ret
, out
, _
= self
.Run(cmd
)
432 if ret
and not 'The input matches no targets' in out
:
433 self
.WriteFailureAndRaise('gn refs returned %d: %s' % (ret
, out
),
436 rpath
= self
.ToSrcRelPath(self
.args
.path
[0]) + os
.sep
437 needed_targets
= [t
.replace(rpath
, '') for t
in out
.splitlines()]
438 needed_targets
= [nt
for nt
in needed_targets
if nt
in inp
['targets']]
439 all_needed_targets
.update(set(needed_targets
))
441 if all_needed_targets
:
442 # TODO: it could be that a target X might depend on a target Y
443 # and both would be listed in the input, but we would only need
444 # to specify target X as a build_target (whereas both X and Y are
445 # targets). I'm not sure if that optimization is generally worth it.
446 self
.WriteJSON({'targets': sorted(all_needed_targets
),
447 'build_targets': sorted(all_needed_targets
),
448 'status': 'Found dependency'}, output_path
)
450 self
.WriteJSON({'targets': [],
452 'status': 'No dependency'}, output_path
)
454 if not ret
and self
.args
.verbose
:
455 outp
= json
.loads(self
.ReadFile(output_path
))
457 self
.Print('analyze output:')
463 def GetAnalyzeInput(self
):
464 path
= self
.args
.input_path
[0]
465 output_path
= self
.args
.output_path
[0]
466 if not self
.Exists(path
):
467 self
.WriteFailureAndRaise('"%s" does not exist' % path
, output_path
)
470 inp
= json
.loads(self
.ReadFile(path
))
471 except Exception as e
:
472 self
.WriteFailureAndRaise('Failed to read JSON input from "%s": %s' %
473 (path
, e
), output_path
)
474 if not 'files' in inp
:
475 self
.WriteFailureAndRaise('input file is missing a "files" key',
477 if not 'targets' in inp
:
478 self
.WriteFailureAndRaise('input file is missing a "targets" key',
483 def WriteFailureAndRaise(self
, msg
, path
):
484 self
.WriteJSON({'error': msg
}, path
)
487 def WriteJSON(self
, obj
, path
):
489 self
.WriteFile(path
, json
.dumps(obj
, indent
=2, sort_keys
=True) + '\n')
490 except Exception as e
:
491 raise MBErr('Error %s writing to the output path "%s"' %
494 def PrintCmd(self
, cmd
):
495 if cmd
[0] == sys
.executable
:
496 cmd
= ['python'] + cmd
[1:]
497 self
.Print(*[pipes
.quote(c
) for c
in cmd
])
499 def PrintJSON(self
, obj
):
500 self
.Print(json
.dumps(obj
, indent
=2, sort_keys
=True))
502 def Print(self
, *args
, **kwargs
):
503 # This function largely exists so it can be overridden for testing.
504 print(*args
, **kwargs
)
507 # This function largely exists so it can be overridden for testing.
508 if self
.args
.dryrun
or self
.args
.verbose
:
512 ret
, out
, err
= self
.Call(cmd
)
513 if self
.args
.verbose
:
515 self
.Print(out
, end
='')
517 self
.Print(err
, end
='', file=sys
.stderr
)
521 p
= subprocess
.Popen(cmd
, shell
=False, cwd
=self
.chromium_src_dir
,
522 stdout
=subprocess
.PIPE
, stderr
=subprocess
.PIPE
)
523 out
, err
= p
.communicate()
524 return p
.returncode
, out
, err
526 def ExpandUser(self
, path
):
527 # This function largely exists so it can be overridden for testing.
528 return os
.path
.expanduser(path
)
530 def Exists(self
, path
):
531 # This function largely exists so it can be overridden for testing.
532 return os
.path
.exists(path
)
534 def ReadFile(self
, path
):
535 # This function largely exists so it can be overriden for testing.
536 with
open(path
) as fp
:
539 def WriteFile(self
, path
, contents
):
540 # This function largely exists so it can be overriden for testing.
541 with
open(path
, 'w') as fp
:
542 return fp
.write(contents
)
544 class MBErr(Exception):
548 if __name__
== '__main__':
550 sys
.exit(main(sys
.argv
[1:]))
554 except KeyboardInterrupt:
555 print("interrupted, exiting", stream
=sys
.stderr
)