blob: d18045cbcf6502980c05cb59185c916cd9855643 [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']))
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
dprankeee5b51f62015-04-09 00:03:22346 def ToSrcRelPath(self, path):
347 """Returns a relative path from the top of the repo."""
348 # TODO: Support normal paths in addition to source-absolute paths.
dprankefe4602312015-04-08 16:20:35349 assert(path.startswith('//'))
350 return path[2:]
351
352 def ParseGYPConfigPath(self, path):
dprankeee5b51f62015-04-09 00:03:22353 rpath = self.ToSrcRelPath(path)
354 output_dir, _, config = rpath.rpartition('/')
dprankefe4602312015-04-08 16:20:35355 self.CheckGYPConfigIsSupported(config, path)
356 return output_dir, config
357
358 def CheckGYPConfigIsSupported(self, config, path):
359 if config not in ('Debug', 'Release'):
360 if (sys.platform in ('win32', 'cygwin') and
361 config not in ('Debug_x64', 'Release_x64')):
362 raise MBErr('Unknown or unsupported config type "%s" in "%s"' %
363 config, path)
364
365 def GYPCmd(self, output_dir, gyp_defines, config):
366 gyp_defines = gyp_defines.replace("$(goma_dir)", self.args.goma_dir)
367 cmd = [
368 sys.executable,
369 os.path.join('build', 'gyp_chromium'),
370 '-G',
371 'output_dir=' + output_dir,
372 '-G',
373 'config=' + config,
374 ]
375 for d in shlex.split(gyp_defines):
376 cmd += ['-D', d]
377 return cmd
378
379 def RunGNAnalyze(self, _vals):
380 inp = self.GetAnalyzeInput()
381
382 # Bail out early if a GN file was modified, since 'gn refs' won't know
383 # what to do about it.
384 if any(f.endswith('.gn') or f.endswith('.gni') for f in inp['files']):
385 self.WriteJSONOutput({'status': 'Found dependency (all)'})
386 return 0
387
388 # TODO: Break long lists of files that might exceed the max command line
389 # up into chunks so that we can return more accurate info.
390 if len(' '.join(inp['files'])) > 1024:
391 self.WriteJSONOutput({'status': 'Found dependency (all)'})
392 return 0
393
dprankeee5b51f62015-04-09 00:03:22394 cmd = (['gn', 'refs', self.args.path[0] ] +
395 ['//' + f for f in inp['files']] +
dprankefe4602312015-04-08 16:20:35396 ['--type=executable', '--all', '--as=output'])
397 needed_targets = []
398 ret, out, _ = self.Run(cmd)
dprankefe4602312015-04-08 16:20:35399 if ret:
400 self.WriteFailureAndRaise('gn refs returned %d: %s' % (ret, out))
401
dprankeee5b51f62015-04-09 00:03:22402 rpath = self.ToSrcRelPath(self.args.path[0]) + os.sep
dprankefe4602312015-04-08 16:20:35403 needed_targets = [t.replace(rpath, '') for t in out.splitlines()]
404 needed_targets = [nt for nt in needed_targets if nt in inp['targets']]
405
dprankefe4602312015-04-08 16:20:35406 if needed_targets:
407 # TODO: it could be that a target X might depend on a target Y
408 # and both would be listed in the input, but we would only need
409 # to specify target X as a build_target (whereas both X and Y are
410 # targets). I'm not sure if that optimization is generally worth it.
411 self.WriteJSON({'targets': needed_targets,
412 'build_targets': needed_targets,
413 'status': 'Found dependency'})
414 else:
415 self.WriteJSON({'targets': [],
416 'build_targets': [],
417 'status': 'No dependency'})
418
419 return 0
420
421 def GetAnalyzeInput(self):
422 path = self.args.input_path[0]
423 if not self.Exists(path):
424 self.WriteFailureAndRaise('"%s" does not exist' % path)
425
426 try:
427 inp = json.loads(self.ReadFile(path))
428 except Exception as e:
429 self.WriteFailureAndRaise('Failed to read JSON input from "%s": %s' %
430 (path, e))
431 if not 'files' in inp:
432 self.WriteFailureAndRaise('input file is missing a "files" key')
433 if not 'targets' in inp:
434 self.WriteFailureAndRaise('input file is missing a "targets" key')
435
436 return inp
437
438 def WriteFailureAndRaise(self, msg):
439 self.WriteJSON({'error': msg})
440 raise MBErr(msg)
441
442 def WriteJSON(self, obj):
443 output_path = self.args.output_path[0]
444 if output_path:
445 try:
dprankeee5b51f62015-04-09 00:03:22446 self.WriteFile(output_path, json.dumps(obj, indent=2) + '\n')
dprankefe4602312015-04-08 16:20:35447 except Exception as e:
448 raise MBErr('Error %s writing to the output path "%s"' %
449 (e, output_path))
450
451 def PrintCmd(self, cmd):
452 if cmd[0] == sys.executable:
453 cmd = ['python'] + cmd[1:]
454 self.Print(*[pipes.quote(c) for c in cmd])
455
456 def Print(self, *args, **kwargs):
457 # This function largely exists so it can be overridden for testing.
458 print(*args, **kwargs)
459
460 def Run(self, cmd):
461 # This function largely exists so it can be overridden for testing.
462 if self.args.dryrun or self.args.verbose:
463 self.PrintCmd(cmd)
464 if self.args.dryrun:
465 return 0, '', ''
466 ret, out, err = self.Call(cmd)
467 if self.args.verbose:
468 if out:
dprankeee5b51f62015-04-09 00:03:22469 self.Print(out, end='')
dprankefe4602312015-04-08 16:20:35470 if err:
dprankeee5b51f62015-04-09 00:03:22471 self.Print(err, end='', file=sys.stderr)
dprankefe4602312015-04-08 16:20:35472 return ret, out, err
473
474 def Call(self, cmd):
475 p = subprocess.Popen(cmd, shell=False, cwd=self.chromium_src_dir,
476 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
477 out, err = p.communicate()
478 return p.returncode, out, err
479
480 def ExpandUser(self, path):
481 # This function largely exists so it can be overridden for testing.
482 return os.path.expanduser(path)
483
484 def Exists(self, path):
485 # This function largely exists so it can be overridden for testing.
486 return os.path.exists(path)
487
488 def ReadFile(self, path):
489 # This function largely exists so it can be overriden for testing.
490 with open(path) as fp:
491 return fp.read()
492
493 def WriteFile(self, path, contents):
494 # This function largely exists so it can be overriden for testing.
495 with open(path, 'w') as fp:
496 return fp.write(contents)
497
498class MBErr(Exception):
499 pass
500
501
502if __name__ == '__main__':
503 try:
504 sys.exit(main(sys.argv[1:]))
505 except MBErr as e:
506 print(e)
507 sys.exit(1)
508 except KeyboardInterrupt:
509 print("interrupted, exiting", stream=sys.stderr)
510 sys.exit(130)