Revert "[M120 Migration]Fix for crash during chrome exit"
[platform/framework/web/chromium-efl.git] / tools / mb / mb.py
1 #!/usr/bin/env python3
2 # Copyright 2020 The Chromium Authors
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 GN.
7
8 MB is a wrapper script for GN that can be used to generate build files
9 for sets of canned configurations and analyze them.
10 """
11
12 import argparse
13 import ast
14 import collections
15 import errno
16 import json
17 import os
18 import shlex
19 import platform
20 import re
21 import shutil
22 import subprocess
23 import sys
24 import tempfile
25 import traceback
26 import urllib.request
27 import zipfile
28
29
30 CHROMIUM_SRC_DIR = os.path.dirname(os.path.dirname(os.path.dirname(
31     os.path.abspath(__file__))))
32 sys.path = [os.path.join(CHROMIUM_SRC_DIR, 'build')] + sys.path
33 sys.path.insert(0, os.path.join(
34     os.path.dirname(os.path.abspath(__file__)), '..'))
35
36 import gn_helpers
37 from mb.lib import validation
38
39
40 def DefaultVals():
41   """Default mixin values"""
42   return {
43       'args_file': '',
44       'gn_args': '',
45   }
46
47
48 def PruneVirtualEnv():
49   # Set by VirtualEnv, no need to keep it.
50   os.environ.pop('VIRTUAL_ENV', None)
51
52   # Set by VPython, if scripts want it back they have to set it explicitly.
53   os.environ.pop('PYTHONNOUSERSITE', None)
54
55   # Look for "activate_this.py" in this path, which is installed by VirtualEnv.
56   # This mechanism is used by vpython as well to sanitize VirtualEnvs from
57   # $PATH.
58   os.environ['PATH'] = os.pathsep.join([
59     p for p in os.environ.get('PATH', '').split(os.pathsep)
60     if not os.path.isfile(os.path.join(p, 'activate_this.py'))
61   ])
62
63
64 def main(args):
65   # Prune all evidence of VPython/VirtualEnv out of the environment. This means
66   # that we 'unwrap' vpython VirtualEnv path/env manipulation. Invocations of
67   # `python` from GN should never inherit the gn.py's own VirtualEnv. This also
68   # helps to ensure that generated ninja files do not reference python.exe from
69   # the VirtualEnv generated from depot_tools' own .vpython file (or lack
70   # thereof), but instead reference the default python from the PATH.
71   PruneVirtualEnv()
72
73   mbw = MetaBuildWrapper()
74   return mbw.Main(args)
75
76
77 class MetaBuildWrapper:
78   def __init__(self):
79     self.chromium_src_dir = CHROMIUM_SRC_DIR
80     self.default_config = os.path.join(self.chromium_src_dir, 'tools', 'mb',
81                                        'mb_config.pyl')
82     self.default_isolate_map = os.path.join(self.chromium_src_dir, 'testing',
83                                             'buildbot', 'gn_isolate_map.pyl')
84     self.executable = sys.executable
85     self.platform = sys.platform
86     self.sep = os.sep
87     self.args = argparse.Namespace()
88     self.configs = {}
89     self.public_artifact_builders = None
90     self.gn_args_locations_files = []
91     self.builder_groups = {}
92     self.mixins = {}
93     self.isolate_exe = 'isolate.exe' if self.platform.startswith(
94         'win') else 'isolate'
95     self.use_luci_auth = False
96
97   def PostArgsInit(self):
98     self.use_luci_auth = getattr(self.args, 'luci_auth', False)
99
100     if 'config_file' in self.args and self.args.config_file is None:
101       self.args.config_file = self.default_config
102
103     if 'expectations_dir' in self.args and self.args.expectations_dir is None:
104       self.args.expectations_dir = os.path.join(
105           os.path.dirname(self.args.config_file), 'mb_config_expectations')
106
107   def Main(self, args):
108     self.ParseArgs(args)
109     self.PostArgsInit()
110     try:
111       ret = self.args.func()
112       if ret != 0:
113         self.DumpInputFiles()
114       return ret
115     except KeyboardInterrupt:
116       self.Print('interrupted, exiting')
117       return 130
118     except Exception:
119       self.DumpInputFiles()
120       s = traceback.format_exc()
121       for l in s.splitlines():
122         self.Print(l)
123       return 1
124
125   def ParseArgs(self, argv):
126     def AddCommonOptions(subp):
127       group = subp.add_mutually_exclusive_group()
128       group.add_argument(
129           '-m',  '--builder-group',
130           help='builder group name to look up config from')
131       subp.add_argument('-b', '--builder',
132                         help='builder name to look up config from')
133       subp.add_argument('-c', '--config',
134                         help='configuration to analyze')
135       subp.add_argument('--phase',
136                         help='optional phase name (used when builders '
137                              'do multiple compiles with different '
138                              'arguments in a single build)')
139       subp.add_argument('-i', '--isolate-map-file', metavar='PATH',
140                         help='path to isolate map file '
141                              '(default is %(default)s)',
142                         default=[],
143                         action='append',
144                         dest='isolate_map_files')
145       subp.add_argument('-n', '--dryrun', action='store_true',
146                         help='Do a dry run (i.e., do nothing, just print '
147                              'the commands that will run)')
148       subp.add_argument('-v', '--verbose', action='store_true',
149                         help='verbose logging')
150       subp.add_argument('--root', help='Path to GN source root')
151       subp.add_argument('--dotfile', help='Path to GN dotfile')
152       AddExpansionOptions(subp)
153
154     def AddExpansionOptions(subp):
155       # These are the args needed to expand a config file into the full
156       # parsed dicts of GN args.
157       subp.add_argument('-f',
158                         '--config-file',
159                         metavar='PATH',
160                         help=('path to config file '
161                               '(default is mb_config.pyl'))
162       subp.add_argument('-g', '--goma-dir', help='path to goma directory')
163       subp.add_argument('--android-version-code',
164                         help='Sets GN arg android_default_version_code')
165       subp.add_argument('--android-version-name',
166                         help='Sets GN arg android_default_version_name')
167
168       # TODO(crbug.com/1060857): Remove this once swarming task templates
169       # support command prefixes.
170       luci_auth_group = subp.add_mutually_exclusive_group()
171       luci_auth_group.add_argument(
172           '--luci-auth',
173           action='store_true',
174           help='Run isolated commands under `luci-auth context`.')
175       luci_auth_group.add_argument(
176           '--no-luci-auth',
177           action='store_false',
178           dest='luci_auth',
179           help='Do not run isolated commands under `luci-auth context`.')
180
181     parser = argparse.ArgumentParser(
182       prog='mb', description='mb (meta-build) is a python wrapper around GN. '
183                              'See the user guide in '
184                              '//tools/mb/docs/user_guide.md for detailed usage '
185                              'instructions.')
186
187     subps = parser.add_subparsers()
188
189     subp = subps.add_parser('analyze',
190                             description='Analyze whether changes to a set of '
191                                         'files will cause a set of binaries to '
192                                         'be rebuilt.')
193     AddCommonOptions(subp)
194     subp.add_argument('path',
195                       help='path build was generated into.')
196     subp.add_argument('input_path',
197                       help='path to a file containing the input arguments '
198                            'as a JSON object.')
199     subp.add_argument('output_path',
200                       help='path to a file containing the output arguments '
201                            'as a JSON object.')
202     subp.add_argument('--json-output',
203                       help='Write errors to json.output')
204     subp.set_defaults(func=self.CmdAnalyze)
205
206     subp = subps.add_parser('export',
207                             description='Print out the expanded configuration '
208                             'for each builder as a JSON object.')
209     AddExpansionOptions(subp)
210     subp.set_defaults(func=self.CmdExport)
211
212     subp = subps.add_parser('get-swarming-command',
213                             description='Get the command needed to run the '
214                             'binary under swarming')
215     AddCommonOptions(subp)
216     subp.add_argument('--no-build',
217                       dest='build',
218                       default=True,
219                       action='store_false',
220                       help='Do not build, just isolate')
221     subp.add_argument('--as-list',
222                       action='store_true',
223                       help='return the command line as a JSON-formatted '
224                       'list of strings instead of single string')
225     subp.add_argument('path',
226                       help=('path to generate build into (or use).'
227                             ' This can be either a regular path or a '
228                             'GN-style source-relative path like '
229                             '//out/Default.'))
230     subp.add_argument('target', help='ninja target to build and run')
231     subp.set_defaults(func=self.CmdGetSwarmingCommand)
232
233     subp = subps.add_parser('train',
234                             description='Writes the expanded configuration '
235                             'for each builder as JSON files to a configured '
236                             'directory.')
237     subp.add_argument('-f',
238                       '--config-file',
239                       metavar='PATH',
240                       help='path to config file (default is mb_config.pyl')
241     subp.add_argument('--expectations-dir',
242                       metavar='PATH',
243                       help='path to dir containing expectation files')
244     subp.add_argument('-n',
245                       '--dryrun',
246                       action='store_true',
247                       help='Do a dry run (i.e., do nothing, just print '
248                       'the commands that will run)')
249     subp.add_argument('-v',
250                       '--verbose',
251                       action='store_true',
252                       help='verbose logging')
253     subp.set_defaults(func=self.CmdTrain)
254
255     subp = subps.add_parser('gen',
256                             description='Generate a new set of build files.')
257     AddCommonOptions(subp)
258     subp.add_argument('--swarming-targets-file',
259                       help='generates runtime dependencies for targets listed '
260                            'in file as .isolate and .isolated.gen.json files. '
261                            'Targets should be listed by name, separated by '
262                            'newline.')
263     subp.add_argument('--json-output',
264                       help='Write errors to json.output')
265     subp.add_argument('path',
266                       help='path to generate build into')
267     subp.set_defaults(func=self.CmdGen)
268
269     subp = subps.add_parser('isolate-everything',
270                             description='Generates a .isolate for all targets. '
271                                         'Requires that mb.py gen has already '
272                                         'been run.')
273     AddCommonOptions(subp)
274     subp.set_defaults(func=self.CmdIsolateEverything)
275     subp.add_argument('path',
276                       help='path build was generated into')
277     subp = subps.add_parser('isolate',
278                             description='Generate the .isolate files for a '
279                                         'given binary.')
280     AddCommonOptions(subp)
281     subp.add_argument('--no-build', dest='build', default=True,
282                       action='store_false',
283                       help='Do not build, just isolate')
284     subp.add_argument('-j', '--jobs', type=int,
285                       help='Number of jobs to pass to ninja')
286     subp.add_argument('path',
287                       help='path build was generated into')
288     subp.add_argument('target',
289                       help='ninja target to generate the isolate for')
290     subp.set_defaults(func=self.CmdIsolate)
291
292     subp = subps.add_parser('lookup',
293                             description='Look up the command for a given '
294                                         'config or builder.')
295     AddCommonOptions(subp)
296     subp.add_argument('--quiet', default=False, action='store_true',
297                       help='Print out just the arguments, '
298                            'do not emulate the output of the gen subcommand.')
299     subp.add_argument('--recursive', default=False, action='store_true',
300                       help='Lookup arguments from imported files, '
301                            'implies --quiet')
302     subp.set_defaults(func=self.CmdLookup)
303
304     subp = subps.add_parser(
305       'run', formatter_class=argparse.RawDescriptionHelpFormatter)
306     subp.description = (
307         'Build, isolate, and run the given binary with the command line\n'
308         'listed in the isolate. You may pass extra arguments after the\n'
309         'target; use "--" if the extra arguments need to include switches.\n'
310         '\n'
311         'Examples:\n'
312         '\n'
313         '  % tools/mb/mb.py run -m chromium.linux -b "Linux Builder" \\\n'
314         '    //out/Default content_browsertests\n'
315         '\n'
316         '  % tools/mb/mb.py run out/Default content_browsertests\n'
317         '\n'
318         '  % tools/mb/mb.py run out/Default content_browsertests -- \\\n'
319         '    --test-launcher-retry-limit=0'
320         '\n'
321     )
322     AddCommonOptions(subp)
323     subp.add_argument('-j', '--jobs', type=int,
324                       help='Number of jobs to pass to ninja')
325     subp.add_argument('--no-build', dest='build', default=True,
326                       action='store_false',
327                       help='Do not build, just isolate and run')
328     subp.add_argument('path',
329                       help=('path to generate build into (or use).'
330                             ' This can be either a regular path or a '
331                             'GN-style source-relative path like '
332                             '//out/Default.'))
333     subp.add_argument('-s', '--swarmed', action='store_true',
334                       help='Run under swarming with the default dimensions')
335     subp.add_argument('-d', '--dimension', default=[], action='append', nargs=2,
336                       dest='dimensions', metavar='FOO bar',
337                       help='dimension to filter on')
338     subp.add_argument('--internal',
339                       action='store_true',
340                       help=('Run under the internal swarming server '
341                             '(chrome-swarming) instead of the public server '
342                             '(chromium-swarm).'))
343     subp.add_argument('--no-bot-mode',
344                       dest='bot_mode',
345                       action='store_false',
346                       default=True,
347                       help='Do not run the test with bot mode.')
348     subp.add_argument('--realm',
349                       default=None,
350                       help=('Optional realm used when triggering swarming '
351                             'tasks.'))
352     subp.add_argument('--service-account',
353                       default=None,
354                       help=('Optional service account to run the swarming '
355                             'tasks as.'))
356     subp.add_argument('--tags', default=[], action='append', metavar='FOO:BAR',
357                       help='Tags to assign to the swarming task')
358     subp.add_argument('--no-default-dimensions', action='store_false',
359                       dest='default_dimensions', default=True,
360                       help='Do not automatically add dimensions to the task')
361     subp.add_argument('target',
362                       help='ninja target to build and run')
363     subp.add_argument('extra_args', nargs='*',
364                       help=('extra args to pass to the isolate to run. Use '
365                             '"--" as the first arg if you need to pass '
366                             'switches'))
367     subp.set_defaults(func=self.CmdRun)
368
369     subp = subps.add_parser('validate',
370                             description='Validate the config file.')
371     AddExpansionOptions(subp)
372     subp.add_argument('--expectations-dir',
373                       metavar='PATH',
374                       help='path to dir containing expectation files')
375     subp.add_argument('--skip-dcheck-check',
376                       help='Skip check for dcheck_always_on.',
377                       action='store_true')
378     subp.set_defaults(func=self.CmdValidate)
379
380     subp = subps.add_parser('zip',
381                             description='Generate a .zip containing the files '
382                                         'needed for a given binary.')
383     AddCommonOptions(subp)
384     subp.add_argument('--no-build', dest='build', default=True,
385                       action='store_false',
386                       help='Do not build, just isolate')
387     subp.add_argument('-j', '--jobs', type=int,
388                       help='Number of jobs to pass to ninja')
389     subp.add_argument('path',
390                       help='path build was generated into')
391     subp.add_argument('target',
392                       help='ninja target to generate the isolate for')
393     subp.add_argument('zip_path',
394                       help='path to zip file to create')
395     subp.set_defaults(func=self.CmdZip)
396
397     subp = subps.add_parser('help',
398                             help='Get help on a subcommand.')
399     subp.add_argument(nargs='?', action='store', dest='subcommand',
400                       help='The command to get help for.')
401     subp.set_defaults(func=self.CmdHelp)
402
403     self.args = parser.parse_args(argv)
404
405   def DumpInputFiles(self):
406
407     def DumpContentsOfFilePassedTo(arg_name, path):
408       if path and self.Exists(path):
409         self.Print("\n# To recreate the file passed to %s:" % arg_name)
410         self.Print("%% cat > %s <<EOF" % path)
411         contents = self.ReadFile(path)
412         self.Print(contents)
413         self.Print("EOF\n%\n")
414
415     if getattr(self.args, 'input_path', None):
416       DumpContentsOfFilePassedTo(
417           'argv[0] (input_path)', self.args.input_path)
418     if getattr(self.args, 'swarming_targets_file', None):
419       DumpContentsOfFilePassedTo(
420           '--swarming-targets-file', self.args.swarming_targets_file)
421
422   def CmdAnalyze(self):
423     vals = self.Lookup()
424     return self.RunGNAnalyze(vals)
425
426   def CmdExport(self):
427     obj = self._ToJsonish()
428     s = json.dumps(obj, sort_keys=True, indent=2, separators=(',', ': '))
429     self.Print(s)
430     return 0
431
432   def CmdTrain(self):
433     expectations_dir = self.args.expectations_dir
434     if not self.Exists(expectations_dir):
435       self.Print('Expectations dir (%s) does not exist.' % expectations_dir)
436       return 1
437     # Removing every expectation file then immediately re-generating them will
438     # clear out deleted groups.
439     for f in self.ListDir(expectations_dir):
440       self.RemoveFile(os.path.join(expectations_dir, f))
441     obj = self._ToJsonish()
442     for builder_group, builder in sorted(obj.items()):
443       expectation_file = os.path.join(expectations_dir, builder_group + '.json')
444       json_s = json.dumps(builder,
445                           indent=2,
446                           sort_keys=True,
447                           separators=(',', ': '))
448       self.WriteFile(expectation_file, json_s)
449     return 0
450
451   def CmdGen(self):
452     vals = self.Lookup()
453     return self.RunGNGen(vals)
454
455   def CmdGetSwarmingCommand(self):
456     vals = self.GetConfig()
457     command, _ = self.GetSwarmingCommand(self.args.target, vals)
458     if self.args.as_list:
459       self.Print(json.dumps(command))
460     else:
461       self.Print(' '.join(command))
462     return 0
463
464   def CmdIsolateEverything(self):
465     vals = self.Lookup()
466     return self.RunGNGenAllIsolates(vals)
467
468   def CmdHelp(self):
469     if self.args.subcommand:
470       self.ParseArgs([self.args.subcommand, '--help'])
471     else:
472       self.ParseArgs(['--help'])
473
474   def CmdIsolate(self):
475     vals = self.GetConfig()
476     if not vals:
477       return 1
478     if self.args.build:
479       ret = self.Build(self.args.target)
480       if ret != 0:
481         return ret
482     return self.RunGNIsolate(vals)
483
484   def CmdLookup(self):
485     vals = self.Lookup()
486     _, gn_args = self.GNArgs(vals, expand_imports=self.args.recursive)
487     if self.args.quiet or self.args.recursive:
488       self.Print(gn_args, end='')
489     else:
490       cmd = self.GNCmd('gen', '_path_')
491       self.Print('\nWriting """\\\n%s""" to _path_/args.gn.\n' % gn_args)
492       self.PrintCmd(cmd)
493     return 0
494
495   def CmdRun(self):
496     vals = self.GetConfig()
497     if not vals:
498       return 1
499     if self.args.build:
500       self.Print('')
501       ret = self.Build(self.args.target)
502       if ret:
503         return ret
504
505     self.Print('')
506     ret = self.RunGNIsolate(vals)
507     if ret:
508       return ret
509
510     self.Print('')
511     if self.args.swarmed:
512       cmd, _ = self.GetSwarmingCommand(self.args.target, vals)
513       return self._RunUnderSwarming(self.args.path, self.args.target, cmd,
514                                     self.args.internal)
515     return self._RunLocallyIsolated(self.args.path, self.args.target)
516
517   def CmdZip(self):
518     ret = self.CmdIsolate()
519     if ret:
520       return ret
521
522     zip_dir = None
523     try:
524       zip_dir = self.TempDir()
525       remap_cmd = [
526           self.PathJoin(self.chromium_src_dir, 'tools', 'luci-go',
527                         self.isolate_exe), 'remap', '-i',
528           self.PathJoin(self.args.path, self.args.target + '.isolate'),
529           '-outdir', zip_dir
530       ]
531       ret, _, _ = self.Run(remap_cmd)
532       if ret:
533         return ret
534
535       zip_path = self.args.zip_path
536       with zipfile.ZipFile(
537           zip_path, 'w', zipfile.ZIP_DEFLATED, allowZip64=True) as fp:
538         for root, _, files in os.walk(zip_dir):
539           for filename in files:
540             path = self.PathJoin(root, filename)
541             fp.write(path, self.RelPath(path, zip_dir))
542       return 0
543     finally:
544       if zip_dir:
545         self.RemoveDirectory(zip_dir)
546
547   def _RunUnderSwarming(self, build_dir, target, isolate_cmd, internal):
548     if internal:
549       cas_instance = 'chrome-swarming'
550       swarming_server = 'chrome-swarming.appspot.com'
551       realm = 'chrome:try' if not self.args.realm else self.args.realm
552       account = 'chrome-tester@chops-service-accounts.iam.gserviceaccount.com'
553     else:
554       cas_instance = 'chromium-swarm'
555       swarming_server = 'chromium-swarm.appspot.com'
556       realm = self.args.realm
557       account = 'chromium-tester@chops-service-accounts.iam.gserviceaccount.com'
558     account = (self.args.service_account
559                if self.args.service_account else account)
560     # TODO(dpranke): Look up the information for the target in
561     # the //testing/buildbot.json file, if possible, so that we
562     # can determine the isolate target, command line, and additional
563     # swarming parameters, if possible.
564     #
565     # TODO(dpranke): Also, add support for sharding and merging results.
566     dimensions = []
567     for k, v in self._DefaultDimensions() + self.args.dimensions:
568       dimensions += ['-d', '%s=%s' % (k, v)]
569
570     archive_json_path = self.ToSrcRelPath(
571         '%s/%s.archive.json' % (build_dir, target))
572     cmd = [
573         self.PathJoin(self.chromium_src_dir, 'tools', 'luci-go',
574                       self.isolate_exe),
575         'archive',
576         '-i',
577         self.ToSrcRelPath('%s/%s.isolate' % (build_dir, target)),
578         '-cas-instance',
579         cas_instance,
580         '-dump-json',
581         archive_json_path,
582     ]
583
584     # Talking to the isolateserver may fail because we're not logged in.
585     # We trap the command explicitly and rewrite the error output so that
586     # the error message is actually correct for a Chromium check out.
587     self.PrintCmd(cmd)
588     ret, out, _ = self.Run(cmd, force_verbose=False)
589     if ret:
590       self.Print('  -> returned %d' % ret)
591       if out:
592         self.Print(out, end='')
593       return ret
594
595     try:
596       archive_hashes = json.loads(self.ReadFile(archive_json_path))
597     except Exception:
598       self.Print(
599           'Failed to read JSON file "%s"' % archive_json_path, file=sys.stderr)
600       return 1
601     try:
602       cas_digest = archive_hashes[target]
603     except Exception:
604       self.Print(
605           'Cannot find hash for "%s" in "%s", file content: %s' %
606           (target, archive_json_path, archive_hashes),
607           file=sys.stderr)
608       return 1
609
610     tags = ['-tag=%s' % tag for tag in self.args.tags]
611
612     try:
613       json_dir = self.TempDir()
614       json_file = self.PathJoin(json_dir, 'task.json')
615       cmd = [
616           self.PathJoin('tools', 'luci-go', 'swarming'),
617           'trigger',
618           '-digest',
619           cas_digest,
620           '-server',
621           swarming_server,
622           # 30 is try level. So use the same here.
623           '-priority',
624           '30',
625           '-service-account',
626           account,
627           '-tag=purpose:user-debug-mb',
628           '-relative-cwd',
629           self.ToSrcRelPath(build_dir),
630           '-dump-json',
631           json_file,
632       ]
633       if realm:
634         cmd += ['--realm', realm]
635       cmd += tags + dimensions + ['--'] + list(isolate_cmd)
636       if self.args.extra_args:
637         cmd += self.args.extra_args
638       self.Print('')
639       ret, _, _ = self.Run(cmd, force_verbose=True, capture_output=False)
640       if ret:
641         return ret
642       task_json = self.ReadFile(json_file)
643       task_id = json.loads(task_json)["tasks"][0]['task_id']
644       collect_output = self.PathJoin(json_dir, 'collect_output.json')
645       cmd = [
646           self.PathJoin('tools', 'luci-go', 'swarming'),
647           'collect',
648           '-server',
649           swarming_server,
650           '-task-output-stdout=console',
651           '-task-summary-json',
652           collect_output,
653           task_id,
654       ]
655       ret, _, _ = self.Run(cmd, force_verbose=True, capture_output=False)
656       if ret != 0:
657         return ret
658       collect_json = json.loads(self.ReadFile(collect_output))
659       # The exit_code field might not be included if the task was successful.
660       ret = int(
661           collect_json.get(task_id, {}).get('results', {}).get('exit_code', 0))
662     finally:
663       if json_dir:
664         self.RemoveDirectory(json_dir)
665     return ret
666
667   def _RunLocallyIsolated(self, build_dir, target):
668     cmd = [
669         self.PathJoin(self.chromium_src_dir, 'tools', 'luci-go',
670                       self.isolate_exe),
671         'run',
672         '-i',
673         self.ToSrcRelPath('%s/%s.isolate' % (build_dir, target)),
674     ]
675     if self.args.extra_args:
676       cmd += ['--'] + self.args.extra_args
677     ret, _, _ = self.Run(cmd, force_verbose=True, capture_output=False)
678     return ret
679
680   def _DefaultDimensions(self):
681     if not self.args.default_dimensions:
682       return []
683
684     # This code is naive and just picks reasonable defaults per platform.
685     if self.platform == 'darwin':
686       os_dim = ('os', 'Mac-10.13')
687     elif self.platform.startswith('linux'):
688       os_dim = ('os', 'Ubuntu-16.04')
689     elif self.platform == 'win32':
690       os_dim = ('os', 'Windows-10')
691     else:
692       raise MBErr('unrecognized platform string "%s"' % self.platform)
693
694     return [('pool', 'chromium.tests'),
695             ('cpu', 'x86-64'),
696             os_dim]
697
698   def _ToJsonish(self):
699     """Dumps the config file into a json-friendly expanded dict.
700
701     Returns:
702       A dict with builder group -> builder -> all GN args mapping.
703     """
704     self.ReadConfigFile(self.args.config_file)
705     obj = {}
706     for builder_group, builders in self.builder_groups.items():
707       obj[builder_group] = {}
708       for builder in builders:
709         config = self.builder_groups[builder_group][builder]
710         if not config:
711           continue
712
713         def flatten(config):
714           flattened_config = FlattenConfig(self.configs, self.mixins, config)
715           if flattened_config['gn_args'] == 'error':
716             return None
717           args = {'gn_args': gn_helpers.FromGNArgs(flattened_config['gn_args'])}
718           if flattened_config.get('args_file'):
719             args['args_file'] = flattened_config['args_file']
720           return args
721
722         if isinstance(config, dict):
723           # This is a 'phased' builder. Each key in the config is a different
724           # phase of the builder.
725           args = {}
726           for k, v in config.items():
727             flattened = flatten(v)
728             if flattened is None:
729               continue
730             args[k] = flattened
731         elif config.startswith('//'):
732           args = config
733         else:
734           args = flatten(config)
735           if args is None:
736             continue
737         obj[builder_group][builder] = args
738
739     return obj
740
741   def CmdValidate(self, print_ok=True):
742     errs = []
743
744     self.ReadConfigFile(self.args.config_file)
745
746     # Build a list of all of the configs referenced by builders.
747     all_configs = validation.GetAllConfigs(self.builder_groups)
748
749     # Check that every referenced args file or config actually exists.
750     for config, loc in all_configs.items():
751       if config.startswith('//'):
752         if not self.Exists(self.ToAbsPath(config)):
753           errs.append('Unknown args file "%s" referenced from "%s".' %
754                       (config, loc))
755       elif not config in self.configs:
756         errs.append('Unknown config "%s" referenced from "%s".' %
757                     (config, loc))
758
759     # Check that every config and mixin is referenced.
760     validation.CheckAllConfigsAndMixinsReferenced(errs, all_configs,
761                                                   self.configs, self.mixins)
762
763     if self.args.config_file == self.default_config:
764       validation.EnsureNoProprietaryMixins(errs, self.builder_groups,
765                                            self.configs, self.mixins)
766
767     validation.CheckDuplicateConfigs(errs, self.configs, self.mixins,
768                                      self.builder_groups, FlattenConfig)
769
770     if not self.args.skip_dcheck_check:
771       self._ValidateEach(errs, validation.CheckDebugDCheckOrOfficial)
772
773     if errs:
774       raise MBErr(('mb config file %s has problems:\n  ' %
775                    self.args.config_file) + '\n  '.join(errs))
776
777     expectations_dir = self.args.expectations_dir
778     # TODO(crbug.com/1117577): Force all versions of mb_config.pyl to have
779     # expectations. For now, just ignore those that don't have them.
780     if self.Exists(expectations_dir):
781       jsonish_blob = self._ToJsonish()
782       if not validation.CheckExpectations(self, jsonish_blob, expectations_dir):
783         raise MBErr("Expectations out of date. Run 'tools/mb/mb.py train'.")
784
785     validation.CheckKeyOrdering(errs, self.builder_groups, self.configs,
786                                 self.mixins)
787     if errs:
788       raise MBErr('mb config file not sorted:\n' + '\n'.join(errs))
789
790     if print_ok:
791       self.Print('mb config file %s looks ok.' % self.args.config_file)
792     return 0
793
794   def _ValidateEach(self, errs, validate):
795     """Checks a validate function against every builder config.
796
797     This loops over all the builders in the config file, invoking the
798     validate function against the full set of GN args. Any errors found
799     should be appended to the errs list passed in; the validation
800     function signature is
801
802         validate(errs:list, gn_args:dict, builder_group:str, builder:str,
803                  phase:(str|None))"""
804
805     for builder_group, builders in self.builder_groups.items():
806       for builder, config in builders.items():
807         if isinstance(config, dict):
808           for phase, phase_config in config.items():
809             vals = FlattenConfig(self.configs, self.mixins, phase_config)
810             if vals['gn_args'] == 'error':
811               continue
812             try:
813               parsed_gn_args, _ = self.GNArgs(vals, expand_imports=True)
814             except IOError:
815               # The builder must use an args file that was not checked out or
816               # generated, so we should just ignore it.
817               parsed_gn_args, _ = self.GNArgs(vals, expand_imports=False)
818             validate(errs, parsed_gn_args, builder_group, builder, phase)
819         else:
820           vals = FlattenConfig(self.configs, self.mixins, config)
821           if vals['gn_args'] == 'error':
822             continue
823           try:
824             parsed_gn_args, _ = self.GNArgs(vals, expand_imports=True)
825           except IOError:
826             # The builder must use an args file that was not checked out or
827             # generated, so we should just ignore it.
828             parsed_gn_args, _ = self.GNArgs(vals, expand_imports=False)
829           validate(errs, parsed_gn_args, builder_group, builder, phase=None)
830
831   def GetConfig(self):
832     build_dir = self.args.path
833
834     vals = DefaultVals()
835     if self.args.builder or self.args.builder_group or self.args.config:
836       vals = self.Lookup()
837       # Re-run gn gen in order to ensure the config is consistent with the
838       # build dir.
839       self.RunGNGen(vals)
840       return vals
841
842     toolchain_path = self.PathJoin(self.ToAbsPath(build_dir),
843                                    'toolchain.ninja')
844     if not self.Exists(toolchain_path):
845       self.Print('Must either specify a path to an existing GN build dir '
846                  'or pass in a -m/-b pair or a -c flag to specify the '
847                  'configuration')
848       return {}
849
850     vals['gn_args'] = self.GNArgsFromDir(build_dir)
851     return vals
852
853   def GNArgsFromDir(self, build_dir):
854     args_contents = ""
855     gn_args_path = self.PathJoin(self.ToAbsPath(build_dir), 'args.gn')
856     if self.Exists(gn_args_path):
857       args_contents = self.ReadFile(gn_args_path)
858
859     # Handle any .gni file imports, e.g. the ones used by CrOS. This should
860     # be automatically handled by gn_helpers.FromGNArgs (via its call to
861     # gn_helpers.GNValueParser.ReplaceImports), but that currently breaks
862     # mb_unittest since it mocks out file reads itself instead of using
863     # pyfakefs. This results in gn_helpers trying to read a non-existent file.
864     # The implementation of ReplaceImports here can be removed once the
865     # unittests use pyfakefs.
866     def ReplaceImports(input_contents):
867       output_contents = ''
868       for l in input_contents.splitlines(True):
869         if not l.strip().startswith('#') and 'import(' in l:
870           import_file = l.split('"', 2)[1]
871           import_file = self.ToAbsPath(import_file)
872           imported_contents = self.ReadFile(import_file)
873           output_contents += ReplaceImports(imported_contents) + '\n'
874         else:
875           output_contents += l
876       return output_contents
877
878     args_contents = ReplaceImports(args_contents)
879     args_dict = gn_helpers.FromGNArgs(args_contents)
880     return self._convert_args_dict_to_args_string(args_dict)
881
882   def _convert_args_dict_to_args_string(self, args_dict):
883     """Format a dict of GN args into a single string."""
884     for k, v in args_dict.items():
885       if isinstance(v, str):
886         # Re-add the quotes around strings so they show up as they would in the
887         # args.gn file.
888         args_dict[k] = '"%s"' % v
889       elif isinstance(v, bool):
890         # Convert boolean values to lower case strings.
891         args_dict[k] = str(v).lower()
892     return ' '.join(['%s=%s' % (k, v) for (k, v) in args_dict.items()])
893
894   def Lookup(self):
895     self.ReadConfigFile(self.args.config_file)
896     try:
897       config = self.ConfigFromArgs()
898     except MBErr as e:
899       # TODO(crbug.com/912681) While iOS bots are migrated to use the
900       # Chromium recipe, we want to ensure that we're checking MB's
901       # configurations first before going to iOS.
902       # This is to be removed once the migration is complete.
903       vals = self.ReadIOSBotConfig()
904       if not vals:
905         raise e
906       return vals
907
908     # "config" would be a dict if the GN args are loaded from a
909     # starlark-generated file.
910     if isinstance(config, dict):
911       return config
912
913     # TODO(crbug.com/912681) Some iOS bots have a definition, with ios_error
914     # as an indicator that it's incorrect. We utilize this to check the
915     # iOS JSON instead, and error out if there exists no definition at all.
916     # This is to be removed once the migration is complete.
917     if config == 'ios_error':
918       vals = self.ReadIOSBotConfig()
919       if not vals:
920         raise MBErr('No iOS definition was found. Please ensure there is a '
921                     'definition for the given iOS bot under '
922                     'mb_config.pyl or a JSON file definition under '
923                     '//ios/build/bots.')
924       return vals
925
926     if config.startswith('//'):
927       if not self.Exists(self.ToAbsPath(config)):
928         raise MBErr('args file "%s" not found' % config)
929       vals = DefaultVals()
930       vals['args_file'] = config
931     else:
932       if not config in self.configs:
933         raise MBErr(
934             'Config "%s" not found in %s' % (config, self.args.config_file))
935       vals = FlattenConfig(self.configs, self.mixins, config)
936     return vals
937
938   def ReadIOSBotConfig(self):
939     if not self.args.builder_group or not self.args.builder:
940       return {}
941     path = self.PathJoin(self.chromium_src_dir, 'ios', 'build', 'bots',
942                          self.args.builder_group, self.args.builder + '.json')
943     if not self.Exists(path):
944       return {}
945
946     contents = json.loads(self.ReadFile(path))
947     gn_args = ' '.join(contents.get('gn_args', []))
948
949     vals = DefaultVals()
950     vals['gn_args'] = gn_args
951     return vals
952
953   def ReadConfigFile(self, config_file):
954     if not self.Exists(config_file):
955       raise MBErr('config file not found at %s' % config_file)
956
957     try:
958       contents = ast.literal_eval(self.ReadFile(config_file))
959     except SyntaxError as e:
960       raise MBErr('Failed to parse config file "%s": %s' %
961                   (config_file, e)) from e
962
963     self.configs = contents['configs']
964     self.mixins = contents['mixins']
965     self.gn_args_locations_files = contents.get('gn_args_locations_files', [])
966     self.builder_groups = contents.get('builder_groups')
967     self.public_artifact_builders = contents.get('public_artifact_builders')
968
969   def ReadIsolateMap(self):
970     if not self.args.isolate_map_files:
971       self.args.isolate_map_files = [self.default_isolate_map]
972
973     for f in self.args.isolate_map_files:
974       if not self.Exists(f):
975         raise MBErr('isolate map file not found at %s' % f)
976     isolate_maps = {}
977     for isolate_map in self.args.isolate_map_files:
978       try:
979         isolate_map = ast.literal_eval(self.ReadFile(isolate_map))
980         duplicates = set(isolate_map).intersection(isolate_maps)
981         if duplicates:
982           raise MBErr(
983               'Duplicate targets in isolate map files: %s.' %
984               ', '.join(duplicates))
985         isolate_maps.update(isolate_map)
986       except SyntaxError as e:
987         raise MBErr('Failed to parse isolate map file "%s": %s' %
988                     (isolate_map, e)) from e
989     return isolate_maps
990
991   def ConfigFromArgs(self):
992     if self.args.config:
993       if self.args.builder_group or self.args.builder:
994         raise MBErr('Can not specific both -c/--config and --builder-group '
995                     'or -b/--builder')
996
997       return self.args.config
998
999     if not self.args.builder_group or not self.args.builder:
1000       raise MBErr('Must specify either -c/--config or '
1001                   '(--builder-group and -b/--builder)')
1002
1003     # Try finding gn-args.json generated by starlark definition.
1004     for gn_args_locations_file in self.gn_args_locations_files:
1005       locations_file_abs_path = os.path.join(
1006           os.path.dirname(self.args.config_file),
1007           os.path.normpath(gn_args_locations_file))
1008       if not self.Exists(locations_file_abs_path):
1009         continue
1010       gn_args_locations = json.loads(self.ReadFile(locations_file_abs_path))
1011       gn_args_file = gn_args_locations.get(self.args.builder_group,
1012                                            {}).get(self.args.builder, None)
1013       if gn_args_file:
1014         gn_args_dict = json.loads(
1015             self.ReadFile(
1016                 os.path.join(os.path.dirname(locations_file_abs_path),
1017                              os.path.normpath(gn_args_file))))
1018
1019         if 'phases' in gn_args_dict:
1020           # The builder has phased GN config.
1021           if self.args.phase is None:
1022             raise MBErr('Must specify a build --phase for %s on %s' %
1023                         (self.args.builder, self.args.builder_group))
1024           phase = str(self.args.phase)
1025           phase_configs = gn_args_dict['phases']
1026           if phase not in phase_configs:
1027             raise MBErr('Phase %s doesn\'t exist for %s on %s' %
1028                         (phase, self.args.builder, self.args.builder_group))
1029           gn_args_dict = phase_configs[phase]
1030         else:
1031           # Non-phased GN config.
1032           if self.args.phase is not None:
1033             raise MBErr('Must not specify a build --phase for %s on %s' %
1034                         (self.args.builder, self.args.builder_group))
1035         return {
1036             'args_file':
1037             gn_args_dict.get('args_file', ''),
1038             'gn_args':
1039             self._convert_args_dict_to_args_string(
1040                 gn_args_dict.get('gn_args', {})) or ''
1041         }
1042
1043     if not self.args.builder_group in self.builder_groups:
1044       raise MBErr('Builder group name "%s" not found in "%s"' %
1045                   (self.args.builder_group, self.args.config_file))
1046
1047     if not self.args.builder in self.builder_groups[self.args.builder_group]:
1048       raise MBErr('Builder name "%s"  not found under groups[%s] in "%s"' %
1049                   (self.args.builder, self.args.builder_group,
1050                    self.args.config_file))
1051
1052     config = self.builder_groups[self.args.builder_group][self.args.builder]
1053     if isinstance(config, dict):
1054       if self.args.phase is None:
1055         raise MBErr('Must specify a build --phase for %s on %s' %
1056                     (self.args.builder, self.args.builder_group))
1057       phase = str(self.args.phase)
1058       if phase not in config:
1059         raise MBErr('Phase %s doesn\'t exist for %s on %s' %
1060                     (phase, self.args.builder, self.args.builder_group))
1061       return config[phase]
1062
1063     if self.args.phase is not None:
1064       raise MBErr('Must not specify a build --phase for %s on %s' %
1065                   (self.args.builder, self.args.builder_group))
1066     return config
1067
1068   def RunGNGen(self, vals, compute_inputs_for_analyze=False, check=True):
1069     build_dir = self.args.path
1070
1071     if check:
1072       cmd = self.GNCmd('gen', build_dir, '--check')
1073     else:
1074       cmd = self.GNCmd('gen', build_dir)
1075     _, gn_args = self.GNArgs(vals)
1076     if compute_inputs_for_analyze:
1077       gn_args += ' compute_inputs_for_analyze=true'
1078
1079     # Since GN hasn't run yet, the build directory may not even exist.
1080     self.MaybeMakeDirectory(self.ToAbsPath(build_dir))
1081
1082     gn_args_path = self.ToAbsPath(build_dir, 'args.gn')
1083     self.WriteFile(gn_args_path, gn_args, force_verbose=True)
1084
1085     if getattr(self.args, 'swarming_targets_file', None):
1086       # We need GN to generate the list of runtime dependencies for
1087       # the compile targets listed (one per line) in the file so
1088       # we can run them via swarming. We use gn_isolate_map.pyl to convert
1089       # the compile targets to the matching GN labels.
1090       path = self.args.swarming_targets_file
1091       if not self.Exists(path):
1092         self.WriteFailureAndRaise('"%s" does not exist' % path,
1093                                   output_path=None)
1094       contents = self.ReadFile(path)
1095       isolate_targets = set(contents.splitlines())
1096
1097       isolate_map = self.ReadIsolateMap()
1098       self.RemovePossiblyStaleRuntimeDepsFiles(vals, isolate_targets,
1099                                                isolate_map, build_dir)
1100
1101       err, labels = self.MapTargetsToLabels(isolate_map, isolate_targets)
1102       if err:
1103         raise MBErr(err)
1104
1105       gn_runtime_deps_path = self.ToAbsPath(build_dir, 'runtime_deps')
1106       self.WriteFile(gn_runtime_deps_path, '\n'.join(labels) + '\n')
1107       cmd.append('--runtime-deps-list-file=%s' % gn_runtime_deps_path)
1108
1109     # Write all generated targets to a JSON file called project.json
1110     # in the build dir.
1111     cmd.append('--ide=json')
1112     cmd.append('--json-file-name=project.json')
1113
1114     ret, output, _ = self.Run(cmd)
1115     if ret != 0:
1116       if self.args.json_output:
1117         # write errors to json.output
1118         self.WriteJSON({'output': output}, self.args.json_output)
1119       # If `gn gen` failed, we should exit early rather than trying to
1120       # generate isolates. Run() will have already logged any error output.
1121       self.Print('GN gen failed: %d' % ret)
1122       return ret
1123
1124     if getattr(self.args, 'swarming_targets_file', None):
1125       ret = self.GenerateIsolates(vals, isolate_targets, isolate_map, build_dir)
1126
1127     return ret
1128
1129   def RunGNGenAllIsolates(self, vals):
1130     """
1131     This command generates all .isolate files.
1132
1133     This command assumes that "mb.py gen" has already been run, as it relies on
1134     "gn ls" to fetch all gn targets. If uses that output, combined with the
1135     isolate_map, to determine all isolates that can be generated for the current
1136     gn configuration.
1137     """
1138     build_dir = self.args.path
1139     ret, output, _ = self.Run(self.GNCmd('ls', build_dir),
1140                               force_verbose=False)
1141     if ret != 0:
1142       # If `gn ls` failed, we should exit early rather than trying to
1143       # generate isolates.
1144       self.Print('GN ls failed: %d' % ret)
1145       return ret
1146
1147     # Create a reverse map from isolate label to isolate dict.
1148     isolate_map = self.ReadIsolateMap()
1149     isolate_dict_map = {}
1150     for key, isolate_dict in isolate_map.items():
1151       isolate_dict_map[isolate_dict['label']] = isolate_dict
1152       isolate_dict_map[isolate_dict['label']]['isolate_key'] = key
1153
1154     runtime_deps = []
1155
1156     isolate_targets = []
1157     # For every GN target, look up the isolate dict.
1158     for line in output.splitlines():
1159       target = line.strip()
1160       if target in isolate_dict_map:
1161         if isolate_dict_map[target]['type'] == 'additional_compile_target':
1162           # By definition, additional_compile_targets are not tests, so we
1163           # shouldn't generate isolates for them.
1164           continue
1165
1166         isolate_targets.append(isolate_dict_map[target]['isolate_key'])
1167         runtime_deps.append(target)
1168
1169     self.RemovePossiblyStaleRuntimeDepsFiles(vals, isolate_targets,
1170                                              isolate_map, build_dir)
1171
1172     gn_runtime_deps_path = self.ToAbsPath(build_dir, 'runtime_deps')
1173     self.WriteFile(gn_runtime_deps_path, '\n'.join(runtime_deps) + '\n')
1174     cmd = self.GNCmd('gen', build_dir)
1175     cmd.append('--runtime-deps-list-file=%s' % gn_runtime_deps_path)
1176     self.Run(cmd)
1177
1178     return self.GenerateIsolates(vals, isolate_targets, isolate_map, build_dir)
1179
1180   def RemovePossiblyStaleRuntimeDepsFiles(self, vals, targets, isolate_map,
1181                                           build_dir):
1182     # TODO(crbug.com/932700): Because `gn gen --runtime-deps-list-file`
1183     # puts the runtime_deps file in different locations based on the actual
1184     # type of a target, we may end up with multiple possible runtime_deps
1185     # files in a given build directory, where some of the entries might be
1186     # stale (since we might be reusing an existing build directory).
1187     #
1188     # We need to be able to get the right one reliably; you might think
1189     # we can just pick the newest file, but because GN won't update timestamps
1190     # if the contents of the files change, an older runtime_deps
1191     # file might actually be the one we should use over a newer one (see
1192     # crbug.com/932387 for a more complete explanation and example).
1193     #
1194     # In order to avoid this, we need to delete any possible runtime_deps
1195     # files *prior* to running GN. As long as the files aren't actually
1196     # needed during the build, this hopefully will not cause unnecessary
1197     # build work, and so it should be safe.
1198     #
1199     # Ultimately, we should just make sure we get the runtime_deps files
1200     # in predictable locations so we don't have this issue at all, and
1201     # that's what crbug.com/932700 is for.
1202     possible_rpaths = self.PossibleRuntimeDepsPaths(vals, targets, isolate_map)
1203     for rpaths in possible_rpaths.values():
1204       for rpath in rpaths:
1205         path = self.ToAbsPath(build_dir, rpath)
1206         if self.Exists(path):
1207           self.RemoveFile(path)
1208
1209   def _FilterOutUnneededSkylabDeps(self, deps):
1210     """Filter out the runtime dependencies not used by Skylab.
1211
1212     Skylab is CrOS infra facilities for us to run hardware tests. These files
1213     may appear in the test target's runtime_deps for browser lab, but
1214     unnecessary for CrOS lab.
1215     """
1216     file_ignore_list = [
1217         re.compile(r'.*build/chromeos.*'),
1218         re.compile(r'.*build/cros_cache.*'),
1219         # No test target should rely on files in [output_dir]/gen.
1220         re.compile(r'^gen/.*'),
1221     ]
1222     return [f for f in deps if not any(r.match(f) for r in file_ignore_list)]
1223
1224   def _DedupDependencies(self, deps):
1225     """Remove the deps already contained by other paths."""
1226
1227     def _add(root, path):
1228       cur = path.popleft()
1229       # Only continue the recursion if the path has child nodes
1230       # AND the current node is not ended by other existing paths.
1231       if path and root.get(cur) != {}:
1232         return _add(root.setdefault(cur, {}), path)
1233       # Cut this path, because child nodes are already included.
1234       root[cur] = {}
1235       return root
1236
1237     def _list(root, prefix, res):
1238       for k, v in root.items():
1239         if v == {}:
1240           res.append('%s/%s' % (prefix, k))
1241           continue
1242         _list(v, '%s/%s' % (prefix, k), res)
1243       return res
1244
1245     root = {}
1246     for d in deps:
1247       q = collections.deque(d.rstrip('/').split('/'))
1248       _add(root, q)
1249     return [p.lstrip('/') for p in _list(root, '', [])]
1250
1251   def GenerateIsolates(self, vals, ninja_targets, isolate_map, build_dir):
1252     """
1253     Generates isolates for a list of ninja targets.
1254
1255     Ninja targets are transformed to GN targets via isolate_map.
1256
1257     This function assumes that a previous invocation of "mb.py gen" has
1258     generated runtime deps for all targets.
1259     """
1260     possible_rpaths = self.PossibleRuntimeDepsPaths(vals, ninja_targets,
1261                                                     isolate_map)
1262
1263     for target, rpaths in possible_rpaths.items():
1264       # TODO(crbug.com/932700): We don't know where each .runtime_deps
1265       # file might be, but assuming we called
1266       # RemovePossiblyStaleRuntimeDepsFiles prior to calling `gn gen`,
1267       # there should only be one file.
1268       found_one = False
1269       path_to_use = None
1270       for r in rpaths:
1271         path = self.ToAbsPath(build_dir, r)
1272         if self.Exists(path):
1273           if found_one:
1274             raise MBErr('Found more than one of %s' % ', '.join(rpaths))
1275           path_to_use = path
1276           found_one = True
1277
1278       if not found_one:
1279         raise MBErr('Did not find any of %s' % ', '.join(rpaths))
1280
1281       command, extra_files = self.GetSwarmingCommand(target, vals)
1282       runtime_deps = self.ReadFile(path_to_use).splitlines()
1283       runtime_deps = self._DedupDependencies(runtime_deps)
1284       # TODO(crbug.com/1481305): Lacros gtest may need files from folders
1285       # filtered out here. Eventually, we should move the filter to builder
1286       # specific config. Before that, leave the filter only for Ash.
1287       if ('is_skylab=true' in vals['gn_args']
1288           and not 'chromeos_is_browser_only=true' in vals['gn_args']):
1289         runtime_deps = self._FilterOutUnneededSkylabDeps(runtime_deps)
1290
1291       canonical_target = target.replace(':','_').replace('/','_')
1292       ret = self.WriteIsolateFiles(build_dir, command, canonical_target,
1293                                    runtime_deps, vals, extra_files)
1294       if ret != 0:
1295         return ret
1296     return 0
1297
1298   def PossibleRuntimeDepsPaths(self, vals, ninja_targets, isolate_map):
1299     """Returns a map of targets to possible .runtime_deps paths.
1300
1301     Each ninja target maps on to a GN label, but depending on the type
1302     of the GN target, `gn gen --runtime-deps-list-file` will write
1303     the .runtime_deps files into different locations. Unfortunately, in
1304     some cases we don't actually know which of multiple locations will
1305     actually be used, so we return all plausible candidates.
1306
1307     The paths that are returned are relative to the build directory.
1308     """
1309
1310     android = 'target_os="android"' in vals['gn_args']
1311     ios = 'target_os="ios"' in vals['gn_args']
1312     fuchsia = 'target_os="fuchsia"' in vals['gn_args']
1313     win = self.platform == 'win32' or 'target_os="win"' in vals['gn_args']
1314     possible_runtime_deps_rpaths = {}
1315     for target in ninja_targets:
1316       target_type = isolate_map[target]['type']
1317       label = isolate_map[target]['label']
1318       stamp_runtime_deps = 'obj/%s.stamp.runtime_deps' % label.replace(':', '/')
1319       # TODO(https://crbug.com/876065): 'official_tests' use
1320       # type='additional_compile_target' to isolate tests. This is not the
1321       # intended use for 'additional_compile_target'.
1322       if (target_type == 'additional_compile_target' and
1323           target != 'official_tests'):
1324         # By definition, additional_compile_targets are not tests, so we
1325         # shouldn't generate isolates for them.
1326         raise MBErr('Cannot generate isolate for %s since it is an '
1327                     'additional_compile_target.' % target)
1328       if fuchsia or ios or target_type == 'generated_script':
1329         # iOS and Fuchsia targets end up as groups.
1330         # generated_script targets are always actions.
1331         rpaths = [stamp_runtime_deps]
1332       elif android:
1333         # Android targets may be either android_apk or executable. The former
1334         # will result in runtime_deps associated with the stamp file, while the
1335         # latter will result in runtime_deps associated with the executable.
1336         label = isolate_map[target]['label']
1337         rpaths = [
1338             target + '.runtime_deps',
1339             stamp_runtime_deps]
1340       elif (target_type == 'script'
1341             or isolate_map[target].get('label_type') == 'group'):
1342         # For script targets, the build target is usually a group,
1343         # for which gn generates the runtime_deps next to the stamp file
1344         # for the label, which lives under the obj/ directory, but it may
1345         # also be an executable.
1346         label = isolate_map[target]['label']
1347         rpaths = [stamp_runtime_deps]
1348         if win:
1349           rpaths += [ target + '.exe.runtime_deps' ]
1350         else:
1351           rpaths += [ target + '.runtime_deps' ]
1352       elif win:
1353         rpaths = [target + '.exe.runtime_deps']
1354       else:
1355         rpaths = [target + '.runtime_deps']
1356
1357       possible_runtime_deps_rpaths[target] = rpaths
1358
1359     return possible_runtime_deps_rpaths
1360
1361   def RunGNIsolate(self, vals):
1362     target = self.args.target
1363     isolate_map = self.ReadIsolateMap()
1364     err, labels = self.MapTargetsToLabels(isolate_map, [target])
1365     if err:
1366       raise MBErr(err)
1367
1368     label = labels[0]
1369
1370     build_dir = self.args.path
1371
1372     command, extra_files = self.GetSwarmingCommand(target, vals)
1373
1374     # Any warning for an unused arg will get interleaved into the cmd's
1375     # stdout. When that happens, the isolate step below will fail with an
1376     # obscure error when it tries processing the lines of the warning. Fail
1377     # quickly in that case to avoid confusion
1378     cmd = self.GNCmd('desc', build_dir, label, 'runtime_deps',
1379                      '--fail-on-unused-args')
1380     ret, out, _ = self.Call(cmd)
1381     if ret != 0:
1382       if out:
1383         self.Print(out)
1384       return ret
1385
1386     runtime_deps = []
1387     for l in out.splitlines():
1388       # FIXME: Can remove this check if/when use_goma is removed.
1389       if 'The gn arg use_goma=true will be deprecated by EOY 2023' not in l:
1390         runtime_deps.append(l)
1391
1392     ret = self.WriteIsolateFiles(build_dir, command, target, runtime_deps, vals,
1393                                  extra_files)
1394     if ret != 0:
1395       return ret
1396
1397     ret, _, _ = self.Run([
1398         self.PathJoin(self.chromium_src_dir, 'tools', 'luci-go',
1399                       self.isolate_exe),
1400         'check',
1401         '-i',
1402         self.ToSrcRelPath('%s/%s.isolate' % (build_dir, target)),
1403     ],
1404                          capture_output=False)
1405
1406     return ret
1407
1408   def WriteIsolateFiles(self, build_dir, command, target, runtime_deps, vals,
1409                         extra_files):
1410     isolate_path = self.ToAbsPath(build_dir, target + '.isolate')
1411     files = sorted(set(runtime_deps + extra_files))
1412
1413     # Complain if any file is a directory that's inside the build directory,
1414     # since that makes incremental builds incorrect. See
1415     # https://crbug.com/912946
1416     is_android = 'target_os="android"' in vals['gn_args']
1417     is_cros = ('target_os="chromeos"' in vals['gn_args']
1418                or 'is_chromeos_device=true' in vals['gn_args'])
1419     is_msan = 'is_msan=true' in vals['gn_args']
1420     is_ios = 'target_os="ios"' in vals['gn_args']
1421     # pylint: disable=consider-using-ternary
1422     is_mac = ((self.platform == 'darwin' and not is_ios)
1423               or 'target_os="mac"' in vals['gn_args'])
1424
1425     err = ''
1426     for f in files:
1427       # Skip a few configs that need extra cleanup for now.
1428       # TODO(https://crbug.com/912946): Fix everything on all platforms and
1429       # enable check everywhere.
1430       if is_android:
1431         break
1432
1433       # iOS has generated directories in gn data items.
1434       # Skipping for iOS instead of listing all apps.
1435       if is_ios:
1436         break
1437
1438       # Skip a few existing violations that need to be cleaned up. Each of
1439       # these will lead to incorrect incremental builds if their directory
1440       # contents change. Do not add to this list, except for mac bundles until
1441       # crbug.com/1000667 is fixed.
1442       # TODO(https://crbug.com/912946): Remove this if statement.
1443       if ((is_msan and f == 'instrumented_libraries_prebuilt/')
1444           or f == 'mr_extension/' or  # https://crbug.com/997947
1445           f.startswith('nacl_test_data/') or
1446           f.startswith('ppapi_nacl_tests_libs/') or
1447           (is_cros and f in (  # https://crbug.com/1002509
1448               'chromevox_test_data/',
1449               'gen/ui/file_manager/file_manager/',
1450               'resources/chromeos/',
1451               'resources/chromeos/accessibility/accessibility_common/',
1452               'resources/chromeos/accessibility/chromevox/',
1453               'resources/chromeos/accessibility/select_to_speak/',
1454               'test_data/chrome/browser/resources/chromeos/accessibility/'
1455               'accessibility_common/',
1456               'test_data/chrome/browser/resources/chromeos/accessibility/'
1457               'chromevox/',
1458               'test_data/chrome/browser/resources/chromeos/accessibility/'
1459               'select_to_speak/',
1460           )) or (is_mac and f in (  # https://crbug.com/1000667
1461               'Chromium Framework.framework/',
1462               'Chromium Helper.app/',
1463               'Chromium.app/',
1464               'ChromiumUpdater.app/',
1465               'ChromiumUpdater_test.app/',
1466               'Content Shell.app/',
1467               'Google Chrome Framework.framework/',
1468               'Google Chrome Helper (Alerts).app/',
1469               'Google Chrome Helper (GPU).app/',
1470               'Google Chrome Helper (Plugin).app/',
1471               'Google Chrome Helper (Renderer).app/',
1472               'Google Chrome Helper.app/',
1473               'Google Chrome.app/',
1474               'GoogleUpdater.app/',
1475               'GoogleUpdater_test.app/',
1476               'UpdaterTestApp Framework.framework/',
1477               'UpdaterTestApp.app/',
1478               'blink_deprecated_test_plugin.plugin/',
1479               'blink_test_plugin.plugin/',
1480               'corb_test_plugin.plugin/',
1481               'obj/tools/grit/brotli_mac_asan_workaround/',
1482               'ppapi_tests.plugin/',
1483               'ui_unittests Framework.framework/',
1484           ))):
1485         continue
1486
1487       # This runs before the build, so we can't use isdir(f). But
1488       # isolate.py luckily requires data directories to end with '/', so we
1489       # can check for that.
1490       if not f.startswith('../../') and f.endswith('/'):
1491         # Don't use self.PathJoin() -- all involved paths consistently use
1492         # forward slashes, so don't add one single backslash on Windows.
1493         err += '\n' + build_dir + '/' +  f
1494
1495     if err:
1496       self.Print('error: gn `data` items may not list generated directories; '
1497                  'list files in directory instead for:' + err)
1498       return 1
1499
1500     isolate = {
1501         'variables': {
1502             'command': command,
1503             'files': files,
1504         }
1505     }
1506
1507     self.WriteFile(isolate_path, json.dumps(isolate, sort_keys=True) + '\n')
1508
1509     self.WriteJSON(
1510       {
1511         'args': [
1512           '--isolate',
1513           self.ToSrcRelPath('%s/%s.isolate' % (build_dir, target)),
1514         ],
1515         'dir': self.chromium_src_dir,
1516         'version': 1,
1517       },
1518       isolate_path + 'd.gen.json',
1519     )
1520
1521     return 0
1522
1523   def MapTargetsToLabels(self, isolate_map, targets):
1524     labels = []
1525     err = ''
1526
1527     for target in targets:
1528       if target == 'all':
1529         labels.append(target)
1530       elif target.startswith('//'):
1531         labels.append(target)
1532       else:
1533         if target in isolate_map:
1534           if isolate_map[target]['type'] == 'unknown':
1535             err += ('test target "%s" type is unknown\n' % target)
1536           else:
1537             labels.append(isolate_map[target]['label'])
1538         else:
1539           err += ('target "%s" not found in '
1540                   '//testing/buildbot/gn_isolate_map.pyl\n' % target)
1541
1542     return err, labels
1543
1544   def GNCmd(self, subcommand, path, *args):
1545     if self.platform.startswith('linux'):
1546       subdir, exe = 'linux64', 'gn'
1547     elif self.platform == 'darwin':
1548       subdir, exe = 'mac', 'gn'
1549     elif self.platform == 'aix6':
1550       subdir, exe = 'aix', 'gn'
1551     else:
1552       subdir, exe = 'win', 'gn.exe'
1553
1554     gn_path = self.PathJoin(self.chromium_src_dir, 'buildtools', subdir, exe)
1555     cmd = [gn_path, subcommand]
1556     if self.args.root:
1557       cmd += ['--root=' + self.args.root]
1558     if self.args.dotfile:
1559       cmd += ['--dotfile=' + self.args.dotfile]
1560     return cmd + [path] + list(args)
1561
1562   def GNArgs(self, vals, expand_imports=False):
1563     """Returns the gn args from vals as a Python dict and a text string.
1564
1565     If expand_imports is true, any import() lines will be read in and
1566     valuese them will be included."""
1567     gn_args = vals['gn_args']
1568
1569     if self.args.goma_dir:
1570       gn_args += ' goma_dir="%s"' % self.args.goma_dir
1571
1572     android_version_code = self.args.android_version_code
1573     if android_version_code:
1574       gn_args += ' android_default_version_code="%s"' % android_version_code
1575
1576     android_version_name = self.args.android_version_name
1577     if android_version_name:
1578       gn_args += ' android_default_version_name="%s"' % android_version_name
1579
1580     args_gn_lines = []
1581     parsed_gn_args = {}
1582
1583     args_file = vals.get('args_file', None)
1584     if args_file:
1585       if expand_imports:
1586         content = self.ReadFile(self.ToAbsPath(args_file))
1587         parsed_gn_args = gn_helpers.FromGNArgs(content)
1588       else:
1589         args_gn_lines.append('import("%s")' % args_file)
1590
1591     # Canonicalize the arg string into a sorted, newline-separated list
1592     # of key-value pairs, and de-dup the keys if need be so that only
1593     # the last instance of each arg is listed.
1594     parsed_gn_args.update(gn_helpers.FromGNArgs(gn_args))
1595     args_gn_lines.append(gn_helpers.ToGNString(parsed_gn_args))
1596
1597     return parsed_gn_args, '\n'.join(args_gn_lines)
1598
1599   def GetSwarmingCommand(self, target, vals):
1600     isolate_map = self.ReadIsolateMap()
1601
1602     is_android = 'target_os="android"' in vals['gn_args']
1603     is_fuchsia = 'target_os="fuchsia"' in vals['gn_args']
1604     is_cros = ('target_os="chromeos"' in vals['gn_args']
1605                or 'is_chromeos_device=true' in vals['gn_args'])
1606     is_cros_device = 'is_chromeos_device=true' in vals['gn_args']
1607     is_ios = 'target_os="ios"' in vals['gn_args']
1608     # pylint: disable=consider-using-ternary
1609     is_mac = ((self.platform == 'darwin' and not is_ios)
1610               or 'target_os="mac"' in vals['gn_args'])
1611     is_win = self.platform == 'win32' or 'target_os="win"' in vals['gn_args']
1612     is_lacros = 'chromeos_is_browser_only=true' in vals['gn_args']
1613
1614     # This should be true if tests with type='windowed_test_launcher' are
1615     # expected to run using xvfb. For example, Linux Desktop, X11 CrOS and
1616     # Ozone CrOS builds on Linux (xvfb is not used on CrOS HW or VMs). Note
1617     # that one Ozone build can be used to run different backends. Currently,
1618     # tests are executed for the headless and X11 backends and both can run
1619     # under Xvfb on Linux.
1620     use_xvfb = (self.platform.startswith('linux') and not is_android
1621                 and not is_fuchsia and not is_cros_device)
1622
1623     asan = 'is_asan=true' in vals['gn_args']
1624     lsan = 'is_lsan=true' in vals['gn_args']
1625     msan = 'is_msan=true' in vals['gn_args']
1626     tsan = 'is_tsan=true' in vals['gn_args']
1627     cfi_diag = 'use_cfi_diag=true' in vals['gn_args']
1628     # Treat sanitizer warnings as test case failures (crbug/1442587).
1629     fail_on_san_warnings = 'fail_on_san_warnings=true' in vals['gn_args']
1630     clang_coverage = 'use_clang_coverage=true' in vals['gn_args']
1631     java_coverage = 'use_jacoco_coverage=true' in vals['gn_args']
1632     javascript_coverage = 'use_javascript_coverage=true' in vals['gn_args']
1633
1634     test_type = isolate_map[target]['type']
1635
1636     if self.use_luci_auth:
1637       cmdline = ['luci-auth.exe' if is_win else 'luci-auth', 'context', '--']
1638     else:
1639       cmdline = []
1640
1641     if getattr(self.args, 'bot_mode', True):
1642       bot_mode = ('--test-launcher-bot-mode', )
1643     else:
1644       bot_mode = ()
1645
1646     if test_type == 'generated_script' or is_ios or is_lacros:
1647       assert 'script' not in isolate_map[target], (
1648           'generated_scripts can no longer customize the script path')
1649       if is_win:
1650         default_script = 'bin\\run_{}.bat'.format(target)
1651       else:
1652         default_script = 'bin/run_{}'.format(target)
1653       script = isolate_map[target].get('script', default_script)
1654
1655       # TODO(crbug.com/816629): remove any use of 'args' from
1656       # generated_scripts.
1657       cmdline += [script] + isolate_map[target].get('args', [])
1658
1659       if java_coverage:
1660         cmdline += ['--coverage-dir', '${ISOLATED_OUTDIR}/coverage']
1661
1662       return cmdline, []
1663
1664
1665     # TODO(crbug.com/816629): Convert all targets to generated_scripts
1666     # and delete the rest of this function.
1667     executable = isolate_map[target].get('executable', target)
1668     executable_suffix = isolate_map[target].get(
1669         'executable_suffix', '.exe' if is_win else '')
1670
1671     vpython_exe = 'vpython3'
1672     extra_files = [
1673         '../../.vpython3',
1674         '../../testing/test_env.py',
1675     ]
1676
1677     if is_android and test_type != 'script':
1678       if asan:
1679         cmdline += [os.path.join('bin', 'run_with_asan'), '--']
1680       cmdline += [
1681           vpython_exe, '../../build/android/test_wrapper/logdog_wrapper.py',
1682           '--target', target, '--logdog-bin-cmd',
1683           '../../.task_template_packages/logdog_butler'
1684       ]
1685       if test_type != 'junit_test':
1686         cmdline += ['--store-tombstones']
1687       if clang_coverage or java_coverage:
1688         cmdline += ['--coverage-dir', '${ISOLATED_OUTDIR}']
1689     elif is_fuchsia and test_type != 'script':
1690       # On Fuchsia, the generated bin/run_* test scripts are used both in
1691       # infrastructure and by developers. test_env.py is intended to establish a
1692       # predictable environment for automated testing. In particular, it adds
1693       # CHROME_HEADLESS=1 to the environment for child processes. This variable
1694       # is a signal to both test and production code that it is running in the
1695       # context of automated an testing environment, and should not be present
1696       # for normal developer workflows.
1697       cmdline += [
1698           vpython_exe,
1699           '../../testing/test_env.py',
1700           os.path.join('bin', 'run_%s' % target),
1701           *bot_mode,
1702           '--logs-dir=${ISOLATED_OUTDIR}',
1703       ]
1704     elif is_cros_device and test_type != 'script':
1705       cmdline += [
1706           os.path.join('bin', 'run_%s' % target),
1707           '--logs-dir=${ISOLATED_OUTDIR}',
1708       ]
1709     elif use_xvfb and test_type == 'windowed_test_launcher':
1710       extra_files.append('../../testing/xvfb.py')
1711       cmdline += [
1712           vpython_exe,
1713           '../../testing/xvfb.py',
1714           './' + str(executable) + executable_suffix,
1715           *bot_mode,
1716           '--asan=%d' % asan,
1717           '--lsan=%d' % asan,  # Enable lsan when asan is enabled.
1718           '--msan=%d' % msan,
1719           '--tsan=%d' % tsan,
1720           '--cfi-diag=%d' % cfi_diag,
1721       ]
1722
1723       if fail_on_san_warnings:
1724         cmdline += ['--fail-san=1']
1725
1726       if javascript_coverage:
1727         cmdline += ['--devtools-code-coverage=${ISOLATED_OUTDIR}']
1728     elif test_type in ('windowed_test_launcher', 'console_test_launcher'):
1729       cmdline += [
1730           vpython_exe,
1731           '../../testing/test_env.py',
1732           './' + str(executable) + executable_suffix,
1733           *bot_mode,
1734           '--asan=%d' % asan,
1735           # Enable lsan when asan is enabled except on Windows where LSAN isn't
1736           # supported.
1737           # TODO(https://crbug.com/1320449): Enable on Mac inside asan once
1738           # things pass.
1739           # TODO(https://crbug.com/974478): Enable on ChromeOS once things pass.
1740           '--lsan=%d' % lsan
1741           or (asan and not is_mac and not is_win and not is_cros),
1742           '--msan=%d' % msan,
1743           '--tsan=%d' % tsan,
1744           '--cfi-diag=%d' % cfi_diag,
1745       ]
1746
1747       if fail_on_san_warnings:
1748         cmdline += ['--fail-san=1']
1749     elif test_type == 'script':
1750       # If we're testing a CrOS simplechrome build, assume we need to prepare a
1751       # DUT for testing. So prepend the command to run with the test wrapper.
1752       if is_cros_device:
1753         cmdline += [
1754             os.path.join('bin', 'cros_test_wrapper'),
1755             '--logs-dir=${ISOLATED_OUTDIR}',
1756         ]
1757       if is_android:
1758         extra_files.append('../../build/android/test_wrapper/logdog_wrapper.py')
1759         cmdline += [
1760             vpython_exe,
1761             '../../testing/test_env.py',
1762             '../../build/android/test_wrapper/logdog_wrapper.py',
1763             '--script',
1764             '../../' + self.ToSrcRelPath(isolate_map[target]['script']),
1765             '--logdog-bin-cmd',
1766             '../../.task_template_packages/logdog_butler',
1767         ]
1768       else:
1769         cmdline += [
1770             vpython_exe, '../../testing/test_env.py',
1771             '../../' + self.ToSrcRelPath(isolate_map[target]['script'])
1772         ]
1773     elif test_type == 'additional_compile_target':
1774       cmdline = [
1775           './' + str(target) + executable_suffix,
1776       ]
1777     else:
1778       self.WriteFailureAndRaise('No command line for %s found (test type %s).'
1779                                 % (target, test_type), output_path=None)
1780
1781     cmdline += isolate_map[target].get('args', [])
1782
1783     return cmdline, extra_files
1784
1785   def ToAbsPath(self, build_path, *comps):
1786     return self.PathJoin(self.chromium_src_dir,
1787                          self.ToSrcRelPath(build_path),
1788                          *comps)
1789
1790   def ToSrcRelPath(self, path):
1791     """Returns a relative path from the top of the repo."""
1792     if path.startswith('//'):
1793       return path[2:].replace('/', self.sep)
1794     return self.RelPath(path, self.chromium_src_dir)
1795
1796   def RunGNAnalyze(self, vals):
1797     # Analyze runs before 'gn gen' now, so we need to run gn gen
1798     # in order to ensure that we have a build directory.
1799     ret = self.RunGNGen(vals, compute_inputs_for_analyze=True, check=False)
1800     if ret != 0:
1801       return ret
1802
1803     build_path = self.args.path
1804     input_path = self.args.input_path
1805     gn_input_path = input_path + '.gn'
1806     output_path = self.args.output_path
1807     gn_output_path = output_path + '.gn'
1808
1809     inp = self.ReadInputJSON(['files', 'test_targets',
1810                               'additional_compile_targets'])
1811     if self.args.verbose:
1812       self.Print()
1813       self.Print('analyze input:')
1814       self.PrintJSON(inp)
1815       self.Print()
1816
1817
1818     # This shouldn't normally happen, but could due to unusual race conditions,
1819     # like a try job that gets scheduled before a patch lands but runs after
1820     # the patch has landed.
1821     if not inp['files']:
1822       self.Print('Warning: No files modified in patch, bailing out early.')
1823       self.WriteJSON({
1824             'status': 'No dependency',
1825             'compile_targets': [],
1826             'test_targets': [],
1827           }, output_path)
1828       return 0
1829
1830     gn_inp = {}
1831     gn_inp['files'] = ['//' + f for f in inp['files'] if not f.startswith('//')]
1832
1833     isolate_map = self.ReadIsolateMap()
1834     err, gn_inp['additional_compile_targets'] = self.MapTargetsToLabels(
1835         isolate_map, inp['additional_compile_targets'])
1836     if err:
1837       raise MBErr(err)
1838
1839     err, gn_inp['test_targets'] = self.MapTargetsToLabels(
1840         isolate_map, inp['test_targets'])
1841     if err:
1842       raise MBErr(err)
1843     labels_to_targets = {}
1844     for i, label in enumerate(gn_inp['test_targets']):
1845       labels_to_targets[label] = inp['test_targets'][i]
1846
1847     try:
1848       self.WriteJSON(gn_inp, gn_input_path)
1849       cmd = self.GNCmd('analyze', build_path, gn_input_path, gn_output_path)
1850       ret, output, _ = self.Run(cmd, force_verbose=True)
1851       if ret != 0:
1852         if self.args.json_output:
1853           # write errors to json.output
1854           self.WriteJSON({'output': output}, self.args.json_output)
1855         return ret
1856
1857       gn_outp_str = self.ReadFile(gn_output_path)
1858       try:
1859         gn_outp = json.loads(gn_outp_str)
1860       except Exception as e:
1861         self.Print("Failed to parse the JSON string GN returned: %s\n%s"
1862                    % (repr(gn_outp_str), str(e)))
1863         raise
1864
1865       outp = {}
1866       if 'status' in gn_outp:
1867         outp['status'] = gn_outp['status']
1868       if 'error' in gn_outp:
1869         outp['error'] = gn_outp['error']
1870       if 'invalid_targets' in gn_outp:
1871         outp['invalid_targets'] = gn_outp['invalid_targets']
1872       if 'compile_targets' in gn_outp:
1873         all_input_compile_targets = sorted(
1874             set(inp['test_targets'] + inp['additional_compile_targets']))
1875
1876         # If we're building 'all', we can throw away the rest of the targets
1877         # since they're redundant.
1878         if 'all' in gn_outp['compile_targets']:
1879           outp['compile_targets'] = ['all']
1880         else:
1881           outp['compile_targets'] = gn_outp['compile_targets']
1882
1883         # crbug.com/736215: When GN returns targets back, for targets in
1884         # the default toolchain, GN will have generated a phony ninja
1885         # target matching the label, and so we can safely (and easily)
1886         # transform any GN label into the matching ninja target. For
1887         # targets in other toolchains, though, GN doesn't generate the
1888         # phony targets, and we don't know how to turn the labels into
1889         # compile targets. In this case, we also conservatively give up
1890         # and build everything. Probably the right thing to do here is
1891         # to have GN return the compile targets directly.
1892         if any("(" in target for target in outp['compile_targets']):
1893           self.Print('WARNING: targets with non-default toolchains were '
1894                      'found, building everything instead.')
1895           outp['compile_targets'] = all_input_compile_targets
1896         else:
1897           outp['compile_targets'] = [
1898               label.replace('//', '') for label in outp['compile_targets']]
1899
1900         # Windows has a maximum command line length of 8k; even Linux
1901         # maxes out at 128k; if analyze returns a *really long* list of
1902         # targets, we just give up and conservatively build everything instead.
1903         # Probably the right thing here is for ninja to support response
1904         # files as input on the command line
1905         # (see https://github.com/ninja-build/ninja/issues/1355).
1906         # Android targets use a lot of templates and often exceed 7kb.
1907         # https://crbug.com/946266
1908         max_cmd_length_kb = 64 if platform.system() == 'Linux' else 7
1909
1910         if len(' '.join(outp['compile_targets'])) > max_cmd_length_kb * 1024:
1911           self.Print('WARNING: Too many compile targets were affected.')
1912           self.Print('WARNING: Building everything instead to avoid '
1913                      'command-line length issues.')
1914           outp['compile_targets'] = all_input_compile_targets
1915
1916
1917       if 'test_targets' in gn_outp:
1918         outp['test_targets'] = [
1919           labels_to_targets[label] for label in gn_outp['test_targets']]
1920
1921       if self.args.verbose:
1922         self.Print()
1923         self.Print('analyze output:')
1924         self.PrintJSON(outp)
1925         self.Print()
1926
1927       self.WriteJSON(outp, output_path)
1928
1929     finally:
1930       if self.Exists(gn_input_path):
1931         self.RemoveFile(gn_input_path)
1932       if self.Exists(gn_output_path):
1933         self.RemoveFile(gn_output_path)
1934
1935     return 0
1936
1937   def ReadInputJSON(self, required_keys):
1938     path = self.args.input_path
1939     output_path = self.args.output_path
1940     if not self.Exists(path):
1941       self.WriteFailureAndRaise('"%s" does not exist' % path, output_path)
1942
1943     try:
1944       inp = json.loads(self.ReadFile(path))
1945     except Exception as e:
1946       self.WriteFailureAndRaise('Failed to read JSON input from "%s": %s' %
1947                                 (path, e), output_path)
1948
1949     for k in required_keys:
1950       if not k in inp:
1951         self.WriteFailureAndRaise('input file is missing a "%s" key' % k,
1952                                   output_path)
1953
1954     return inp
1955
1956   def WriteFailureAndRaise(self, msg, output_path):
1957     if output_path:
1958       self.WriteJSON({'error': msg}, output_path, force_verbose=True)
1959     raise MBErr(msg)
1960
1961   def WriteJSON(self, obj, path, force_verbose=False):
1962     try:
1963       self.WriteFile(path, json.dumps(obj, indent=2, sort_keys=True) + '\n',
1964                      force_verbose=force_verbose)
1965     except Exception as e:
1966       raise MBErr('Error %s writing to the output path "%s"' % (e, path)) from e
1967
1968   def PrintCmd(self, cmd):
1969     if self.platform == 'win32':
1970       shell_quoter = QuoteForCmd
1971     else:
1972       shell_quoter = shlex.quote
1973
1974     if cmd[0] == self.executable:
1975       cmd = ['python'] + cmd[1:]
1976     self.Print(*[shell_quoter(arg) for arg in cmd])
1977
1978   def PrintJSON(self, obj):
1979     self.Print(json.dumps(obj, indent=2, sort_keys=True))
1980
1981   def Build(self, target):
1982     build_dir = self.ToSrcRelPath(self.args.path)
1983     if self.platform == 'win32':
1984       # On Windows use the batch script since there is no exe
1985       ninja_cmd = ['autoninja.bat', '-C', build_dir]
1986     else:
1987       ninja_cmd = ['autoninja', '-C', build_dir]
1988     if self.args.jobs:
1989       ninja_cmd.extend(['-j', '%d' % self.args.jobs])
1990     ninja_cmd.append(target)
1991     ret, _, _ = self.Run(ninja_cmd, capture_output=False)
1992     return ret
1993
1994   def Run(self, cmd, env=None, force_verbose=True, capture_output=True):
1995     # This function largely exists so it can be overridden for testing.
1996     if self.args.dryrun or self.args.verbose or force_verbose:
1997       self.PrintCmd(cmd)
1998     if self.args.dryrun:
1999       return 0, '', ''
2000
2001     ret, out, err = self.Call(cmd, env=env, capture_output=capture_output)
2002     if self.args.verbose or force_verbose:
2003       if ret != 0:
2004         self.Print('  -> returned %d' % ret)
2005       if out:
2006         # This is the error seen on the logs
2007         self.Print(out, end='')
2008       if err:
2009         self.Print(err, end='', file=sys.stderr)
2010     return ret, out, err
2011
2012   # Call has argument input to match subprocess.run
2013   def Call(
2014       self,
2015       cmd,
2016       env=None,
2017       capture_output=True,
2018       input='',
2019   ):  # pylint: disable=redefined-builtin
2020     # We are returning the exit code, we don't want an exception thrown
2021     # for non-zero exit code
2022     # pylint: disable=subprocess-run-check
2023     p = subprocess.run(cmd,
2024                        shell=False,
2025                        capture_output=capture_output,
2026                        cwd=self.chromium_src_dir,
2027                        env=env,
2028                        text=True,
2029                        input=input)
2030     return p.returncode, p.stdout, p.stderr
2031
2032   def Exists(self, path):
2033     # This function largely exists so it can be overridden for testing.
2034     return os.path.exists(path)
2035
2036   def Fetch(self, url):
2037     # This function largely exists so it can be overridden for testing.
2038     f = urllib.request.urlopen(url)
2039     contents = f.read()
2040     f.close()
2041     return contents
2042
2043   def ListDir(self, path):
2044     # This function largely exists so it can be overridden for testing.
2045     return os.listdir(path)
2046
2047   def MaybeMakeDirectory(self, path):
2048     try:
2049       os.makedirs(path)
2050     except OSError as e:
2051       if e.errno != errno.EEXIST:
2052         raise
2053
2054   def PathJoin(self, *comps):
2055     # This function largely exists so it can be overriden for testing.
2056     return os.path.join(*comps)
2057
2058   def Print(self, *args, **kwargs):
2059     # This function largely exists so it can be overridden for testing.
2060     print(*args, **kwargs)
2061     if kwargs.get('stream', sys.stdout) == sys.stdout:
2062       sys.stdout.flush()
2063
2064   def ReadFile(self, path):
2065     # This function largely exists so it can be overriden for testing.
2066     with open(path) as fp:
2067       return fp.read()
2068
2069   def RelPath(self, path, start='.'):
2070     # This function largely exists so it can be overriden for testing.
2071     return os.path.relpath(path, start)
2072
2073   def RemoveFile(self, path):
2074     # This function largely exists so it can be overriden for testing.
2075     os.remove(path)
2076
2077   def RemoveDirectory(self, abs_path):
2078     if self.platform == 'win32':
2079       # In other places in chromium, we often have to retry this command
2080       # because we're worried about other processes still holding on to
2081       # file handles, but when MB is invoked, it will be early enough in the
2082       # build that their should be no other processes to interfere. We
2083       # can change this if need be.
2084       self.Run(['cmd.exe', '/c', 'rmdir', '/q', '/s', abs_path])
2085     else:
2086       shutil.rmtree(abs_path, ignore_errors=True)
2087
2088   def TempDir(self):
2089     # This function largely exists so it can be overriden for testing.
2090     return tempfile.mkdtemp(prefix='mb_')
2091
2092   def TempFile(self, mode='w'):
2093     # This function largely exists so it can be overriden for testing.
2094     return tempfile.NamedTemporaryFile(mode=mode, delete=False)
2095
2096   def WriteFile(self, path, contents, force_verbose=False):
2097     # This function largely exists so it can be overriden for testing.
2098     if self.args.dryrun or self.args.verbose or force_verbose:
2099       self.Print('\nWriting """\\\n%s""" to %s.\n' % (contents, path))
2100     with open(path, 'w', encoding='utf-8', newline='') as fp:
2101       return fp.write(contents)
2102
2103
2104 def FlattenConfig(config_pool, mixin_pool, config):
2105   mixins = config_pool[config]
2106   vals = DefaultVals()
2107
2108   visited = []
2109   FlattenMixins(mixin_pool, mixins, vals, visited)
2110   return vals
2111
2112
2113 def FlattenMixins(mixin_pool, mixins_to_flatten, vals, visited):
2114   for m in mixins_to_flatten:
2115     if m not in mixin_pool:
2116       raise MBErr('Unknown mixin "%s"' % m)
2117
2118     visited.append(m)
2119
2120     mixin_vals = mixin_pool[m]
2121
2122     if 'args_file' in mixin_vals:
2123       if vals['args_file']:
2124         raise MBErr('args_file specified multiple times in mixins '
2125                     'for mixin %s' % m)
2126       vals['args_file'] = mixin_vals['args_file']
2127     if 'gn_args' in mixin_vals:
2128       if vals['gn_args']:
2129         vals['gn_args'] += ' ' + mixin_vals['gn_args']
2130       else:
2131         vals['gn_args'] = mixin_vals['gn_args']
2132
2133     if 'mixins' in mixin_vals:
2134       FlattenMixins(mixin_pool, mixin_vals['mixins'], vals, visited)
2135   return vals
2136
2137
2138
2139 class MBErr(Exception):
2140   pass
2141
2142
2143 # See http://goo.gl/l5NPDW and http://goo.gl/4Diozm for the painful
2144 # details of this next section, which handles escaping command lines
2145 # so that they can be copied and pasted into a cmd window.
2146 UNSAFE_FOR_SET = set('^<>&|')
2147 UNSAFE_FOR_CMD = UNSAFE_FOR_SET.union(set('()%'))
2148 ALL_META_CHARS = UNSAFE_FOR_CMD.union(set('"'))
2149
2150
2151 def QuoteForSet(arg):
2152   if any(a in UNSAFE_FOR_SET for a in arg):
2153     arg = ''.join('^' + a if a in UNSAFE_FOR_SET else a for a in arg)
2154   return arg
2155
2156
2157 def QuoteForCmd(arg):
2158   # First, escape the arg so that CommandLineToArgvW will parse it properly.
2159   if arg == '' or ' ' in arg or '"' in arg:
2160     quote_re = re.compile(r'(\\*)"')
2161     arg = '"%s"' % (quote_re.sub(lambda mo: 2 * mo.group(1) + '\\"', arg))
2162
2163   # Then check to see if the arg contains any metacharacters other than
2164   # double quotes; if it does, quote everything (including the double
2165   # quotes) for safety.
2166   if any(a in UNSAFE_FOR_CMD for a in arg):
2167     arg = ''.join('^' + a if a in ALL_META_CHARS else a for a in arg)
2168   return arg
2169
2170
2171 if __name__ == '__main__':
2172   sys.exit(main(sys.argv[1:]))