Upstream version 5.34.92.0
[platform/framework/web/crosswalk.git] / src / tools / swarming_client / isolate.py
1 #!/usr/bin/env python
2 # Copyright 2012 The Swarming Authors. All rights reserved.
3 # Use of this source code is governed under the Apache License, Version 2.0 that
4 # can be found in the LICENSE file.
5
6 """Front end tool to operate on .isolate files.
7
8 This includes creating, merging or compiling them to generate a .isolated file.
9
10 See more information at
11   https://code.google.com/p/swarming/wiki/IsolateDesign
12   https://code.google.com/p/swarming/wiki/IsolateUserGuide
13 """
14 # Run ./isolate.py --help for more detailed information.
15
16 import datetime
17 import logging
18 import optparse
19 import os
20 import posixpath
21 import re
22 import subprocess
23 import sys
24
25 import isolate_format
26 import isolateserver
27 import run_isolated
28 import trace_inputs
29
30 # Import here directly so isolate is easier to use as a library.
31 from run_isolated import get_flavor
32
33 from third_party import colorama
34 from third_party.depot_tools import fix_encoding
35 from third_party.depot_tools import subcommand
36
37 from utils import file_path
38 from utils import tools
39
40
41 __version__ = '0.2'
42
43
44 class ExecutionError(Exception):
45   """A generic error occurred."""
46   def __str__(self):
47     return self.args[0]
48
49
50 ### Path handling code.
51
52
53 def expand_directories_and_symlinks(indir, infiles, blacklist,
54                                     follow_symlinks, ignore_broken_items):
55   """Expands the directories and the symlinks, applies the blacklist and
56   verifies files exist.
57
58   Files are specified in os native path separator.
59   """
60   outfiles = []
61   for relfile in infiles:
62     try:
63       outfiles.extend(
64           isolateserver.expand_directory_and_symlink(
65               indir, relfile, blacklist, follow_symlinks))
66     except isolateserver.MappingError as e:
67       if ignore_broken_items:
68         logging.info('warning: %s', e)
69       else:
70         raise
71   return outfiles
72
73
74 def recreate_tree(outdir, indir, infiles, action, as_hash):
75   """Creates a new tree with only the input files in it.
76
77   Arguments:
78     outdir:    Output directory to create the files in.
79     indir:     Root directory the infiles are based in.
80     infiles:   dict of files to map from |indir| to |outdir|.
81     action:    One of accepted action of run_isolated.link_file().
82     as_hash:   Output filename is the hash instead of relfile.
83   """
84   logging.info(
85       'recreate_tree(outdir=%s, indir=%s, files=%d, action=%s, as_hash=%s)' %
86       (outdir, indir, len(infiles), action, as_hash))
87
88   assert os.path.isabs(outdir) and outdir == os.path.normpath(outdir), outdir
89   if not os.path.isdir(outdir):
90     logging.info('Creating %s' % outdir)
91     os.makedirs(outdir)
92
93   for relfile, metadata in infiles.iteritems():
94     infile = os.path.join(indir, relfile)
95     if as_hash:
96       # Do the hashtable specific checks.
97       if 'l' in metadata:
98         # Skip links when storing a hashtable.
99         continue
100       outfile = os.path.join(outdir, metadata['h'])
101       if os.path.isfile(outfile):
102         # Just do a quick check that the file size matches. No need to stat()
103         # again the input file, grab the value from the dict.
104         if not 's' in metadata:
105           raise isolateserver.MappingError(
106               'Misconfigured item %s: %s' % (relfile, metadata))
107         if metadata['s'] == os.stat(outfile).st_size:
108           continue
109         else:
110           logging.warn('Overwritting %s' % metadata['h'])
111           os.remove(outfile)
112     else:
113       outfile = os.path.join(outdir, relfile)
114       outsubdir = os.path.dirname(outfile)
115       if not os.path.isdir(outsubdir):
116         os.makedirs(outsubdir)
117
118     # TODO(csharp): Fix crbug.com/150823 and enable the touched logic again.
119     # if metadata.get('T') == True:
120     #   open(outfile, 'ab').close()
121     if 'l' in metadata:
122       pointed = metadata['l']
123       logging.debug('Symlink: %s -> %s' % (outfile, pointed))
124       # symlink doesn't exist on Windows.
125       os.symlink(pointed, outfile)  # pylint: disable=E1101
126     else:
127       run_isolated.link_file(outfile, infile, action)
128
129
130 ### Variable stuff.
131
132
133 def _normalize_path_variable(cwd, relative_base_dir, key, value):
134   """Normalizes a path variable into a relative directory.
135   """
136   # Variables could contain / or \ on windows. Always normalize to
137   # os.path.sep.
138   x = os.path.join(cwd, value.strip().replace('/', os.path.sep))
139   normalized = file_path.get_native_path_case(os.path.normpath(x))
140   if not os.path.isdir(normalized):
141     raise ExecutionError('%s=%s is not a directory' % (key, normalized))
142
143   # All variables are relative to the .isolate file.
144   normalized = os.path.relpath(normalized, relative_base_dir)
145   logging.debug(
146       'Translated variable %s from %s to %s', key, value, normalized)
147   return normalized
148
149
150 def normalize_path_variables(cwd, path_variables, relative_base_dir):
151   """Processes path variables as a special case and returns a copy of the dict.
152
153   For each 'path' variable: first normalizes it based on |cwd|, verifies it
154   exists then sets it as relative to relative_base_dir.
155   """
156   logging.info(
157       'normalize_path_variables(%s, %s, %s)', cwd, path_variables,
158       relative_base_dir)
159   assert isinstance(cwd, unicode), cwd
160   assert isinstance(relative_base_dir, unicode), relative_base_dir
161   relative_base_dir = file_path.get_native_path_case(relative_base_dir)
162   return dict(
163       (k, _normalize_path_variable(cwd, relative_base_dir, k, v))
164       for k, v in path_variables.iteritems())
165
166
167 ### Internal state files.
168
169
170 def isolatedfile_to_state(filename):
171   """For a '.isolate' file, returns the path to the saved '.state' file."""
172   return filename + '.state'
173
174
175 def classify_files(root_dir, tracked, untracked):
176   """Converts the list of files into a .isolate 'variables' dictionary.
177
178   Arguments:
179   - tracked: list of files names to generate a dictionary out of that should
180              probably be tracked.
181   - untracked: list of files names that must not be tracked.
182   """
183   # These directories are not guaranteed to be always present on every builder.
184   CHROMIUM_OPTIONAL_DIRECTORIES = (
185     'test/data/plugin',
186     'third_party/WebKit/LayoutTests',
187   )
188
189   new_tracked = []
190   new_untracked = list(untracked)
191
192   def should_be_tracked(filepath):
193     """Returns True if it is a file without whitespace in a non-optional
194     directory that has no symlink in its path.
195     """
196     if filepath.endswith('/'):
197       return False
198     if ' ' in filepath:
199       return False
200     if any(i in filepath for i in CHROMIUM_OPTIONAL_DIRECTORIES):
201       return False
202     # Look if any element in the path is a symlink.
203     split = filepath.split('/')
204     for i in range(len(split)):
205       if os.path.islink(os.path.join(root_dir, '/'.join(split[:i+1]))):
206         return False
207     return True
208
209   for filepath in sorted(tracked):
210     if should_be_tracked(filepath):
211       new_tracked.append(filepath)
212     else:
213       # Anything else.
214       new_untracked.append(filepath)
215
216   variables = {}
217   if new_tracked:
218     variables[isolate_format.KEY_TRACKED] = sorted(new_tracked)
219   if new_untracked:
220     variables[isolate_format.KEY_UNTRACKED] = sorted(new_untracked)
221   return variables
222
223
224 def chromium_fix(f, variables):
225   """Fixes an isolate dependency with Chromium-specific fixes."""
226   # Skip log in PRODUCT_DIR. Note that these are applied on '/' style path
227   # separator.
228   LOG_FILE = re.compile(r'^\<\(PRODUCT_DIR\)\/[^\/]+\.log$')
229   # Ignored items.
230   IGNORED_ITEMS = (
231       # http://crbug.com/160539, on Windows, it's in chrome/.
232       'Media Cache/',
233       'chrome/Media Cache/',
234       # 'First Run' is not created by the compile, but by the test itself.
235       '<(PRODUCT_DIR)/First Run')
236
237   # Blacklist logs and other unimportant files.
238   if LOG_FILE.match(f) or f in IGNORED_ITEMS:
239     logging.debug('Ignoring %s', f)
240     return None
241
242   EXECUTABLE = re.compile(
243       r'^(\<\(PRODUCT_DIR\)\/[^\/\.]+)' +
244       re.escape(variables.get('EXECUTABLE_SUFFIX', '')) +
245       r'$')
246   match = EXECUTABLE.match(f)
247   if match:
248     return match.group(1) + '<(EXECUTABLE_SUFFIX)'
249
250   if sys.platform == 'darwin':
251     # On OSX, the name of the output is dependent on gyp define, it can be
252     # 'Google Chrome.app' or 'Chromium.app', same for 'XXX
253     # Framework.framework'. Furthermore, they are versioned with a gyp
254     # variable.  To lower the complexity of the .isolate file, remove all the
255     # individual entries that show up under any of the 4 entries and replace
256     # them with the directory itself. Overall, this results in a bit more
257     # files than strictly necessary.
258     OSX_BUNDLES = (
259       '<(PRODUCT_DIR)/Chromium Framework.framework/',
260       '<(PRODUCT_DIR)/Chromium.app/',
261       '<(PRODUCT_DIR)/Google Chrome Framework.framework/',
262       '<(PRODUCT_DIR)/Google Chrome.app/',
263     )
264     for prefix in OSX_BUNDLES:
265       if f.startswith(prefix):
266         # Note this result in duplicate values, so the a set() must be used to
267         # remove duplicates.
268         return prefix
269   return f
270
271
272 def generate_simplified(
273     tracked, untracked, touched, root_dir, path_variables, extra_variables,
274     relative_cwd, trace_blacklist):
275   """Generates a clean and complete .isolate 'variables' dictionary.
276
277   Cleans up and extracts only files from within root_dir then processes
278   variables and relative_cwd.
279   """
280   root_dir = os.path.realpath(root_dir)
281   logging.info(
282       'generate_simplified(%d files, %s, %s, %s, %s)' %
283       (len(tracked) + len(untracked) + len(touched),
284         root_dir, path_variables, extra_variables, relative_cwd))
285
286   # Preparation work.
287   relative_cwd = file_path.cleanup_path(relative_cwd)
288   assert not os.path.isabs(relative_cwd), relative_cwd
289
290   # Normalizes to posix path. .isolate files are using posix paths on all OSes
291   # for coherency.
292   path_variables = dict(
293       (k, v.replace(os.path.sep, '/')) for k, v in path_variables.iteritems())
294   # Contains normalized path_variables plus extra_variables.
295   total_variables = path_variables.copy()
296   total_variables.update(extra_variables)
297
298   # Actual work: Process the files.
299   # TODO(maruel): if all the files in a directory are in part tracked and in
300   # part untracked, the directory will not be extracted. Tracked files should be
301   # 'promoted' to be untracked as needed.
302   tracked = trace_inputs.extract_directories(
303       root_dir, tracked, trace_blacklist)
304   untracked = trace_inputs.extract_directories(
305       root_dir, untracked, trace_blacklist)
306   # touched is not compressed, otherwise it would result in files to be archived
307   # that we don't need.
308
309   root_dir_posix = root_dir.replace(os.path.sep, '/')
310   def fix(f):
311     """Bases the file on the most restrictive variable."""
312     # Important, GYP stores the files with / and not \.
313     f = f.replace(os.path.sep, '/')
314     logging.debug('fix(%s)' % f)
315     # If it's not already a variable.
316     if not f.startswith('<'):
317       # relative_cwd is usually the directory containing the gyp file. It may be
318       # empty if the whole directory containing the gyp file is needed.
319       # Use absolute paths in case cwd_dir is outside of root_dir.
320       # Convert the whole thing to / since it's isolate's speak.
321       f = file_path.posix_relpath(
322           posixpath.join(root_dir_posix, f),
323           posixpath.join(root_dir_posix, relative_cwd)) or './'
324
325       # Use the longest value first.
326       for key, value in sorted(
327           path_variables.iteritems(), key=lambda x: -len(x[1])):
328         if f.startswith(value):
329           f = '<(%s)%s' % (key, f[len(value):])
330           logging.debug('Converted to %s' % f)
331           break
332     return f
333
334   def fix_all(items):
335     """Reduces the items to convert variables, removes unneeded items, apply
336     chromium-specific fixes and only return unique items.
337     """
338     variables_converted = (fix(f.path) for f in items)
339     chromium_fixed = (
340         chromium_fix(f, total_variables) for f in variables_converted)
341     return set(f for f in chromium_fixed if f)
342
343   tracked = fix_all(tracked)
344   untracked = fix_all(untracked)
345   touched = fix_all(touched)
346   out = classify_files(root_dir, tracked, untracked)
347   if touched:
348     out[isolate_format.KEY_TOUCHED] = sorted(touched)
349   return out
350
351
352 def generate_isolate(
353     tracked, untracked, touched, root_dir, path_variables, config_variables,
354     extra_variables, relative_cwd, trace_blacklist):
355   """Generates a clean and complete .isolate file."""
356   dependencies = generate_simplified(
357       tracked, untracked, touched, root_dir, path_variables, extra_variables,
358       relative_cwd, trace_blacklist)
359   config_variable_names, config_values = zip(
360       *sorted(config_variables.iteritems()))
361   out = isolate_format.Configs(None, config_variable_names)
362   # TODO(maruel): Create a public interface in Configs to add a ConfigSettings.
363   # pylint: disable=W0212
364   out._by_config[config_values] = isolate_format.ConfigSettings(dependencies)
365   return out.make_isolate_file()
366
367
368 def chromium_save_isolated(isolated, data, path_variables, algo):
369   """Writes one or many .isolated files.
370
371   This slightly increases the cold cache cost but greatly reduce the warm cache
372   cost by splitting low-churn files off the master .isolated file. It also
373   reduces overall isolateserver memcache consumption.
374   """
375   slaves = []
376
377   def extract_into_included_isolated(prefix):
378     new_slave = {
379       'algo': data['algo'],
380       'files': {},
381       'os': data['os'],
382       'version': data['version'],
383     }
384     for f in data['files'].keys():
385       if f.startswith(prefix):
386         new_slave['files'][f] = data['files'].pop(f)
387     if new_slave['files']:
388       slaves.append(new_slave)
389
390   # Split test/data/ in its own .isolated file.
391   extract_into_included_isolated(os.path.join('test', 'data', ''))
392
393   # Split everything out of PRODUCT_DIR in its own .isolated file.
394   if path_variables.get('PRODUCT_DIR'):
395     extract_into_included_isolated(path_variables['PRODUCT_DIR'])
396
397   files = []
398   for index, f in enumerate(slaves):
399     slavepath = isolated[:-len('.isolated')] + '.%d.isolated' % index
400     tools.write_json(slavepath, f, True)
401     data.setdefault('includes', []).append(
402         isolateserver.hash_file(slavepath, algo))
403     files.append(os.path.basename(slavepath))
404
405   files.extend(isolateserver.save_isolated(isolated, data))
406   return files
407
408
409 class Flattenable(object):
410   """Represents data that can be represented as a json file."""
411   MEMBERS = ()
412
413   def flatten(self):
414     """Returns a json-serializable version of itself.
415
416     Skips None entries.
417     """
418     items = ((member, getattr(self, member)) for member in self.MEMBERS)
419     return dict((member, value) for member, value in items if value is not None)
420
421   @classmethod
422   def load(cls, data, *args, **kwargs):
423     """Loads a flattened version."""
424     data = data.copy()
425     out = cls(*args, **kwargs)
426     for member in out.MEMBERS:
427       if member in data:
428         # Access to a protected member XXX of a client class
429         # pylint: disable=W0212
430         out._load_member(member, data.pop(member))
431     if data:
432       raise ValueError(
433           'Found unexpected entry %s while constructing an object %s' %
434             (data, cls.__name__), data, cls.__name__)
435     return out
436
437   def _load_member(self, member, value):
438     """Loads a member into self."""
439     setattr(self, member, value)
440
441   @classmethod
442   def load_file(cls, filename, *args, **kwargs):
443     """Loads the data from a file or return an empty instance."""
444     try:
445       out = cls.load(tools.read_json(filename), *args, **kwargs)
446       logging.debug('Loaded %s(%s)', cls.__name__, filename)
447     except (IOError, ValueError) as e:
448       # On failure, loads the default instance.
449       out = cls(*args, **kwargs)
450       logging.warn('Failed to load %s: %s', filename, e)
451     return out
452
453
454 class SavedState(Flattenable):
455   """Describes the content of a .state file.
456
457   This file caches the items calculated by this script and is used to increase
458   the performance of the script. This file is not loaded by run_isolated.py.
459   This file can always be safely removed.
460
461   It is important to note that the 'files' dict keys are using native OS path
462   separator instead of '/' used in .isolate file.
463   """
464   MEMBERS = (
465     # Algorithm used to generate the hash. The only supported value is at the
466     # time of writting 'sha-1'.
467     'algo',
468     # Cache of the processed command. This value is saved because .isolated
469     # files are never loaded by isolate.py so it's the only way to load the
470     # command safely.
471     'command',
472     # GYP variables that are used to generate conditions. The most frequent
473     # example is 'OS'.
474     'config_variables',
475     # GYP variables that will be replaced in 'command' and paths but will not be
476     # considered a relative directory.
477     'extra_variables',
478     # Cache of the files found so the next run can skip hash calculation.
479     'files',
480     # Path of the original .isolate file. Relative path to isolated_basedir.
481     'isolate_file',
482     # List of included .isolated files. Used to support/remember 'slave'
483     # .isolated files. Relative path to isolated_basedir.
484     'child_isolated_files',
485     # If the generated directory tree should be read-only.
486     'read_only',
487     # Relative cwd to use to start the command.
488     'relative_cwd',
489     # GYP variables used to generate the .isolated files paths based on path
490     # variables. Frequent examples are DEPTH and PRODUCT_DIR.
491     'path_variables',
492     # Version of the file format in format 'major.minor'. Any non-breaking
493     # change must update minor. Any breaking change must update major.
494     'version',
495   )
496
497   def __init__(self, isolated_basedir):
498     """Creates an empty SavedState.
499
500     |isolated_basedir| is the directory where the .isolated and .isolated.state
501     files are saved.
502     """
503     super(SavedState, self).__init__()
504     assert os.path.isabs(isolated_basedir), isolated_basedir
505     assert os.path.isdir(isolated_basedir), isolated_basedir
506     self.isolated_basedir = isolated_basedir
507
508     # The default algorithm used.
509     self.algo = isolateserver.SUPPORTED_ALGOS['sha-1']
510     self.child_isolated_files = []
511     self.command = []
512     self.config_variables = {}
513     self.extra_variables = {}
514     self.files = {}
515     self.isolate_file = None
516     self.path_variables = {}
517     self.read_only = None
518     self.relative_cwd = None
519     self.version = isolateserver.ISOLATED_FILE_VERSION
520
521   def update(
522       self, isolate_file, path_variables, config_variables, extra_variables):
523     """Updates the saved state with new data to keep GYP variables and internal
524     reference to the original .isolate file.
525     """
526     assert os.path.isabs(isolate_file)
527     # Convert back to a relative path. On Windows, if the isolate and
528     # isolated files are on different drives, isolate_file will stay an absolute
529     # path.
530     isolate_file = file_path.safe_relpath(isolate_file, self.isolated_basedir)
531
532     # The same .isolate file should always be used to generate the .isolated and
533     # .isolated.state.
534     assert isolate_file == self.isolate_file or not self.isolate_file, (
535         isolate_file, self.isolate_file)
536     self.config_variables.update(config_variables)
537     self.extra_variables.update(extra_variables)
538     self.isolate_file = isolate_file
539     self.path_variables.update(path_variables)
540
541   def update_isolated(self, command, infiles, touched, read_only, relative_cwd):
542     """Updates the saved state with data necessary to generate a .isolated file.
543
544     The new files in |infiles| are added to self.files dict but their hash is
545     not calculated here.
546     """
547     self.command = command
548     # Add new files.
549     for f in infiles:
550       self.files.setdefault(f, {})
551     for f in touched:
552       self.files.setdefault(f, {})['T'] = True
553     # Prune extraneous files that are not a dependency anymore.
554     for f in set(self.files).difference(set(infiles).union(touched)):
555       del self.files[f]
556     if read_only is not None:
557       self.read_only = read_only
558     self.relative_cwd = relative_cwd
559
560   def to_isolated(self):
561     """Creates a .isolated dictionary out of the saved state.
562
563     https://code.google.com/p/swarming/wiki/IsolatedDesign
564     """
565     def strip(data):
566       """Returns a 'files' entry with only the whitelisted keys."""
567       return dict((k, data[k]) for k in ('h', 'l', 'm', 's') if k in data)
568
569     out = {
570       'algo': isolateserver.SUPPORTED_ALGOS_REVERSE[self.algo],
571       'files': dict(
572           (filepath, strip(data)) for filepath, data in self.files.iteritems()),
573       'version': self.version,
574     }
575     if self.config_variables.get('OS'):
576       out['os'] = self.config_variables['OS']
577     if self.command:
578       out['command'] = self.command
579     if self.read_only is not None:
580       out['read_only'] = self.read_only
581     if self.relative_cwd:
582       out['relative_cwd'] = self.relative_cwd
583     return out
584
585   @property
586   def isolate_filepath(self):
587     """Returns the absolute path of self.isolate_file."""
588     return os.path.normpath(
589         os.path.join(self.isolated_basedir, self.isolate_file))
590
591   # Arguments number differs from overridden method
592   @classmethod
593   def load(cls, data, isolated_basedir):  # pylint: disable=W0221
594     """Special case loading to disallow different OS.
595
596     It is not possible to load a .isolated.state files from a different OS, this
597     file is saved in OS-specific format.
598     """
599     out = super(SavedState, cls).load(data, isolated_basedir)
600     if data.get('os'):
601       out.config_variables['OS'] = data['os']
602
603     # Converts human readable form back into the proper class type.
604     algo = data.get('algo', 'sha-1')
605     if not algo in isolateserver.SUPPORTED_ALGOS:
606       raise isolateserver.ConfigError('Unknown algo \'%s\'' % out.algo)
607     out.algo = isolateserver.SUPPORTED_ALGOS[algo]
608
609     # Refuse the load non-exact version, even minor difference. This is unlike
610     # isolateserver.load_isolated(). This is because .isolated.state could have
611     # changed significantly even in minor version difference.
612     if not re.match(r'^(\d+)\.(\d+)$', out.version):
613       raise isolateserver.ConfigError('Unknown version \'%s\'' % out.version)
614     if out.version != isolateserver.ISOLATED_FILE_VERSION:
615       raise isolateserver.ConfigError(
616           'Unsupported version \'%s\'' % out.version)
617
618     # The .isolate file must be valid. It could be absolute on Windows if the
619     # drive containing the .isolate and the drive containing the .isolated files
620     # differ.
621     assert not os.path.isabs(out.isolate_file) or sys.platform == 'win32'
622     assert os.path.isfile(out.isolate_filepath), out.isolate_filepath
623     return out
624
625   def flatten(self):
626     """Makes sure 'algo' is in human readable form."""
627     out = super(SavedState, self).flatten()
628     out['algo'] = isolateserver.SUPPORTED_ALGOS_REVERSE[out['algo']]
629     return out
630
631   def __str__(self):
632     def dict_to_str(d):
633       return ''.join('\n    %s=%s' % (k, d[k]) for k in sorted(d))
634
635     out = '%s(\n' % self.__class__.__name__
636     out += '  command: %s\n' % self.command
637     out += '  files: %d\n' % len(self.files)
638     out += '  isolate_file: %s\n' % self.isolate_file
639     out += '  read_only: %s\n' % self.read_only
640     out += '  relative_cwd: %s\n' % self.relative_cwd
641     out += '  child_isolated_files: %s\n' % self.child_isolated_files
642     out += '  path_variables: %s\n' % dict_to_str(self.path_variables)
643     out += '  config_variables: %s\n' % dict_to_str(self.config_variables)
644     out += '  extra_variables: %s\n' % dict_to_str(self.extra_variables)
645     return out
646
647
648 class CompleteState(object):
649   """Contains all the state to run the task at hand."""
650   def __init__(self, isolated_filepath, saved_state):
651     super(CompleteState, self).__init__()
652     assert isolated_filepath is None or os.path.isabs(isolated_filepath)
653     self.isolated_filepath = isolated_filepath
654     # Contains the data to ease developer's use-case but that is not strictly
655     # necessary.
656     self.saved_state = saved_state
657
658   @classmethod
659   def load_files(cls, isolated_filepath):
660     """Loads state from disk."""
661     assert os.path.isabs(isolated_filepath), isolated_filepath
662     isolated_basedir = os.path.dirname(isolated_filepath)
663     return cls(
664         isolated_filepath,
665         SavedState.load_file(
666             isolatedfile_to_state(isolated_filepath), isolated_basedir))
667
668   def load_isolate(
669       self, cwd, isolate_file, path_variables, config_variables,
670       extra_variables, ignore_broken_items):
671     """Updates self.isolated and self.saved_state with information loaded from a
672     .isolate file.
673
674     Processes the loaded data, deduce root_dir, relative_cwd.
675     """
676     # Make sure to not depend on os.getcwd().
677     assert os.path.isabs(isolate_file), isolate_file
678     isolate_file = file_path.get_native_path_case(isolate_file)
679     logging.info(
680         'CompleteState.load_isolate(%s, %s, %s, %s, %s, %s)',
681         cwd, isolate_file, path_variables, config_variables, extra_variables,
682         ignore_broken_items)
683     relative_base_dir = os.path.dirname(isolate_file)
684
685     # Processes the variables.
686     path_variables = normalize_path_variables(
687         cwd, path_variables, relative_base_dir)
688     # Update the saved state.
689     self.saved_state.update(
690         isolate_file, path_variables, config_variables, extra_variables)
691     path_variables = self.saved_state.path_variables
692
693     with open(isolate_file, 'r') as f:
694       # At that point, variables are not replaced yet in command and infiles.
695       # infiles may contain directory entries and is in posix style.
696       command, infiles, touched, read_only = (
697           isolate_format.load_isolate_for_config(
698               os.path.dirname(isolate_file), f.read(),
699               self.saved_state.config_variables))
700
701     total_variables = self.saved_state.path_variables.copy()
702     total_variables.update(self.saved_state.config_variables)
703     total_variables.update(self.saved_state.extra_variables)
704     command = [
705         isolate_format.eval_variables(i, total_variables) for i in command
706     ]
707
708     total_variables = self.saved_state.path_variables.copy()
709     total_variables.update(self.saved_state.extra_variables)
710     infiles = [
711         isolate_format.eval_variables(f, total_variables) for f in infiles
712     ]
713     touched = [
714         isolate_format.eval_variables(f, total_variables) for f in touched
715     ]
716     # root_dir is automatically determined by the deepest root accessed with the
717     # form '../../foo/bar'. Note that path variables must be taken in account
718     # too, add them as if they were input files.
719     root_dir = isolate_format.determine_root_dir(
720         relative_base_dir, infiles + touched +
721         self.saved_state.path_variables.values())
722     # The relative directory is automatically determined by the relative path
723     # between root_dir and the directory containing the .isolate file,
724     # isolate_base_dir.
725     relative_cwd = os.path.relpath(relative_base_dir, root_dir)
726     # Now that we know where the root is, check that the path_variables point
727     # inside it.
728     for k, v in self.saved_state.path_variables.iteritems():
729       if not file_path.path_starts_with(
730           root_dir, os.path.join(relative_base_dir, v)):
731         raise isolateserver.MappingError(
732             'Path variable %s=%r points outside the inferred root directory %s'
733             % (k, v, root_dir))
734     # Normalize the files based to root_dir. It is important to keep the
735     # trailing os.path.sep at that step.
736     infiles = [
737       file_path.relpath(
738           file_path.normpath(os.path.join(relative_base_dir, f)), root_dir)
739       for f in infiles
740     ]
741     touched = [
742       file_path.relpath(
743           file_path.normpath(os.path.join(relative_base_dir, f)), root_dir)
744       for f in touched
745     ]
746     follow_symlinks = config_variables['OS'] != 'win'
747     # Expand the directories by listing each file inside. Up to now, trailing
748     # os.path.sep must be kept. Do not expand 'touched'.
749     infiles = expand_directories_and_symlinks(
750         root_dir,
751         infiles,
752         lambda x: re.match(r'.*\.(git|svn|pyc)$', x),
753         follow_symlinks,
754         ignore_broken_items)
755
756     # If we ignore broken items then remove any missing touched items.
757     if ignore_broken_items:
758       original_touched_count = len(touched)
759       touched = [touch for touch in touched if os.path.exists(touch)]
760
761       if len(touched) != original_touched_count:
762         logging.info('Removed %d invalid touched entries',
763                      len(touched) - original_touched_count)
764
765     # Finally, update the new data to be able to generate the foo.isolated file,
766     # the file that is used by run_isolated.py.
767     self.saved_state.update_isolated(
768         command, infiles, touched, read_only, relative_cwd)
769     logging.debug(self)
770
771   def process_inputs(self, subdir):
772     """Updates self.saved_state.files with the files' mode and hash.
773
774     If |subdir| is specified, filters to a subdirectory. The resulting .isolated
775     file is tainted.
776
777     See isolateserver.process_input() for more information.
778     """
779     for infile in sorted(self.saved_state.files):
780       if subdir and not infile.startswith(subdir):
781         self.saved_state.files.pop(infile)
782       else:
783         filepath = os.path.join(self.root_dir, infile)
784         self.saved_state.files[infile] = isolateserver.process_input(
785             filepath,
786             self.saved_state.files[infile],
787             self.saved_state.read_only,
788             self.saved_state.config_variables['OS'],
789             self.saved_state.algo)
790
791   def save_files(self):
792     """Saves self.saved_state and creates a .isolated file."""
793     logging.debug('Dumping to %s' % self.isolated_filepath)
794     self.saved_state.child_isolated_files = chromium_save_isolated(
795         self.isolated_filepath,
796         self.saved_state.to_isolated(),
797         self.saved_state.path_variables,
798         self.saved_state.algo)
799     total_bytes = sum(
800         i.get('s', 0) for i in self.saved_state.files.itervalues())
801     if total_bytes:
802       # TODO(maruel): Stats are missing the .isolated files.
803       logging.debug('Total size: %d bytes' % total_bytes)
804     saved_state_file = isolatedfile_to_state(self.isolated_filepath)
805     logging.debug('Dumping to %s' % saved_state_file)
806     tools.write_json(saved_state_file, self.saved_state.flatten(), True)
807
808   @property
809   def root_dir(self):
810     """Returns the absolute path of the root_dir to reference the .isolate file
811     via relative_cwd.
812
813     So that join(root_dir, relative_cwd, basename(isolate_file)) is equivalent
814     to isolate_filepath.
815     """
816     if not self.saved_state.isolate_file:
817       raise ExecutionError('Please specify --isolate')
818     isolate_dir = os.path.dirname(self.saved_state.isolate_filepath)
819     # Special case '.'.
820     if self.saved_state.relative_cwd == '.':
821       root_dir = isolate_dir
822     else:
823       if not isolate_dir.endswith(self.saved_state.relative_cwd):
824         raise ExecutionError(
825             ('Make sure the .isolate file is in the directory that will be '
826              'used as the relative directory. It is currently in %s and should '
827              'be in %s') % (isolate_dir, self.saved_state.relative_cwd))
828       # Walk back back to the root directory.
829       root_dir = isolate_dir[:-(len(self.saved_state.relative_cwd) + 1)]
830     return file_path.get_native_path_case(root_dir)
831
832   @property
833   def resultdir(self):
834     """Returns the absolute path containing the .isolated file.
835
836     It is usually equivalent to the variable PRODUCT_DIR. Uses the .isolated
837     path as the value.
838     """
839     return os.path.dirname(self.isolated_filepath)
840
841   def __str__(self):
842     def indent(data, indent_length):
843       """Indents text."""
844       spacing = ' ' * indent_length
845       return ''.join(spacing + l for l in str(data).splitlines(True))
846
847     out = '%s(\n' % self.__class__.__name__
848     out += '  root_dir: %s\n' % self.root_dir
849     out += '  saved_state: %s)' % indent(self.saved_state, 2)
850     return out
851
852
853 def load_complete_state(options, cwd, subdir, skip_update):
854   """Loads a CompleteState.
855
856   This includes data from .isolate and .isolated.state files. Never reads the
857   .isolated file.
858
859   Arguments:
860     options: Options instance generated with OptionParserIsolate. For either
861              options.isolate and options.isolated, if the value is set, it is an
862              absolute path.
863     cwd: base directory to be used when loading the .isolate file.
864     subdir: optional argument to only process file in the subdirectory, relative
865             to CompleteState.root_dir.
866     skip_update: Skip trying to load the .isolate file and processing the
867                  dependencies. It is useful when not needed, like when tracing.
868   """
869   assert not options.isolate or os.path.isabs(options.isolate)
870   assert not options.isolated or os.path.isabs(options.isolated)
871   cwd = file_path.get_native_path_case(unicode(cwd))
872   if options.isolated:
873     # Load the previous state if it was present. Namely, "foo.isolated.state".
874     # Note: this call doesn't load the .isolate file.
875     complete_state = CompleteState.load_files(options.isolated)
876   else:
877     # Constructs a dummy object that cannot be saved. Useful for temporary
878     # commands like 'run'.
879     complete_state = CompleteState(None, SavedState())
880
881   if not options.isolate:
882     if not complete_state.saved_state.isolate_file:
883       if not skip_update:
884         raise ExecutionError('A .isolate file is required.')
885       isolate = None
886     else:
887       isolate = complete_state.saved_state.isolate_filepath
888   else:
889     isolate = options.isolate
890     if complete_state.saved_state.isolate_file:
891       rel_isolate = file_path.safe_relpath(
892           options.isolate, complete_state.saved_state.isolated_basedir)
893       if rel_isolate != complete_state.saved_state.isolate_file:
894         raise ExecutionError(
895             '%s and %s do not match.' % (
896               options.isolate, complete_state.saved_state.isolate_file))
897
898   if not skip_update:
899     # Then load the .isolate and expands directories.
900     complete_state.load_isolate(
901         cwd, isolate, options.path_variables, options.config_variables,
902         options.extra_variables, options.ignore_broken_items)
903
904   # Regenerate complete_state.saved_state.files.
905   if subdir:
906     subdir = unicode(subdir)
907     # This is tricky here. If it is a path, take it from the root_dir. If
908     # it is a variable, it must be keyed from the directory containing the
909     # .isolate file. So translate all variables first.
910     translated_path_variables = dict(
911         (k,
912           os.path.normpath(os.path.join(complete_state.saved_state.relative_cwd,
913             v)))
914         for k, v in complete_state.saved_state.path_variables.iteritems())
915     subdir = isolate_format.eval_variables(subdir, translated_path_variables)
916     subdir = subdir.replace('/', os.path.sep)
917
918   if not skip_update:
919     complete_state.process_inputs(subdir)
920   return complete_state
921
922
923 def read_trace_as_isolate_dict(complete_state, trace_blacklist):
924   """Reads a trace and returns the .isolate dictionary.
925
926   Returns exceptions during the log parsing so it can be re-raised.
927   """
928   api = trace_inputs.get_api()
929   logfile = complete_state.isolated_filepath + '.log'
930   if not os.path.isfile(logfile):
931     raise ExecutionError(
932         'No log file \'%s\' to read, did you forget to \'trace\'?' % logfile)
933   try:
934     data = api.parse_log(logfile, trace_blacklist, None)
935     exceptions = [i['exception'] for i in data if 'exception' in i]
936     results = (i['results'] for i in data if 'results' in i)
937     results_stripped = (i.strip_root(complete_state.root_dir) for i in results)
938     files = set(sum((result.existent for result in results_stripped), []))
939     tracked, touched = isolate_format.split_touched(files)
940     value = generate_isolate(
941         tracked,
942         [],
943         touched,
944         complete_state.root_dir,
945         complete_state.saved_state.path_variables,
946         complete_state.saved_state.config_variables,
947         complete_state.saved_state.extra_variables,
948         complete_state.saved_state.relative_cwd,
949         trace_blacklist)
950     return value, exceptions
951   except trace_inputs.TracingFailure, e:
952     raise ExecutionError(
953         'Reading traces failed for: %s\n%s' %
954           (' '.join(complete_state.saved_state.command), str(e)))
955
956
957 def merge(complete_state, trace_blacklist):
958   """Reads a trace and merges it back into the source .isolate file."""
959   value, exceptions = read_trace_as_isolate_dict(
960       complete_state, trace_blacklist)
961
962   # Now take that data and union it into the original .isolate file.
963   with open(complete_state.saved_state.isolate_filepath, 'r') as f:
964     prev_content = f.read()
965   isolate_dir = os.path.dirname(complete_state.saved_state.isolate_filepath)
966   prev_config = isolate_format.load_isolate_as_config(
967       isolate_dir,
968       isolate_format.eval_content(prev_content),
969       isolate_format.extract_comment(prev_content))
970   new_config = isolate_format.load_isolate_as_config(isolate_dir, value, '')
971   config = isolate_format.union(prev_config, new_config)
972   data = config.make_isolate_file()
973   print('Updating %s' % complete_state.saved_state.isolate_file)
974   with open(complete_state.saved_state.isolate_filepath, 'wb') as f:
975     isolate_format.print_all(config.file_comment, data, f)
976   if exceptions:
977     # It got an exception, raise the first one.
978     raise \
979         exceptions[0][0], \
980         exceptions[0][1], \
981         exceptions[0][2]
982
983
984 def get_remap_dir(root_dir, isolated, outdir):
985   """If necessary, creates a directory aside the root directory."""
986   if outdir:
987     if not os.path.isdir(outdir):
988       os.makedirs(outdir)
989     return outdir
990
991   if not os.path.isabs(root_dir):
992     root_dir = os.path.join(os.path.dirname(isolated), root_dir)
993   return run_isolated.make_temp_dir(
994       'isolate-%s' % datetime.date.today(), root_dir)
995
996
997 def create_isolate_tree(outdir, root_dir, files, relative_cwd, read_only):
998   """Creates a isolated tree usable for test execution.
999
1000   Returns the current working directory where the isolated command should be
1001   started in.
1002   """
1003   # Forcibly copy when the tree has to be read only. Otherwise the inode is
1004   # modified, and this cause real problems because the user's source tree
1005   # becomes read only. On the other hand, the cost of doing file copy is huge.
1006   if read_only not in (0, None):
1007     action = run_isolated.COPY
1008   else:
1009     action = run_isolated.HARDLINK_WITH_FALLBACK
1010
1011   recreate_tree(
1012       outdir=outdir,
1013       indir=root_dir,
1014       infiles=files,
1015       action=action,
1016       as_hash=False)
1017   cwd = os.path.normpath(os.path.join(outdir, relative_cwd))
1018   if not os.path.isdir(cwd):
1019     # It can happen when no files are mapped from the directory containing the
1020     # .isolate file. But the directory must exist to be the current working
1021     # directory.
1022     os.makedirs(cwd)
1023   run_isolated.change_tree_read_only(outdir, read_only)
1024   return cwd
1025
1026
1027 ### Commands.
1028
1029
1030 def add_subdir_flag(parser):
1031   parser.add_option(
1032       '--subdir',
1033       help='Filters to a subdirectory. Its behavior changes depending if it '
1034            'is a relative path as a string or as a path variable. Path '
1035            'variables are always keyed from the directory containing the '
1036            '.isolate file. Anything else is keyed on the root directory.')
1037
1038
1039 def CMDarchive(parser, args):
1040   """Creates a .isolated file and uploads the tree to an isolate server.
1041
1042   All the files listed in the .isolated file are put in the isolate server
1043   cache via isolateserver.py.
1044   """
1045   add_subdir_flag(parser)
1046   options, args = parser.parse_args(args)
1047   if args:
1048     parser.error('Unsupported argument: %s' % args)
1049
1050   with tools.Profiler('GenerateHashtable'):
1051     success = False
1052     try:
1053       complete_state = load_complete_state(
1054           options, os.getcwd(), options.subdir, False)
1055       if not options.outdir:
1056         options.outdir = os.path.join(
1057             os.path.dirname(complete_state.isolated_filepath), 'hashtable')
1058       # Make sure that complete_state isn't modified until save_files() is
1059       # called, because any changes made to it here will propagate to the files
1060       # created (which is probably not intended).
1061       complete_state.save_files()
1062
1063       infiles = complete_state.saved_state.files
1064       # Add all the .isolated files.
1065       isolated_hash = []
1066       isolated_files = [
1067         options.isolated,
1068       ] + complete_state.saved_state.child_isolated_files
1069       for item in isolated_files:
1070         item_path = os.path.join(
1071             os.path.dirname(complete_state.isolated_filepath), item)
1072         # Do not use isolateserver.hash_file() here because the file is
1073         # likely smallish (under 500kb) and its file size is needed.
1074         with open(item_path, 'rb') as f:
1075           content = f.read()
1076         isolated_hash.append(
1077             complete_state.saved_state.algo(content).hexdigest())
1078         isolated_metadata = {
1079           'h': isolated_hash[-1],
1080           's': len(content),
1081           'priority': '0'
1082         }
1083         infiles[item_path] = isolated_metadata
1084
1085       logging.info('Creating content addressed object store with %d item',
1086                    len(infiles))
1087
1088       if file_path.is_url(options.outdir):
1089         isolateserver.upload_tree(
1090             base_url=options.outdir,
1091             indir=complete_state.root_dir,
1092             infiles=infiles,
1093             namespace='default-gzip')
1094       else:
1095         recreate_tree(
1096             outdir=options.outdir,
1097             indir=complete_state.root_dir,
1098             infiles=infiles,
1099             action=run_isolated.HARDLINK_WITH_FALLBACK,
1100             as_hash=True)
1101         # TODO(maruel): Make the files read-only?
1102       success = True
1103       print('%s  %s' % (isolated_hash[0], os.path.basename(options.isolated)))
1104     finally:
1105       # If the command failed, delete the .isolated file if it exists. This is
1106       # important so no stale swarm job is executed.
1107       if not success and os.path.isfile(options.isolated):
1108         os.remove(options.isolated)
1109   return not success
1110
1111
1112 def CMDcheck(parser, args):
1113   """Checks that all the inputs are present and generates .isolated."""
1114   add_subdir_flag(parser)
1115   options, args = parser.parse_args(args)
1116   if args:
1117     parser.error('Unsupported argument: %s' % args)
1118
1119   complete_state = load_complete_state(
1120       options, os.getcwd(), options.subdir, False)
1121
1122   # Nothing is done specifically. Just store the result and state.
1123   complete_state.save_files()
1124   return 0
1125
1126
1127 CMDhashtable = CMDarchive
1128
1129
1130 def CMDmerge(parser, args):
1131   """Reads and merges the data from the trace back into the original .isolate.
1132
1133   Ignores --outdir.
1134   """
1135   parser.require_isolated = False
1136   add_trace_option(parser)
1137   options, args = parser.parse_args(args)
1138   if args:
1139     parser.error('Unsupported argument: %s' % args)
1140
1141   complete_state = load_complete_state(options, os.getcwd(), None, False)
1142   blacklist = tools.gen_blacklist(options.trace_blacklist)
1143   merge(complete_state, blacklist)
1144   return 0
1145
1146
1147 def CMDread(parser, args):
1148   """Reads the trace file generated with command 'trace'.
1149
1150   Ignores --outdir.
1151   """
1152   parser.require_isolated = False
1153   add_trace_option(parser)
1154   parser.add_option(
1155       '--skip-refresh', action='store_true',
1156       help='Skip reading .isolate file and do not refresh the hash of '
1157            'dependencies')
1158   parser.add_option(
1159       '-m', '--merge', action='store_true',
1160       help='merge the results back in the .isolate file instead of printing')
1161   options, args = parser.parse_args(args)
1162   if args:
1163     parser.error('Unsupported argument: %s' % args)
1164
1165   complete_state = load_complete_state(
1166       options, os.getcwd(), None, options.skip_refresh)
1167   blacklist = tools.gen_blacklist(options.trace_blacklist)
1168   value, exceptions = read_trace_as_isolate_dict(complete_state, blacklist)
1169   if options.merge:
1170     merge(complete_state, blacklist)
1171   else:
1172     isolate_format.pretty_print(value, sys.stdout)
1173
1174   if exceptions:
1175     # It got an exception, raise the first one.
1176     raise \
1177         exceptions[0][0], \
1178         exceptions[0][1], \
1179         exceptions[0][2]
1180   return 0
1181
1182
1183 def CMDremap(parser, args):
1184   """Creates a directory with all the dependencies mapped into it.
1185
1186   Useful to test manually why a test is failing. The target executable is not
1187   run.
1188   """
1189   parser.require_isolated = False
1190   parser.add_option(
1191       '--skip-refresh', action='store_true',
1192       help='Skip reading .isolate file and do not refresh the hash of '
1193            'dependencies')
1194   options, args = parser.parse_args(args)
1195   if args:
1196     parser.error('Unsupported argument: %s' % args)
1197   if options.outdir and file_path.is_url(options.outdir):
1198     parser.error('Can\'t use url for --outdir with mode remap.')
1199
1200   complete_state = load_complete_state(
1201       options, os.getcwd(), None, options.skip_refresh)
1202
1203   outdir = get_remap_dir(
1204       complete_state.root_dir, options.isolated, options.outdir)
1205
1206   print('Remapping into %s' % outdir)
1207   if len(os.listdir(outdir)):
1208     raise ExecutionError('Can\'t remap in a non-empty directory')
1209
1210   create_isolate_tree(
1211       outdir, complete_state.root_dir, complete_state.saved_state.files,
1212       complete_state.saved_state.relative_cwd,
1213       complete_state.saved_state.read_only)
1214   if complete_state.isolated_filepath:
1215     complete_state.save_files()
1216   return 0
1217
1218
1219 def CMDrewrite(parser, args):
1220   """Rewrites a .isolate file into the canonical format."""
1221   parser.require_isolated = False
1222   options, args = parser.parse_args(args)
1223   if args:
1224     parser.error('Unsupported argument: %s' % args)
1225
1226   if options.isolated:
1227     # Load the previous state if it was present. Namely, "foo.isolated.state".
1228     complete_state = CompleteState.load_files(options.isolated)
1229     isolate = options.isolate or complete_state.saved_state.isolate_filepath
1230   else:
1231     isolate = options.isolate
1232   if not isolate:
1233     parser.error('--isolate is required.')
1234
1235   with open(isolate, 'r') as f:
1236     content = f.read()
1237   config = isolate_format.load_isolate_as_config(
1238       os.path.dirname(os.path.abspath(isolate)),
1239       isolate_format.eval_content(content),
1240       isolate_format.extract_comment(content))
1241   data = config.make_isolate_file()
1242   print('Updating %s' % isolate)
1243   with open(isolate, 'wb') as f:
1244     isolate_format.print_all(config.file_comment, data, f)
1245   return 0
1246
1247
1248 @subcommand.usage('-- [extra arguments]')
1249 def CMDrun(parser, args):
1250   """Runs the test executable in an isolated (temporary) directory.
1251
1252   All the dependencies are mapped into the temporary directory and the
1253   directory is cleaned up after the target exits. Warning: if --outdir is
1254   specified, it is deleted upon exit.
1255
1256   Argument processing stops at -- and these arguments are appended to the
1257   command line of the target to run. For example, use:
1258     isolate.py run --isolated foo.isolated -- --gtest_filter=Foo.Bar
1259   """
1260   parser.require_isolated = False
1261   parser.add_option(
1262       '--skip-refresh', action='store_true',
1263       help='Skip reading .isolate file and do not refresh the hash of '
1264            'dependencies')
1265   options, args = parser.parse_args(args)
1266   if options.outdir and file_path.is_url(options.outdir):
1267     parser.error('Can\'t use url for --outdir with mode run.')
1268
1269   complete_state = load_complete_state(
1270       options, os.getcwd(), None, options.skip_refresh)
1271   cmd = complete_state.saved_state.command + args
1272   if not cmd:
1273     raise ExecutionError('No command to run.')
1274   cmd = tools.fix_python_path(cmd)
1275
1276   try:
1277     outdir = get_remap_dir(
1278         complete_state.root_dir, options.isolated, options.outdir)
1279     # TODO(maruel): Use run_isolated.run_tha_test().
1280     cwd = create_isolate_tree(
1281         outdir, complete_state.root_dir, complete_state.saved_state.files,
1282         complete_state.saved_state.relative_cwd,
1283         complete_state.saved_state.read_only)
1284     logging.info('Running %s, cwd=%s' % (cmd, cwd))
1285     result = subprocess.call(cmd, cwd=cwd)
1286   finally:
1287     if options.outdir:
1288       run_isolated.rmtree(options.outdir)
1289
1290   if complete_state.isolated_filepath:
1291     complete_state.save_files()
1292   return result
1293
1294
1295 @subcommand.usage('-- [extra arguments]')
1296 def CMDtrace(parser, args):
1297   """Traces the target using trace_inputs.py.
1298
1299   It runs the executable without remapping it, and traces all the files it and
1300   its child processes access. Then the 'merge' command can be used to generate
1301   an updated .isolate file out of it or the 'read' command to print it out to
1302   stdout.
1303
1304   Argument processing stops at -- and these arguments are appended to the
1305   command line of the target to run. For example, use:
1306     isolate.py trace --isolated foo.isolated -- --gtest_filter=Foo.Bar
1307   """
1308   add_trace_option(parser)
1309   parser.add_option(
1310       '-m', '--merge', action='store_true',
1311       help='After tracing, merge the results back in the .isolate file')
1312   parser.add_option(
1313       '--skip-refresh', action='store_true',
1314       help='Skip reading .isolate file and do not refresh the hash of '
1315            'dependencies')
1316   options, args = parser.parse_args(args)
1317
1318   complete_state = load_complete_state(
1319       options, os.getcwd(), None, options.skip_refresh)
1320   cmd = complete_state.saved_state.command + args
1321   if not cmd:
1322     raise ExecutionError('No command to run.')
1323   cmd = tools.fix_python_path(cmd)
1324   cwd = os.path.normpath(os.path.join(
1325       unicode(complete_state.root_dir),
1326       complete_state.saved_state.relative_cwd))
1327   cmd[0] = os.path.normpath(os.path.join(cwd, cmd[0]))
1328   if not os.path.isfile(cmd[0]):
1329     raise ExecutionError(
1330         'Tracing failed for: %s\nIt doesn\'t exit' % ' '.join(cmd))
1331   logging.info('Running %s, cwd=%s' % (cmd, cwd))
1332   api = trace_inputs.get_api()
1333   logfile = complete_state.isolated_filepath + '.log'
1334   api.clean_trace(logfile)
1335   out = None
1336   try:
1337     with api.get_tracer(logfile) as tracer:
1338       result, out = tracer.trace(
1339           cmd,
1340           cwd,
1341           'default',
1342           True)
1343   except trace_inputs.TracingFailure, e:
1344     raise ExecutionError('Tracing failed for: %s\n%s' % (' '.join(cmd), str(e)))
1345
1346   if result:
1347     logging.error(
1348         'Tracer exited with %d, which means the tests probably failed so the '
1349         'trace is probably incomplete.', result)
1350     logging.info(out)
1351
1352   complete_state.save_files()
1353
1354   if options.merge:
1355     blacklist = tools.gen_blacklist(options.trace_blacklist)
1356     merge(complete_state, blacklist)
1357
1358   return result
1359
1360
1361 def _process_variable_arg(option, opt, _value, parser):
1362   """Called by OptionParser to process a --<foo>-variable argument."""
1363   if not parser.rargs:
1364     raise optparse.OptionValueError(
1365         'Please use %s FOO=BAR or %s FOO BAR' % (opt, opt))
1366   k = parser.rargs.pop(0)
1367   variables = getattr(parser.values, option.dest)
1368   if '=' in k:
1369     k, v = k.split('=', 1)
1370   else:
1371     if not parser.rargs:
1372       raise optparse.OptionValueError(
1373           'Please use %s FOO=BAR or %s FOO BAR' % (opt, opt))
1374     v = parser.rargs.pop(0)
1375   if not re.match('^' + isolate_format.VALID_VARIABLE + '$', k):
1376     raise optparse.OptionValueError(
1377         'Variable \'%s\' doesn\'t respect format \'%s\'' %
1378         (k, isolate_format.VALID_VARIABLE))
1379   variables.append((k, v.decode('utf-8')))
1380
1381
1382 def add_variable_option(parser):
1383   """Adds --isolated and --<foo>-variable to an OptionParser."""
1384   parser.add_option(
1385       '-s', '--isolated',
1386       metavar='FILE',
1387       help='.isolated file to generate or read')
1388   # Keep for compatibility. TODO(maruel): Remove once not used anymore.
1389   parser.add_option(
1390       '-r', '--result',
1391       dest='isolated',
1392       help=optparse.SUPPRESS_HELP)
1393   is_win = sys.platform in ('win32', 'cygwin')
1394   # There is really 3 kind of variables:
1395   # - path variables, like DEPTH or PRODUCT_DIR that should be
1396   #   replaced opportunistically when tracing tests.
1397   # - extraneous things like EXECUTABE_SUFFIX.
1398   # - configuration variables that are to be used in deducing the matrix to
1399   #   reduce.
1400   # - unrelated variables that are used as command flags for example.
1401   parser.add_option(
1402       '--config-variable',
1403       action='callback',
1404       callback=_process_variable_arg,
1405       default=[('OS', get_flavor())],
1406       dest='config_variables',
1407       metavar='FOO BAR',
1408       help='Config variables are used to determine which conditions should be '
1409            'matched when loading a .isolate file, default: %default. '
1410             'All 3 kinds of variables are persistent accross calls, they are '
1411             'saved inside <.isolated>.state')
1412   parser.add_option(
1413       '--path-variable',
1414       action='callback',
1415       callback=_process_variable_arg,
1416       default=[],
1417       dest='path_variables',
1418       metavar='FOO BAR',
1419       help='Path variables are used to replace file paths when loading a '
1420            '.isolate file, default: %default')
1421   parser.add_option(
1422       '--extra-variable',
1423       action='callback',
1424       callback=_process_variable_arg,
1425       default=[('EXECUTABLE_SUFFIX', '.exe' if is_win else '')],
1426       dest='extra_variables',
1427       metavar='FOO BAR',
1428       help='Extraneous variables are replaced on the \'command\' entry and on '
1429            'paths in the .isolate file but are not considered relative paths.')
1430
1431
1432 def add_trace_option(parser):
1433   """Adds --trace-blacklist to the parser."""
1434   parser.add_option(
1435       '--trace-blacklist',
1436       action='append', default=list(isolateserver.DEFAULT_BLACKLIST),
1437       help='List of regexp to use as blacklist filter for files to consider '
1438            'important, not to be confused with --blacklist which blacklists '
1439            'test case.')
1440
1441
1442 def parse_isolated_option(parser, options, cwd, require_isolated):
1443   """Processes --isolated."""
1444   if options.isolated:
1445     options.isolated = os.path.normpath(
1446         os.path.join(cwd, options.isolated.replace('/', os.path.sep)))
1447   if require_isolated and not options.isolated:
1448     parser.error('--isolated is required.')
1449   if options.isolated and not options.isolated.endswith('.isolated'):
1450     parser.error('--isolated value must end with \'.isolated\'')
1451
1452
1453 def parse_variable_option(options):
1454   """Processes all the --<foo>-variable flags."""
1455   # TODO(benrg): Maybe we should use a copy of gyp's NameValueListToDict here,
1456   # but it wouldn't be backward compatible.
1457   def try_make_int(s):
1458     """Converts a value to int if possible, converts to unicode otherwise."""
1459     try:
1460       return int(s)
1461     except ValueError:
1462       return s.decode('utf-8')
1463   options.config_variables = dict(
1464       (k, try_make_int(v)) for k, v in options.config_variables)
1465   options.path_variables = dict(options.path_variables)
1466   options.extra_variables = dict(options.extra_variables)
1467
1468
1469 class OptionParserIsolate(tools.OptionParserWithLogging):
1470   """Adds automatic --isolate, --isolated, --out and --<foo>-variable handling.
1471   """
1472   # Set it to False if it is not required, e.g. it can be passed on but do not
1473   # fail if not given.
1474   require_isolated = True
1475
1476   def __init__(self, **kwargs):
1477     tools.OptionParserWithLogging.__init__(
1478         self,
1479         verbose=int(os.environ.get('ISOLATE_DEBUG', 0)),
1480         **kwargs)
1481     group = optparse.OptionGroup(self, "Common options")
1482     group.add_option(
1483         '-i', '--isolate',
1484         metavar='FILE',
1485         help='.isolate file to load the dependency data from')
1486     add_variable_option(group)
1487     group.add_option(
1488         '-o', '--outdir', metavar='DIR',
1489         help='Directory used to recreate the tree or store the hash table. '
1490              'Defaults: run|remap: a /tmp subdirectory, others: '
1491              'defaults to the directory containing --isolated')
1492     group.add_option(
1493         '--ignore_broken_items', action='store_true',
1494         default=bool(os.environ.get('ISOLATE_IGNORE_BROKEN_ITEMS')),
1495         help='Indicates that invalid entries in the isolated file to be '
1496              'only be logged and not stop processing. Defaults to True if '
1497              'env var ISOLATE_IGNORE_BROKEN_ITEMS is set')
1498     self.add_option_group(group)
1499
1500   def parse_args(self, *args, **kwargs):
1501     """Makes sure the paths make sense.
1502
1503     On Windows, / and \ are often mixed together in a path.
1504     """
1505     options, args = tools.OptionParserWithLogging.parse_args(
1506         self, *args, **kwargs)
1507     if not self.allow_interspersed_args and args:
1508       self.error('Unsupported argument: %s' % args)
1509
1510     cwd = file_path.get_native_path_case(unicode(os.getcwd()))
1511     parse_isolated_option(self, options, cwd, self.require_isolated)
1512     parse_variable_option(options)
1513
1514     if options.isolate:
1515       # TODO(maruel): Work with non-ASCII.
1516       # The path must be in native path case for tracing purposes.
1517       options.isolate = unicode(options.isolate).replace('/', os.path.sep)
1518       options.isolate = os.path.normpath(os.path.join(cwd, options.isolate))
1519       options.isolate = file_path.get_native_path_case(options.isolate)
1520
1521     if options.outdir and not file_path.is_url(options.outdir):
1522       options.outdir = unicode(options.outdir).replace('/', os.path.sep)
1523       # outdir doesn't need native path case since tracing is never done from
1524       # there.
1525       options.outdir = os.path.normpath(os.path.join(cwd, options.outdir))
1526
1527     return options, args
1528
1529
1530 def main(argv):
1531   dispatcher = subcommand.CommandDispatcher(__name__)
1532   try:
1533     return dispatcher.execute(OptionParserIsolate(version=__version__), argv)
1534   except Exception as e:
1535     tools.report_error(e)
1536     return 1
1537
1538
1539 if __name__ == '__main__':
1540   fix_encoding.fix_encoding()
1541   tools.disable_buffering()
1542   colorama.init()
1543   sys.exit(main(sys.argv[1:]))