blob: 0b5775e9f2069fb887144b1deda0ef700a8763d3 [file] [log] [blame]
dprankefe4602312015-04-08 16:20:351#!/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
8MB is a wrapper script for GYP and GN that can be used to generate build files
9for sets of canned configurations and analyze them.
10"""
11
12from __future__ import print_function
13
14import argparse
15import ast
16import json
17import os
18import pipes
19import shlex
20import shutil
21import sys
22import subprocess
23
24
25def main(args):
dprankeee5b51f62015-04-09 00:03:2226 mbw = MetaBuildWrapper()
27 mbw.ParseArgs(args)
28 return mbw.args.func()
dprankefe4602312015-04-08 16:20:3529
30
31class 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')
dpranked1fba482015-04-14 20:54:5138 self.platform = sys.platform
dprankefe4602312015-04-08 16:20:3539 self.args = argparse.Namespace()
40 self.configs = {}
41 self.masters = {}
42 self.mixins = {}
43 self.private_configs = []
44 self.common_dev_configs = []
45 self.unsupported_configs = []
46
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 '
66 'code.')
67 subp.add_argument('-v', '--verbose', action='count',
68 help='verbose logging (may specify multiple times).')
69
70 parser = argparse.ArgumentParser(prog='mb')
71 subps = parser.add_subparsers()
72
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 '
81 'as a JSON object.')
82 subp.add_argument('output_path', nargs=1,
83 help='path to a file containing the output arguments '
84 'as a JSON object.')
85 subp.set_defaults(func=self.CmdAnalyze)
86
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)
93
94 subp = subps.add_parser('lookup',
95 help='look up the command for a given config or '
96 'builder')
97 AddCommonOptions(subp)
98 subp.set_defaults(func=self.CmdLookup)
99
100 subp = subps.add_parser('validate',
101 help='validate the config file')
102 AddCommonOptions(subp)
103 subp.set_defaults(func=self.CmdValidate)
104
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)
110
111 self.args = parser.parse_args(argv)
112
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)
119 else:
120 raise MBErr('Unknown meta-build type "%s"' % vals['type'])
121
122 def CmdGen(self):
123 vals = self.GetConfig()
124 if vals['type'] == 'gn':
125 self.RunGNGen(self.args.path[0], vals)
126 elif vals['type'] == 'gyp':
127 self.RunGYPGen(self.args.path[0], vals)
128 else:
129 raise MBErr('Unknown meta-build type "%s"' % vals['type'])
130 return 0
131
132 def CmdLookup(self):
133 vals = self.GetConfig()
134 if vals['type'] == 'gn':
dpranked1fba482015-04-14 20:54:51135 cmd = self.GNCmd('gen', '<path>', vals['gn_args'])
dprankefe4602312015-04-08 16:20:35136 elif vals['type'] == 'gyp':
137 cmd = self.GYPCmd('<path>', vals['gyp_defines'], vals['gyp_config'])
138 else:
139 raise MBErr('Unknown meta-build type "%s"' % vals['type'])
140
141 self.PrintCmd(cmd)
142 return 0
143
144 def CmdHelp(self):
145 if self.args.subcommand:
146 self.ParseArgs([self.args.subcommand, '--help'])
147 else:
148 self.ParseArgs(['--help'])
149
150 def CmdValidate(self):
151 errs = []
152
153 # Read the file to make sure it parses.
154 self.ReadConfigFile()
155
156 # Figure out the whole list of configs and ensure that no config is
157 # listed in more than one category.
158 all_configs = {}
159 for config in self.common_dev_configs:
160 all_configs[config] = 'common_dev_configs'
161 for config in self.private_configs:
162 if config in all_configs:
163 errs.append('config "%s" listed in "private_configs" also '
164 'listed in "%s"' % (config, all_configs['config']))
165 else:
166 all_configs[config] = 'private_configs'
167 for config in self.unsupported_configs:
168 if config in all_configs:
169 errs.append('config "%s" listed in "unsupported_configs" also '
170 'listed in "%s"' % (config, all_configs['config']))
171 else:
172 all_configs[config] = 'unsupported_configs'
173
174 for master in self.masters:
175 for builder in self.masters[master]:
176 config = self.masters[master][builder]
177 if config in all_configs and all_configs[config] not in self.masters:
178 errs.append('Config "%s" used by a bot is also listed in "%s".' %
179 (config, all_configs[config]))
180 else:
181 all_configs[config] = master
182
183 # Check that every referenced config actually exists.
184 for config, loc in all_configs.items():
185 if not config in self.configs:
186 errs.append('Unknown config "%s" referenced from "%s".' %
187 (config, loc))
188
189 # Check that every actual config is actually referenced.
190 for config in self.configs:
191 if not config in all_configs:
192 errs.append('Unused config "%s".' % config)
193
194 # Figure out the whole list of mixins, and check that every mixin
195 # listed by a config or another mixin actually exists.
196 referenced_mixins = set()
197 for config, mixins in self.configs.items():
198 for mixin in mixins:
199 if not mixin in self.mixins:
200 errs.append('Unknown mixin "%s" referenced by config "%s".' %
201 (mixin, config))
202 referenced_mixins.add(mixin)
203
204 for mixin in self.mixins:
205 for sub_mixin in self.mixins[mixin].get('mixins', []):
206 if not sub_mixin in self.mixins:
207 errs.append('Unknown mixin "%s" referenced by mixin "%s".' %
208 (sub_mixin, mixin))
209 referenced_mixins.add(sub_mixin)
210
211 # Check that every mixin defined is actually referenced somewhere.
212 for mixin in self.mixins:
213 if not mixin in referenced_mixins:
214 errs.append('Unreferenced mixin "%s".' % mixin)
215
216 if errs:
217 raise MBErr('mb config file %s has problems:\n ' + '\n '.join(errs))
218
219 if not self.args.quiet:
220 self.Print('mb config file %s looks ok.' % self.args.config_file)
221 return 0
222
223 def GetConfig(self):
224 self.ReadConfigFile()
225 config = self.ConfigFromArgs()
226 if not config in self.configs:
227 raise MBErr('Config "%s" not found in %s' %
228 (config, self.args.config_file))
229
230 return self.FlattenConfig(config)
231
232 def ReadConfigFile(self):
233 if not self.Exists(self.args.config_file):
234 raise MBErr('config file not found at %s' % self.args.config_file)
235
236 try:
237 contents = ast.literal_eval(self.ReadFile(self.args.config_file))
238 except SyntaxError as e:
239 raise MBErr('Failed to parse config file "%s": %s' %
240 (self.args.config_file, e))
241
242 self.common_dev_configs = contents['common_dev_configs']
243 self.configs = contents['configs']
244 self.masters = contents['masters']
245 self.mixins = contents['mixins']
246 self.private_configs = contents['private_configs']
247 self.unsupported_configs = contents['unsupported_configs']
248
249 def ConfigFromArgs(self):
250 if self.args.config:
251 if self.args.master or self.args.builder:
252 raise MBErr('Can not specific both -c/--config and -m/--master or '
253 '-b/--builder')
254
255 return self.args.config
256
257 if not self.args.master or not self.args.builder:
258 raise MBErr('Must specify either -c/--config or '
259 '(-m/--master and -b/--builder)')
260
261 if not self.args.master in self.masters:
262 raise MBErr('Master name "%s" not found in "%s"' %
263 (self.args.master, self.args.config_file))
264
265 if not self.args.builder in self.masters[self.args.master]:
266 raise MBErr('Builder name "%s" not found under masters[%s] in "%s"' %
267 (self.args.builder, self.args.master, self.args.config_file))
268
269 return self.masters[self.args.master][self.args.builder]
270
271 def FlattenConfig(self, config):
272 mixins = self.configs[config]
273 vals = {
274 'type': None,
275 'gn_args': [],
276 'gyp_config': [],
277 'gyp_defines': [],
278 }
279
280 visited = []
281 self.FlattenMixins(mixins, vals, visited)
282 return vals
283
284 def FlattenMixins(self, mixins, vals, visited):
285 for m in mixins:
286 if m not in self.mixins:
287 raise MBErr('Unknown mixin "%s"' % m)
dprankeee5b51f62015-04-09 00:03:22288
289 # TODO: check for cycles in mixins.
dprankefe4602312015-04-08 16:20:35290
291 visited.append(m)
292
293 mixin_vals = self.mixins[m]
294 if 'type' in mixin_vals:
295 vals['type'] = mixin_vals['type']
296 if 'gn_args' in mixin_vals:
297 if vals['gn_args']:
298 vals['gn_args'] += ' ' + mixin_vals['gn_args']
299 else:
300 vals['gn_args'] = mixin_vals['gn_args']
301 if 'gyp_config' in mixin_vals:
302 vals['gyp_config'] = mixin_vals['gyp_config']
303 if 'gyp_defines' in mixin_vals:
304 if vals['gyp_defines']:
305 vals['gyp_defines'] += ' ' + mixin_vals['gyp_defines']
306 else:
307 vals['gyp_defines'] = mixin_vals['gyp_defines']
308 if 'mixins' in mixin_vals:
309 self.FlattenMixins(mixin_vals['mixins'], vals, visited)
310 return vals
311
312 def RunGNGen(self, path, vals):
dpranked1fba482015-04-14 20:54:51313 cmd = self.GNCmd('gen', path, vals['gn_args'])
dprankefe4602312015-04-08 16:20:35314 ret, _, _ = self.Run(cmd)
315 return ret
316
dpranked1fba482015-04-14 20:54:51317 def GNCmd(self, subcommand, path, gn_args=''):
318 if self.platform == 'linux2':
319 gn_path = os.path.join(self.chromium_src_dir, 'buildtools', 'linux64',
320 'gn')
321 elif self.platform == 'darwin':
322 gn_path = os.path.join(self.chromium_src_dir, 'buildtools', 'mac',
323 'gn')
324 else:
325 gn_path = os.path.join(self.chromium_src_dir, 'buildtools', 'win',
326 'gn.exe')
327
328 cmd = [gn_path, subcommand, path]
dprankeee5b51f62015-04-09 00:03:22329 gn_args = gn_args.replace("$(goma_dir)", self.args.goma_dir)
dprankefe4602312015-04-08 16:20:35330 if gn_args:
331 cmd.append('--args=%s' % gn_args)
332 return cmd
333
334 def RunGYPGen(self, path, vals):
335 output_dir, gyp_config = self.ParseGYPConfigPath(path)
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 ret, _, _ = self.Run(cmd)
342 return ret
343
344 def RunGYPAnalyze(self, vals):
345 output_dir, gyp_config = self.ParseGYPConfigPath(self.args.path[0])
346 if gyp_config != vals['gyp_config']:
347 raise MBErr('The last component of the path (%s) must match the '
348 'GYP configuration specified in the config (%s), and '
349 'it does not.' % (gyp_config, vals['gyp_config']))
dprankecda00332015-04-11 04:18:32350 if self.args.verbose:
351 inp = self.GetAnalyzeInput()
352 self.Print()
353 self.Print('analyze input:')
354 self.PrintJSON(inp)
355 self.Print()
356
dprankefe4602312015-04-08 16:20:35357 cmd = self.GYPCmd(output_dir, vals['gyp_defines'], config=gyp_config)
358 cmd.extend(['-G', 'config_path=%s' % self.args.input_path[0],
359 '-G', 'analyzer_output_path=%s' % self.args.output_path[0]])
360 ret, _, _ = self.Run(cmd)
dprankecda00332015-04-11 04:18:32361 if not ret and self.args.verbose:
362 outp = json.loads(self.ReadFile(self.args.output_path[0]))
363 self.Print()
364 self.Print('analyze output:')
365 self.PrintJSON(inp)
366 self.Print()
367
dprankefe4602312015-04-08 16:20:35368 return ret
369
dprankeee5b51f62015-04-09 00:03:22370 def ToSrcRelPath(self, path):
371 """Returns a relative path from the top of the repo."""
372 # TODO: Support normal paths in addition to source-absolute paths.
dprankefe4602312015-04-08 16:20:35373 assert(path.startswith('//'))
374 return path[2:]
375
376 def ParseGYPConfigPath(self, path):
dprankeee5b51f62015-04-09 00:03:22377 rpath = self.ToSrcRelPath(path)
378 output_dir, _, config = rpath.rpartition('/')
dprankefe4602312015-04-08 16:20:35379 self.CheckGYPConfigIsSupported(config, path)
380 return output_dir, config
381
382 def CheckGYPConfigIsSupported(self, config, path):
383 if config not in ('Debug', 'Release'):
384 if (sys.platform in ('win32', 'cygwin') and
385 config not in ('Debug_x64', 'Release_x64')):
386 raise MBErr('Unknown or unsupported config type "%s" in "%s"' %
387 config, path)
388
389 def GYPCmd(self, output_dir, gyp_defines, config):
390 gyp_defines = gyp_defines.replace("$(goma_dir)", self.args.goma_dir)
391 cmd = [
392 sys.executable,
393 os.path.join('build', 'gyp_chromium'),
394 '-G',
395 'output_dir=' + output_dir,
396 '-G',
397 'config=' + config,
398 ]
399 for d in shlex.split(gyp_defines):
400 cmd += ['-D', d]
401 return cmd
402
403 def RunGNAnalyze(self, _vals):
404 inp = self.GetAnalyzeInput()
dprankecda00332015-04-11 04:18:32405 if self.args.verbose:
406 self.Print()
407 self.Print('analyze input:')
408 self.PrintJSON(inp)
409 self.Print()
410
411 output_path = self.args.output_path[0]
dprankefe4602312015-04-08 16:20:35412
413 # Bail out early if a GN file was modified, since 'gn refs' won't know
414 # what to do about it.
415 if any(f.endswith('.gn') or f.endswith('.gni') for f in inp['files']):
Dirk Prankec965fa32015-04-14 23:46:29416 self.WriteJSON({'status': 'Found dependency (all)'}, output_path)
dprankefe4602312015-04-08 16:20:35417 return 0
418
dpranke76734662015-04-16 02:17:50419 # TODO: Because of the --type=executable filter below, we don't detect
420 # when files will cause 'all' or 'gn_all' or similar targets to be
421 # dirty. We need to figure out how to handle that properly, but for
422 # now we can just bail out early.
423 if 'gn_all' in inp['targets'] or 'all' in inp['targets']:
424 self.WriteJSON({'status': 'Found dependency (all)'}, output_path)
425 return 0
426
dprankecda00332015-04-11 04:18:32427 all_needed_targets = set()
Dirk Pranke12ee2db2015-04-14 23:15:32428 ret = 0
dprankecda00332015-04-11 04:18:32429 for f in inp['files']:
dpranked1fba482015-04-14 20:54:51430 cmd = self.GNCmd('refs', self.args.path[0]) + [
431 '//' + f, '--type=executable', '--all', '--as=output']
dprankecda00332015-04-11 04:18:32432 ret, out, _ = self.Run(cmd)
dpranke0b3b7882015-04-24 03:38:12433 if ret and not 'The input matches no targets' in out:
dprankecda00332015-04-11 04:18:32434 self.WriteFailureAndRaise('gn refs returned %d: %s' % (ret, out),
435 output_path)
dprankefe4602312015-04-08 16:20:35436
dprankecda00332015-04-11 04:18:32437 rpath = self.ToSrcRelPath(self.args.path[0]) + os.sep
438 needed_targets = [t.replace(rpath, '') for t in out.splitlines()]
439 needed_targets = [nt for nt in needed_targets if nt in inp['targets']]
440 all_needed_targets.update(set(needed_targets))
dprankefe4602312015-04-08 16:20:35441
dprankecda00332015-04-11 04:18:32442 if all_needed_targets:
dprankefe4602312015-04-08 16:20:35443 # TODO: it could be that a target X might depend on a target Y
444 # and both would be listed in the input, but we would only need
445 # to specify target X as a build_target (whereas both X and Y are
446 # targets). I'm not sure if that optimization is generally worth it.
dprankecda00332015-04-11 04:18:32447 self.WriteJSON({'targets': sorted(all_needed_targets),
448 'build_targets': sorted(all_needed_targets),
449 'status': 'Found dependency'}, output_path)
dprankefe4602312015-04-08 16:20:35450 else:
451 self.WriteJSON({'targets': [],
452 'build_targets': [],
dprankecda00332015-04-11 04:18:32453 'status': 'No dependency'}, output_path)
454
455 if not ret and self.args.verbose:
456 outp = json.loads(self.ReadFile(output_path))
457 self.Print()
458 self.Print('analyze output:')
459 self.PrintJSON(outp)
460 self.Print()
dprankefe4602312015-04-08 16:20:35461
462 return 0
463
464 def GetAnalyzeInput(self):
465 path = self.args.input_path[0]
dprankecda00332015-04-11 04:18:32466 output_path = self.args.output_path[0]
dprankefe4602312015-04-08 16:20:35467 if not self.Exists(path):
dprankecda00332015-04-11 04:18:32468 self.WriteFailureAndRaise('"%s" does not exist' % path, output_path)
dprankefe4602312015-04-08 16:20:35469
470 try:
471 inp = json.loads(self.ReadFile(path))
472 except Exception as e:
473 self.WriteFailureAndRaise('Failed to read JSON input from "%s": %s' %
dprankecda00332015-04-11 04:18:32474 (path, e), output_path)
dprankefe4602312015-04-08 16:20:35475 if not 'files' in inp:
dprankecda00332015-04-11 04:18:32476 self.WriteFailureAndRaise('input file is missing a "files" key',
477 output_path)
dprankefe4602312015-04-08 16:20:35478 if not 'targets' in inp:
dprankecda00332015-04-11 04:18:32479 self.WriteFailureAndRaise('input file is missing a "targets" key',
480 output_path)
dprankefe4602312015-04-08 16:20:35481
482 return inp
483
dprankecda00332015-04-11 04:18:32484 def WriteFailureAndRaise(self, msg, path):
485 self.WriteJSON({'error': msg}, path)
dprankefe4602312015-04-08 16:20:35486 raise MBErr(msg)
487
dprankecda00332015-04-11 04:18:32488 def WriteJSON(self, obj, path):
489 try:
490 self.WriteFile(path, json.dumps(obj, indent=2, sort_keys=True) + '\n')
491 except Exception as e:
492 raise MBErr('Error %s writing to the output path "%s"' %
493 (e, path))
dprankefe4602312015-04-08 16:20:35494
495 def PrintCmd(self, cmd):
496 if cmd[0] == sys.executable:
497 cmd = ['python'] + cmd[1:]
498 self.Print(*[pipes.quote(c) for c in cmd])
499
dprankecda00332015-04-11 04:18:32500 def PrintJSON(self, obj):
501 self.Print(json.dumps(obj, indent=2, sort_keys=True))
502
dprankefe4602312015-04-08 16:20:35503 def Print(self, *args, **kwargs):
504 # This function largely exists so it can be overridden for testing.
505 print(*args, **kwargs)
506
507 def Run(self, cmd):
508 # This function largely exists so it can be overridden for testing.
509 if self.args.dryrun or self.args.verbose:
510 self.PrintCmd(cmd)
511 if self.args.dryrun:
512 return 0, '', ''
513 ret, out, err = self.Call(cmd)
514 if self.args.verbose:
515 if out:
dprankeee5b51f62015-04-09 00:03:22516 self.Print(out, end='')
dprankefe4602312015-04-08 16:20:35517 if err:
dprankeee5b51f62015-04-09 00:03:22518 self.Print(err, end='', file=sys.stderr)
dprankefe4602312015-04-08 16:20:35519 return ret, out, err
520
521 def Call(self, cmd):
522 p = subprocess.Popen(cmd, shell=False, cwd=self.chromium_src_dir,
523 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
524 out, err = p.communicate()
525 return p.returncode, out, err
526
527 def ExpandUser(self, path):
528 # This function largely exists so it can be overridden for testing.
529 return os.path.expanduser(path)
530
531 def Exists(self, path):
532 # This function largely exists so it can be overridden for testing.
533 return os.path.exists(path)
534
535 def ReadFile(self, path):
536 # This function largely exists so it can be overriden for testing.
537 with open(path) as fp:
538 return fp.read()
539
540 def WriteFile(self, path, contents):
541 # This function largely exists so it can be overriden for testing.
542 with open(path, 'w') as fp:
543 return fp.write(contents)
544
545class MBErr(Exception):
546 pass
547
548
549if __name__ == '__main__':
550 try:
551 sys.exit(main(sys.argv[1:]))
552 except MBErr as e:
553 print(e)
554 sys.exit(1)
555 except KeyboardInterrupt:
556 print("interrupted, exiting", stream=sys.stderr)
557 sys.exit(130)