blob: 1b7bbb92666ac02a8bc27447e82d64f5a87ef74b [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')
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)
dprankeee5b51f62015-04-09 00:03:22287
288 # TODO: check for cycles in mixins.
dprankefe4602312015-04-08 16:20:35289
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]
dprankeee5b51f62015-04-09 00:03:22319 gn_args = gn_args.replace("$(goma_dir)", self.args.goma_dir)
dprankefe4602312015-04-08 16:20:35320 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']))
dprankecda00332015-04-11 04:18:32340 if self.args.verbose:
341 inp = self.GetAnalyzeInput()
342 self.Print()
343 self.Print('analyze input:')
344 self.PrintJSON(inp)
345 self.Print()
346
dprankefe4602312015-04-08 16:20:35347 cmd = self.GYPCmd(output_dir, vals['gyp_defines'], config=gyp_config)
348 cmd.extend(['-G', 'config_path=%s' % self.args.input_path[0],
349 '-G', 'analyzer_output_path=%s' % self.args.output_path[0]])
350 ret, _, _ = self.Run(cmd)
dprankecda00332015-04-11 04:18:32351 if not ret and self.args.verbose:
352 outp = json.loads(self.ReadFile(self.args.output_path[0]))
353 self.Print()
354 self.Print('analyze output:')
355 self.PrintJSON(inp)
356 self.Print()
357
dprankefe4602312015-04-08 16:20:35358 return ret
359
dprankeee5b51f62015-04-09 00:03:22360 def ToSrcRelPath(self, path):
361 """Returns a relative path from the top of the repo."""
362 # TODO: Support normal paths in addition to source-absolute paths.
dprankefe4602312015-04-08 16:20:35363 assert(path.startswith('//'))
364 return path[2:]
365
366 def ParseGYPConfigPath(self, path):
dprankeee5b51f62015-04-09 00:03:22367 rpath = self.ToSrcRelPath(path)
368 output_dir, _, config = rpath.rpartition('/')
dprankefe4602312015-04-08 16:20:35369 self.CheckGYPConfigIsSupported(config, path)
370 return output_dir, config
371
372 def CheckGYPConfigIsSupported(self, config, path):
373 if config not in ('Debug', 'Release'):
374 if (sys.platform in ('win32', 'cygwin') and
375 config not in ('Debug_x64', 'Release_x64')):
376 raise MBErr('Unknown or unsupported config type "%s" in "%s"' %
377 config, path)
378
379 def GYPCmd(self, output_dir, gyp_defines, config):
380 gyp_defines = gyp_defines.replace("$(goma_dir)", self.args.goma_dir)
381 cmd = [
382 sys.executable,
383 os.path.join('build', 'gyp_chromium'),
384 '-G',
385 'output_dir=' + output_dir,
386 '-G',
387 'config=' + config,
388 ]
389 for d in shlex.split(gyp_defines):
390 cmd += ['-D', d]
391 return cmd
392
393 def RunGNAnalyze(self, _vals):
394 inp = self.GetAnalyzeInput()
dprankecda00332015-04-11 04:18:32395 if self.args.verbose:
396 self.Print()
397 self.Print('analyze input:')
398 self.PrintJSON(inp)
399 self.Print()
400
401 output_path = self.args.output_path[0]
dprankefe4602312015-04-08 16:20:35402
403 # Bail out early if a GN file was modified, since 'gn refs' won't know
404 # what to do about it.
405 if any(f.endswith('.gn') or f.endswith('.gni') for f in inp['files']):
dprankecda00332015-04-11 04:18:32406 self.WriteJSONOutput({'status': 'Found dependency (all)'}, output_path)
dprankefe4602312015-04-08 16:20:35407 return 0
408
dprankecda00332015-04-11 04:18:32409 all_needed_targets = set()
410 for f in inp['files']:
411 cmd = ['gn', 'refs', self.args.path[0], '//' + f,
412 '--type=executable', '--all', '--as=output']
413 ret, out, _ = self.Run(cmd)
414 if ret:
415 self.WriteFailureAndRaise('gn refs returned %d: %s' % (ret, out),
416 output_path)
dprankefe4602312015-04-08 16:20:35417
dprankecda00332015-04-11 04:18:32418 rpath = self.ToSrcRelPath(self.args.path[0]) + os.sep
419 needed_targets = [t.replace(rpath, '') for t in out.splitlines()]
420 needed_targets = [nt for nt in needed_targets if nt in inp['targets']]
421 all_needed_targets.update(set(needed_targets))
dprankefe4602312015-04-08 16:20:35422
dprankecda00332015-04-11 04:18:32423 if all_needed_targets:
dprankefe4602312015-04-08 16:20:35424 # TODO: it could be that a target X might depend on a target Y
425 # and both would be listed in the input, but we would only need
426 # to specify target X as a build_target (whereas both X and Y are
427 # targets). I'm not sure if that optimization is generally worth it.
dprankecda00332015-04-11 04:18:32428 self.WriteJSON({'targets': sorted(all_needed_targets),
429 'build_targets': sorted(all_needed_targets),
430 'status': 'Found dependency'}, output_path)
dprankefe4602312015-04-08 16:20:35431 else:
432 self.WriteJSON({'targets': [],
433 'build_targets': [],
dprankecda00332015-04-11 04:18:32434 'status': 'No dependency'}, output_path)
435
436 if not ret and self.args.verbose:
437 outp = json.loads(self.ReadFile(output_path))
438 self.Print()
439 self.Print('analyze output:')
440 self.PrintJSON(outp)
441 self.Print()
dprankefe4602312015-04-08 16:20:35442
443 return 0
444
445 def GetAnalyzeInput(self):
446 path = self.args.input_path[0]
dprankecda00332015-04-11 04:18:32447 output_path = self.args.output_path[0]
dprankefe4602312015-04-08 16:20:35448 if not self.Exists(path):
dprankecda00332015-04-11 04:18:32449 self.WriteFailureAndRaise('"%s" does not exist' % path, output_path)
dprankefe4602312015-04-08 16:20:35450
451 try:
452 inp = json.loads(self.ReadFile(path))
453 except Exception as e:
454 self.WriteFailureAndRaise('Failed to read JSON input from "%s": %s' %
dprankecda00332015-04-11 04:18:32455 (path, e), output_path)
dprankefe4602312015-04-08 16:20:35456 if not 'files' in inp:
dprankecda00332015-04-11 04:18:32457 self.WriteFailureAndRaise('input file is missing a "files" key',
458 output_path)
dprankefe4602312015-04-08 16:20:35459 if not 'targets' in inp:
dprankecda00332015-04-11 04:18:32460 self.WriteFailureAndRaise('input file is missing a "targets" key',
461 output_path)
dprankefe4602312015-04-08 16:20:35462
463 return inp
464
dprankecda00332015-04-11 04:18:32465 def WriteFailureAndRaise(self, msg, path):
466 self.WriteJSON({'error': msg}, path)
dprankefe4602312015-04-08 16:20:35467 raise MBErr(msg)
468
dprankecda00332015-04-11 04:18:32469 def WriteJSON(self, obj, path):
470 try:
471 self.WriteFile(path, json.dumps(obj, indent=2, sort_keys=True) + '\n')
472 except Exception as e:
473 raise MBErr('Error %s writing to the output path "%s"' %
474 (e, path))
dprankefe4602312015-04-08 16:20:35475
476 def PrintCmd(self, cmd):
477 if cmd[0] == sys.executable:
478 cmd = ['python'] + cmd[1:]
479 self.Print(*[pipes.quote(c) for c in cmd])
480
dprankecda00332015-04-11 04:18:32481 def PrintJSON(self, obj):
482 self.Print(json.dumps(obj, indent=2, sort_keys=True))
483
dprankefe4602312015-04-08 16:20:35484 def Print(self, *args, **kwargs):
485 # This function largely exists so it can be overridden for testing.
486 print(*args, **kwargs)
487
488 def Run(self, cmd):
489 # This function largely exists so it can be overridden for testing.
490 if self.args.dryrun or self.args.verbose:
491 self.PrintCmd(cmd)
492 if self.args.dryrun:
493 return 0, '', ''
494 ret, out, err = self.Call(cmd)
495 if self.args.verbose:
496 if out:
dprankeee5b51f62015-04-09 00:03:22497 self.Print(out, end='')
dprankefe4602312015-04-08 16:20:35498 if err:
dprankeee5b51f62015-04-09 00:03:22499 self.Print(err, end='', file=sys.stderr)
dprankefe4602312015-04-08 16:20:35500 return ret, out, err
501
502 def Call(self, cmd):
503 p = subprocess.Popen(cmd, shell=False, cwd=self.chromium_src_dir,
504 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
505 out, err = p.communicate()
506 return p.returncode, out, err
507
508 def ExpandUser(self, path):
509 # This function largely exists so it can be overridden for testing.
510 return os.path.expanduser(path)
511
512 def Exists(self, path):
513 # This function largely exists so it can be overridden for testing.
514 return os.path.exists(path)
515
516 def ReadFile(self, path):
517 # This function largely exists so it can be overriden for testing.
518 with open(path) as fp:
519 return fp.read()
520
521 def WriteFile(self, path, contents):
522 # This function largely exists so it can be overriden for testing.
523 with open(path, 'w') as fp:
524 return fp.write(contents)
525
526class MBErr(Exception):
527 pass
528
529
530if __name__ == '__main__':
531 try:
532 sys.exit(main(sys.argv[1:]))
533 except MBErr as e:
534 print(e)
535 sys.exit(1)
536 except KeyboardInterrupt:
537 print("interrupted, exiting", stream=sys.stderr)
538 sys.exit(130)