blob: 8c201edeba960d3795f2572a22e165c15ac72dd0 [file] [log] [blame]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001#!/usr/bin/env python
2# Copyright (c) 2012 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Front end tool to manage .isolate files and corresponding tests.
7
8Run ./isolate.py --help for more detailed information.
9
10See more information at
11http://dev.chromium.org/developers/testing/isolated-testing
12"""
13
14import binascii
15import copy
16import hashlib
17import logging
18import optparse
19import os
20import posixpath
21import re
22import stat
23import subprocess
24import sys
25import time
26import urllib
27import urllib2
28
maruel@chromium.orgb8375c22012-10-05 18:10:01 +000029import run_isolated
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +000030import trace_inputs
31
32# Import here directly so isolate is easier to use as a library.
maruel@chromium.orgb8375c22012-10-05 18:10:01 +000033from run_isolated import get_flavor
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +000034
35
36# Used by process_input().
37NO_INFO, STATS_ONLY, WITH_HASH = range(56, 59)
38SHA_1_NULL = hashlib.sha1().hexdigest()
39
40PATH_VARIABLES = ('DEPTH', 'PRODUCT_DIR')
41DEFAULT_OSES = ('linux', 'mac', 'win')
42
43# Files that should be 0-length when mapped.
44KEY_TOUCHED = 'isolate_dependency_touched'
45# Files that should be tracked by the build tool.
46KEY_TRACKED = 'isolate_dependency_tracked'
47# Files that should not be tracked by the build tool.
48KEY_UNTRACKED = 'isolate_dependency_untracked'
49
50_GIT_PATH = os.path.sep + '.git'
51_SVN_PATH = os.path.sep + '.svn'
52
53# The maximum number of upload attempts to try when uploading a single file.
54MAX_UPLOAD_ATTEMPTS = 5
55
56# The minimum size of files to upload directly to the blobstore.
57MIN_SIZE_FOR_DIRECT_BLOBSTORE = 20 * 8
58
59
60class ExecutionError(Exception):
61 """A generic error occurred."""
62 def __str__(self):
63 return self.args[0]
64
65
66### Path handling code.
67
68
69def relpath(path, root):
70 """os.path.relpath() that keeps trailing os.path.sep."""
71 out = os.path.relpath(path, root)
72 if path.endswith(os.path.sep):
73 out += os.path.sep
74 return out
75
76
77def normpath(path):
78 """os.path.normpath() that keeps trailing os.path.sep."""
79 out = os.path.normpath(path)
80 if path.endswith(os.path.sep):
81 out += os.path.sep
82 return out
83
84
85def posix_relpath(path, root):
86 """posix.relpath() that keeps trailing slash."""
87 out = posixpath.relpath(path, root)
88 if path.endswith('/'):
89 out += '/'
90 return out
91
92
93def cleanup_path(x):
94 """Cleans up a relative path. Converts any os.path.sep to '/' on Windows."""
95 if x:
96 x = x.rstrip(os.path.sep).replace(os.path.sep, '/')
97 if x == '.':
98 x = ''
99 if x:
100 x += '/'
101 return x
102
103
104def default_blacklist(f):
105 """Filters unimportant files normally ignored."""
106 return (
107 f.endswith(('.pyc', '.run_test_cases', 'testserver.log')) or
108 _GIT_PATH in f or
109 _SVN_PATH in f or
110 f in ('.git', '.svn'))
111
112
113def expand_directory_and_symlink(indir, relfile, blacklist):
114 """Expands a single input. It can result in multiple outputs.
115
116 This function is recursive when relfile is a directory or a symlink.
117
118 Note: this code doesn't properly handle recursive symlink like one created
119 with:
120 ln -s .. foo
121 """
122 if os.path.isabs(relfile):
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000123 raise run_isolated.MappingError(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000124 'Can\'t map absolute path %s' % relfile)
125
126 infile = normpath(os.path.join(indir, relfile))
127 if not infile.startswith(indir):
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000128 raise run_isolated.MappingError(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000129 'Can\'t map file %s outside %s' % (infile, indir))
130
131 if sys.platform != 'win32':
132 # Look if any item in relfile is a symlink.
133 base, symlink, rest = trace_inputs.split_at_symlink(indir, relfile)
134 if symlink:
135 # Append everything pointed by the symlink. If the symlink is recursive,
136 # this code blows up.
137 symlink_relfile = os.path.join(base, symlink)
138 symlink_path = os.path.join(indir, symlink_relfile)
139 pointed = os.readlink(symlink_path)
140 dest_infile = normpath(
141 os.path.join(os.path.dirname(symlink_path), pointed))
142 if rest:
143 dest_infile = trace_inputs.safe_join(dest_infile, rest)
144 if not dest_infile.startswith(indir):
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000145 raise run_isolated.MappingError(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000146 'Can\'t map symlink reference %s (from %s) ->%s outside of %s' %
147 (symlink_relfile, relfile, dest_infile, indir))
148 if infile.startswith(dest_infile):
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000149 raise run_isolated.MappingError(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000150 'Can\'t map recursive symlink reference %s->%s' %
151 (symlink_relfile, dest_infile))
152 dest_relfile = dest_infile[len(indir)+1:]
153 logging.info('Found symlink: %s -> %s' % (symlink_relfile, dest_relfile))
154 out = expand_directory_and_symlink(indir, dest_relfile, blacklist)
155 # Add the symlink itself.
156 out.append(symlink_relfile)
157 return out
158
159 if relfile.endswith(os.path.sep):
160 if not os.path.isdir(infile):
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000161 raise run_isolated.MappingError(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000162 '%s is not a directory but ends with "%s"' % (infile, os.path.sep))
163
164 outfiles = []
165 for filename in os.listdir(infile):
166 inner_relfile = os.path.join(relfile, filename)
167 if blacklist(inner_relfile):
168 continue
169 if os.path.isdir(os.path.join(indir, inner_relfile)):
170 inner_relfile += os.path.sep
171 outfiles.extend(
172 expand_directory_and_symlink(indir, inner_relfile, blacklist))
173 return outfiles
174 else:
175 # Always add individual files even if they were blacklisted.
176 if os.path.isdir(infile):
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000177 raise run_isolated.MappingError(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000178 'Input directory %s must have a trailing slash' % infile)
179
180 if not os.path.isfile(infile):
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000181 raise run_isolated.MappingError(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000182 'Input file %s doesn\'t exist' % infile)
183
184 return [relfile]
185
186
187def expand_directories_and_symlinks(indir, infiles, blacklist):
188 """Expands the directories and the symlinks, applies the blacklist and
189 verifies files exist.
190
191 Files are specified in os native path separator.
192 """
193 outfiles = []
194 for relfile in infiles:
195 outfiles.extend(expand_directory_and_symlink(indir, relfile, blacklist))
196 return outfiles
197
198
199def recreate_tree(outdir, indir, infiles, action, as_sha1):
200 """Creates a new tree with only the input files in it.
201
202 Arguments:
203 outdir: Output directory to create the files in.
204 indir: Root directory the infiles are based in.
205 infiles: dict of files to map from |indir| to |outdir|.
206 action: See assert below.
207 as_sha1: Output filename is the sha1 instead of relfile.
208 """
209 logging.info(
210 'recreate_tree(outdir=%s, indir=%s, files=%d, action=%s, as_sha1=%s)' %
211 (outdir, indir, len(infiles), action, as_sha1))
212
213 assert action in (
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000214 run_isolated.HARDLINK,
215 run_isolated.SYMLINK,
216 run_isolated.COPY)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000217 outdir = os.path.normpath(outdir)
218 if not os.path.isdir(outdir):
219 logging.info ('Creating %s' % outdir)
220 os.makedirs(outdir)
221 # Do not call abspath until the directory exists.
222 outdir = os.path.abspath(outdir)
223
224 for relfile, metadata in infiles.iteritems():
225 infile = os.path.join(indir, relfile)
226 if as_sha1:
227 # Do the hashtable specific checks.
228 if 'link' in metadata:
229 # Skip links when storing a hashtable.
230 continue
231 outfile = os.path.join(outdir, metadata['sha-1'])
232 if os.path.isfile(outfile):
233 # Just do a quick check that the file size matches. No need to stat()
234 # again the input file, grab the value from the dict.
maruel@chromium.org861a5e72012-10-09 14:49:42 +0000235 if not 'size' in metadata:
236 raise run_isolated.MappingError(
237 'Misconfigured item %s: %s' % (relfile, metadata))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000238 if metadata['size'] == os.stat(outfile).st_size:
239 continue
240 else:
241 logging.warn('Overwritting %s' % metadata['sha-1'])
242 os.remove(outfile)
243 else:
244 outfile = os.path.join(outdir, relfile)
245 outsubdir = os.path.dirname(outfile)
246 if not os.path.isdir(outsubdir):
247 os.makedirs(outsubdir)
248
249 # TODO(csharp): Fix crbug.com/150823 and enable the touched logic again.
250 # if metadata.get('touched_only') == True:
251 # open(outfile, 'ab').close()
252 if 'link' in metadata:
253 pointed = metadata['link']
254 logging.debug('Symlink: %s -> %s' % (outfile, pointed))
255 os.symlink(pointed, outfile)
256 else:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000257 run_isolated.link_file(outfile, infile, action)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000258
259
260def encode_multipart_formdata(fields, files,
261 mime_mapper=lambda _: 'application/octet-stream'):
262 """Encodes a Multipart form data object.
263
264 Args:
265 fields: a sequence (name, value) elements for
266 regular form fields.
267 files: a sequence of (name, filename, value) elements for data to be
268 uploaded as files.
269 mime_mapper: function to return the mime type from the filename.
270 Returns:
271 content_type: for httplib.HTTP instance
272 body: for httplib.HTTP instance
273 """
274 boundary = hashlib.md5(str(time.time())).hexdigest()
275 body_list = []
276 for (key, value) in fields:
maruel@chromium.org861a5e72012-10-09 14:49:42 +0000277 if isinstance(key, unicode):
278 value = key.encode('utf-8')
279 if isinstance(value, unicode):
280 value = value.encode('utf-8')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000281 body_list.append('--' + boundary)
282 body_list.append('Content-Disposition: form-data; name="%s"' % key)
283 body_list.append('')
284 body_list.append(value)
285 body_list.append('--' + boundary)
286 body_list.append('')
287 for (key, filename, value) in files:
maruel@chromium.org861a5e72012-10-09 14:49:42 +0000288 if isinstance(key, unicode):
289 value = key.encode('utf-8')
290 if isinstance(filename, unicode):
291 value = filename.encode('utf-8')
292 if isinstance(value, unicode):
293 value = value.encode('utf-8')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000294 body_list.append('--' + boundary)
295 body_list.append('Content-Disposition: form-data; name="%s"; '
296 'filename="%s"' % (key, filename))
297 body_list.append('Content-Type: %s' % mime_mapper(filename))
298 body_list.append('')
299 body_list.append(value)
300 body_list.append('--' + boundary)
301 body_list.append('')
302 if body_list:
303 body_list[-2] += '--'
304 body = '\r\n'.join(body_list)
305 content_type = 'multipart/form-data; boundary=%s' % boundary
306 return content_type, body
307
308
309def upload_hash_content(url, params=None, payload=None,
310 content_type='application/octet-stream'):
311 """Uploads the given hash contents.
312
313 Arguments:
314 url: The url to upload the hash contents to.
315 params: The params to include with the upload.
316 payload: The data to upload.
317 content_type: The content_type of the data being uploaded.
318 """
319 if params:
320 url = url + '?' + urllib.urlencode(params)
321 request = urllib2.Request(url, data=payload)
322 request.add_header('Content-Type', content_type)
323 request.add_header('Content-Length', len(payload or ''))
324
325 return urllib2.urlopen(request)
326
327
328def upload_hash_content_to_blobstore(generate_upload_url, params,
329 hash_data):
330 """Uploads the given hash contents directly to the blobsotre via a generated
331 url.
332
333 Arguments:
334 generate_upload_url: The url to get the new upload url from.
335 params: The params to include with the upload.
336 hash_contents: The contents to upload.
337 """
338 content_type, body = encode_multipart_formdata(
maruel@chromium.org861a5e72012-10-09 14:49:42 +0000339 params.items(), [('hash_contents', 'hash_content', hash_data)])
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000340
341 logging.debug('Generating url to directly upload file to blobstore')
342 response = urllib2.urlopen(generate_upload_url)
343 upload_url = response.read()
344
345 if not upload_url:
346 logging.error('Unable to generate upload url')
347 return
348
349 return upload_hash_content(upload_url, payload=body,
350 content_type=content_type)
351
352
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000353class UploadRemote(run_isolated.Remote):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000354 @staticmethod
355 def get_file_handler(base_url):
356 def upload_file(hash_data, hash_key):
357 params = {'hash_key': hash_key}
358 if len(hash_data) > MIN_SIZE_FOR_DIRECT_BLOBSTORE:
359 upload_hash_content_to_blobstore(
360 base_url.rstrip('/') + '/content/generate_blobstore_url',
361 params, hash_data)
362 else:
363 upload_hash_content(
364 base_url.rstrip('/') + '/content/store', params, hash_data)
365 return upload_file
366
367
368def url_open(url, data=None, max_retries=MAX_UPLOAD_ATTEMPTS):
369 """Opens the given url with the given data, repeating up to max_retries
370 times if it encounters an error.
371
372 Arguments:
373 url: The url to open.
374 data: The data to send to the url.
375 max_retries: The maximum number of times to try connecting to the url.
376
377 Returns:
378 The response from the url, or it raises an exception it it failed to get
379 a response.
380 """
maruel@chromium.orgd2434882012-10-11 14:08:46 +0000381 response = None
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000382 for _ in range(max_retries):
383 try:
384 response = urllib2.urlopen(url, data=data)
385 except urllib2.URLError as e:
386 logging.warning('Unable to connect to %s, error msg: %s', url, e)
387 time.sleep(1)
388
389 # If we get no response from the server after max_retries, assume it
390 # is down and raise an exception
391 if response is None:
maruel@chromium.orgd2434882012-10-11 14:08:46 +0000392 raise run_isolated.MappingError(
393 'Unable to connect to server, %s, to see which files are presents' %
394 url)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000395
396 return response
397
398
399def update_files_to_upload(query_url, queries, files_to_upload):
400 """Queries the server to see which files from this batch already exist there.
401
402 Arguments:
403 queries: The hash files to potential upload to the server.
404 files_to_upload: Any new files that need to be upload are added to
405 this list.
406 """
407 body = ''.join(
408 (binascii.unhexlify(meta_data['sha-1']) for (_, meta_data) in queries))
409 response = url_open(query_url, data=body).read()
410 if len(queries) != len(response):
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000411 raise run_isolated.MappingError(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000412 'Got an incorrect number of responses from the server. Expected %d, '
413 'but got %d' % (len(queries), len(response)))
414
415 for i in range(len(response)):
416 if response[i] == chr(0):
417 files_to_upload.append(queries[i])
418 else:
419 logging.debug('Hash for %s already exists on the server, no need '
420 'to upload again', queries[i][0])
421
422
423def upload_sha1_tree(base_url, indir, infiles):
424 """Uploads the given tree to the given url.
425
426 Arguments:
427 base_url: The base url, it is assume that |base_url|/has/ can be used to
428 query if an element was already uploaded, and |base_url|/store/
429 can be used to upload a new element.
430 indir: Root directory the infiles are based in.
431 infiles: dict of files to map from |indir| to |outdir|.
432 """
433 logging.info('upload tree(base_url=%s, indir=%s, files=%d)' %
434 (base_url, indir, len(infiles)))
435
436 # Generate the list of files that need to be uploaded (since some may already
437 # be on the server.
438 base_url = base_url.rstrip('/')
439 contains_hash_url = base_url + '/content/contains'
440 to_upload = []
441 next_queries = []
442 for relfile, metadata in infiles.iteritems():
443 if 'link' in metadata:
444 # Skip links when uploading.
445 continue
446
447 next_queries.append((relfile, metadata))
448 if len(next_queries) == 1000:
449 update_files_to_upload(contains_hash_url, next_queries, to_upload)
450 next_queries = []
451
452 if next_queries:
453 update_files_to_upload(contains_hash_url, next_queries, to_upload)
454
455
456 # Upload the required files.
457 remote_uploader = UploadRemote(base_url)
458 for relfile, metadata in to_upload:
459 # TODO(csharp): Fix crbug.com/150823 and enable the touched logic again.
460 # if metadata.get('touched_only') == True:
461 # hash_data = ''
462 infile = os.path.join(indir, relfile)
463 with open(infile, 'rb') as f:
464 hash_data = f.read()
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000465 remote_uploader.add_item(run_isolated.Remote.MED,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000466 hash_data,
467 metadata['sha-1'])
468 remote_uploader.join()
469
470 exception = remote_uploader.next_exception()
471 if exception:
maruel@chromium.org861a5e72012-10-09 14:49:42 +0000472 raise exception[0], exception[1], exception[2]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000473
474
475def process_input(filepath, prevdict, level, read_only):
476 """Processes an input file, a dependency, and return meta data about it.
477
478 Arguments:
479 - filepath: File to act on.
480 - prevdict: the previous dictionary. It is used to retrieve the cached sha-1
481 to skip recalculating the hash.
482 - level: determines the amount of information retrieved.
483 - read_only: If True, the file mode is manipulated. In practice, only save
484 one of 4 modes: 0755 (rwx), 0644 (rw), 0555 (rx), 0444 (r). On
485 windows, mode is not set since all files are 'executable' by
486 default.
487
488 Behaviors:
489 - NO_INFO retrieves no information.
490 - STATS_ONLY retrieves the file mode, file size, file timestamp, file link
491 destination if it is a file link.
492 - WITH_HASH retrieves all of STATS_ONLY plus the sha-1 of the content of the
493 file.
494 """
495 assert level in (NO_INFO, STATS_ONLY, WITH_HASH)
496 out = {}
497 # TODO(csharp): Fix crbug.com/150823 and enable the touched logic again.
498 # if prevdict.get('touched_only') == True:
499 # # The file's content is ignored. Skip the time and hard code mode.
500 # if get_flavor() != 'win':
501 # out['mode'] = stat.S_IRUSR | stat.S_IRGRP
502 # out['size'] = 0
503 # out['sha-1'] = SHA_1_NULL
504 # out['touched_only'] = True
505 # return out
506
507 if level >= STATS_ONLY:
508 try:
509 filestats = os.lstat(filepath)
510 except OSError:
511 # The file is not present.
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000512 raise run_isolated.MappingError('%s is missing' % filepath)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000513 is_link = stat.S_ISLNK(filestats.st_mode)
514 if get_flavor() != 'win':
515 # Ignore file mode on Windows since it's not really useful there.
516 filemode = stat.S_IMODE(filestats.st_mode)
517 # Remove write access for group and all access to 'others'.
518 filemode &= ~(stat.S_IWGRP | stat.S_IRWXO)
519 if read_only:
520 filemode &= ~stat.S_IWUSR
521 if filemode & stat.S_IXUSR:
522 filemode |= stat.S_IXGRP
523 else:
524 filemode &= ~stat.S_IXGRP
525 out['mode'] = filemode
526 if not is_link:
527 out['size'] = filestats.st_size
528 # Used to skip recalculating the hash. Use the most recent update time.
529 out['timestamp'] = int(round(filestats.st_mtime))
530 # If the timestamp wasn't updated, carry on the sha-1.
531 if prevdict.get('timestamp') == out['timestamp']:
532 if 'sha-1' in prevdict:
533 # Reuse the previous hash.
534 out['sha-1'] = prevdict['sha-1']
535 if 'link' in prevdict:
536 # Reuse the previous link destination.
537 out['link'] = prevdict['link']
538 if is_link and not 'link' in out:
539 # A symlink, store the link destination.
540 out['link'] = os.readlink(filepath)
541
542 if level >= WITH_HASH and not out.get('sha-1') and not out.get('link'):
543 if not is_link:
544 with open(filepath, 'rb') as f:
545 out['sha-1'] = hashlib.sha1(f.read()).hexdigest()
546 return out
547
548
549### Variable stuff.
550
551
maruel@chromium.org4b57f692012-10-05 20:33:09 +0000552def isolatedfile_to_state(filename):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000553 """Replaces the file's extension."""
maruel@chromium.org4d52ce42012-10-05 12:22:35 +0000554 return filename + '.state'
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000555
556
557def determine_root_dir(relative_root, infiles):
558 """For a list of infiles, determines the deepest root directory that is
559 referenced indirectly.
560
561 All arguments must be using os.path.sep.
562 """
563 # The trick used to determine the root directory is to look at "how far" back
564 # up it is looking up.
565 deepest_root = relative_root
566 for i in infiles:
567 x = relative_root
568 while i.startswith('..' + os.path.sep):
569 i = i[3:]
570 assert not i.startswith(os.path.sep)
571 x = os.path.dirname(x)
572 if deepest_root.startswith(x):
573 deepest_root = x
574 logging.debug(
575 'determine_root_dir(%s, %d files) -> %s' % (
576 relative_root, len(infiles), deepest_root))
577 return deepest_root
578
579
580def replace_variable(part, variables):
581 m = re.match(r'<\(([A-Z_]+)\)', part)
582 if m:
583 if m.group(1) not in variables:
584 raise ExecutionError(
585 'Variable "%s" was not found in %s.\nDid you forget to specify '
586 '--variable?' % (m.group(1), variables))
587 return variables[m.group(1)]
588 return part
589
590
591def process_variables(variables, relative_base_dir):
592 """Processes path variables as a special case and returns a copy of the dict.
593
594 For each 'path' variable: first normalizes it, verifies it exists, converts it
595 to an absolute path, then sets it as relative to relative_base_dir.
596 """
597 variables = variables.copy()
598 for i in PATH_VARIABLES:
599 if i not in variables:
600 continue
601 variable = os.path.normpath(variables[i])
602 if not os.path.isdir(variable):
603 raise ExecutionError('%s=%s is not a directory' % (i, variable))
604 # Variables could contain / or \ on windows. Always normalize to
605 # os.path.sep.
606 variable = os.path.abspath(variable.replace('/', os.path.sep))
607 # All variables are relative to the .isolate file.
608 variables[i] = os.path.relpath(variable, relative_base_dir)
609 return variables
610
611
612def eval_variables(item, variables):
613 """Replaces the .isolate variables in a string item.
614
615 Note that the .isolate format is a subset of the .gyp dialect.
616 """
617 return ''.join(
618 replace_variable(p, variables) for p in re.split(r'(<\([A-Z_]+\))', item))
619
620
621def classify_files(root_dir, tracked, untracked):
622 """Converts the list of files into a .isolate 'variables' dictionary.
623
624 Arguments:
625 - tracked: list of files names to generate a dictionary out of that should
626 probably be tracked.
627 - untracked: list of files names that must not be tracked.
628 """
629 # These directories are not guaranteed to be always present on every builder.
630 OPTIONAL_DIRECTORIES = (
631 'test/data/plugin',
632 'third_party/WebKit/LayoutTests',
633 )
634
635 new_tracked = []
636 new_untracked = list(untracked)
637
638 def should_be_tracked(filepath):
639 """Returns True if it is a file without whitespace in a non-optional
640 directory that has no symlink in its path.
641 """
642 if filepath.endswith('/'):
643 return False
644 if ' ' in filepath:
645 return False
646 if any(i in filepath for i in OPTIONAL_DIRECTORIES):
647 return False
648 # Look if any element in the path is a symlink.
649 split = filepath.split('/')
650 for i in range(len(split)):
651 if os.path.islink(os.path.join(root_dir, '/'.join(split[:i+1]))):
652 return False
653 return True
654
655 for filepath in sorted(tracked):
656 if should_be_tracked(filepath):
657 new_tracked.append(filepath)
658 else:
659 # Anything else.
660 new_untracked.append(filepath)
661
662 variables = {}
663 if new_tracked:
664 variables[KEY_TRACKED] = sorted(new_tracked)
665 if new_untracked:
666 variables[KEY_UNTRACKED] = sorted(new_untracked)
667 return variables
668
669
670def generate_simplified(
671 tracked, untracked, touched, root_dir, variables, relative_cwd):
672 """Generates a clean and complete .isolate 'variables' dictionary.
673
674 Cleans up and extracts only files from within root_dir then processes
675 variables and relative_cwd.
676 """
677 logging.info(
678 'generate_simplified(%d files, %s, %s, %s)' %
679 (len(tracked) + len(untracked) + len(touched),
680 root_dir, variables, relative_cwd))
681 # Constants.
682 # Skip log in PRODUCT_DIR. Note that these are applied on '/' style path
683 # separator.
684 LOG_FILE = re.compile(r'^\<\(PRODUCT_DIR\)\/[^\/]+\.log$')
685 EXECUTABLE = re.compile(
686 r'^(\<\(PRODUCT_DIR\)\/[^\/\.]+)' +
687 re.escape(variables.get('EXECUTABLE_SUFFIX', '')) +
688 r'$')
689
690 # Preparation work.
691 relative_cwd = cleanup_path(relative_cwd)
692 # Creates the right set of variables here. We only care about PATH_VARIABLES.
693 variables = dict(
694 ('<(%s)' % k, variables[k].replace(os.path.sep, '/'))
695 for k in PATH_VARIABLES if k in variables)
696
697 # Actual work: Process the files.
698 # TODO(maruel): if all the files in a directory are in part tracked and in
699 # part untracked, the directory will not be extracted. Tracked files should be
700 # 'promoted' to be untracked as needed.
701 tracked = trace_inputs.extract_directories(
702 root_dir, tracked, default_blacklist)
703 untracked = trace_inputs.extract_directories(
704 root_dir, untracked, default_blacklist)
705 # touched is not compressed, otherwise it would result in files to be archived
706 # that we don't need.
707
708 def fix(f):
709 """Bases the file on the most restrictive variable."""
710 logging.debug('fix(%s)' % f)
711 # Important, GYP stores the files with / and not \.
712 f = f.replace(os.path.sep, '/')
713 # If it's not already a variable.
714 if not f.startswith('<'):
715 # relative_cwd is usually the directory containing the gyp file. It may be
716 # empty if the whole directory containing the gyp file is needed.
717 f = posix_relpath(f, relative_cwd) or './'
718
719 for variable, root_path in variables.iteritems():
720 if f.startswith(root_path):
721 f = variable + f[len(root_path):]
722 break
723
724 # Now strips off known files we want to ignore and to any specific mangling
725 # as necessary. It's easier to do it here than generate a blacklist.
726 match = EXECUTABLE.match(f)
727 if match:
728 return match.group(1) + '<(EXECUTABLE_SUFFIX)'
729
730 # Blacklist logs and 'First Run' in the PRODUCT_DIR. First Run is not
731 # created by the compile, but by the test itself.
732 if LOG_FILE.match(f) or f == '<(PRODUCT_DIR)/First Run':
733 return None
734
735 if sys.platform == 'darwin':
736 # On OSX, the name of the output is dependent on gyp define, it can be
737 # 'Google Chrome.app' or 'Chromium.app', same for 'XXX
738 # Framework.framework'. Furthermore, they are versioned with a gyp
739 # variable. To lower the complexity of the .isolate file, remove all the
740 # individual entries that show up under any of the 4 entries and replace
741 # them with the directory itself. Overall, this results in a bit more
742 # files than strictly necessary.
743 OSX_BUNDLES = (
744 '<(PRODUCT_DIR)/Chromium Framework.framework/',
745 '<(PRODUCT_DIR)/Chromium.app/',
746 '<(PRODUCT_DIR)/Google Chrome Framework.framework/',
747 '<(PRODUCT_DIR)/Google Chrome.app/',
748 )
749 for prefix in OSX_BUNDLES:
750 if f.startswith(prefix):
751 # Note this result in duplicate values, so the a set() must be used to
752 # remove duplicates.
753 return prefix
754
755 return f
756
757 tracked = set(filter(None, (fix(f.path) for f in tracked)))
758 untracked = set(filter(None, (fix(f.path) for f in untracked)))
759 touched = set(filter(None, (fix(f.path) for f in touched)))
760 out = classify_files(root_dir, tracked, untracked)
761 if touched:
762 out[KEY_TOUCHED] = sorted(touched)
763 return out
764
765
766def generate_isolate(
767 tracked, untracked, touched, root_dir, variables, relative_cwd):
768 """Generates a clean and complete .isolate file."""
769 result = generate_simplified(
770 tracked, untracked, touched, root_dir, variables, relative_cwd)
771 return {
772 'conditions': [
773 ['OS=="%s"' % get_flavor(), {
774 'variables': result,
775 }],
776 ],
777 }
778
779
780def split_touched(files):
781 """Splits files that are touched vs files that are read."""
782 tracked = []
783 touched = []
784 for f in files:
785 if f.size:
786 tracked.append(f)
787 else:
788 touched.append(f)
789 return tracked, touched
790
791
792def pretty_print(variables, stdout):
793 """Outputs a gyp compatible list from the decoded variables.
794
795 Similar to pprint.print() but with NIH syndrome.
796 """
797 # Order the dictionary keys by these keys in priority.
798 ORDER = (
799 'variables', 'condition', 'command', 'relative_cwd', 'read_only',
800 KEY_TRACKED, KEY_UNTRACKED)
801
802 def sorting_key(x):
803 """Gives priority to 'most important' keys before the others."""
804 if x in ORDER:
805 return str(ORDER.index(x))
806 return x
807
808 def loop_list(indent, items):
809 for item in items:
810 if isinstance(item, basestring):
811 stdout.write('%s\'%s\',\n' % (indent, item))
812 elif isinstance(item, dict):
813 stdout.write('%s{\n' % indent)
814 loop_dict(indent + ' ', item)
815 stdout.write('%s},\n' % indent)
816 elif isinstance(item, list):
817 # A list inside a list will write the first item embedded.
818 stdout.write('%s[' % indent)
819 for index, i in enumerate(item):
820 if isinstance(i, basestring):
821 stdout.write(
822 '\'%s\', ' % i.replace('\\', '\\\\').replace('\'', '\\\''))
823 elif isinstance(i, dict):
824 stdout.write('{\n')
825 loop_dict(indent + ' ', i)
826 if index != len(item) - 1:
827 x = ', '
828 else:
829 x = ''
830 stdout.write('%s}%s' % (indent, x))
831 else:
832 assert False
833 stdout.write('],\n')
834 else:
835 assert False
836
837 def loop_dict(indent, items):
838 for key in sorted(items, key=sorting_key):
839 item = items[key]
840 stdout.write("%s'%s': " % (indent, key))
841 if isinstance(item, dict):
842 stdout.write('{\n')
843 loop_dict(indent + ' ', item)
844 stdout.write(indent + '},\n')
845 elif isinstance(item, list):
846 stdout.write('[\n')
847 loop_list(indent + ' ', item)
848 stdout.write(indent + '],\n')
849 elif isinstance(item, basestring):
850 stdout.write(
851 '\'%s\',\n' % item.replace('\\', '\\\\').replace('\'', '\\\''))
852 elif item in (True, False, None):
853 stdout.write('%s\n' % item)
854 else:
855 assert False, item
856
857 stdout.write('{\n')
858 loop_dict(' ', variables)
859 stdout.write('}\n')
860
861
862def union(lhs, rhs):
863 """Merges two compatible datastructures composed of dict/list/set."""
864 assert lhs is not None or rhs is not None
865 if lhs is None:
866 return copy.deepcopy(rhs)
867 if rhs is None:
868 return copy.deepcopy(lhs)
869 assert type(lhs) == type(rhs), (lhs, rhs)
870 if hasattr(lhs, 'union'):
871 # Includes set, OSSettings and Configs.
872 return lhs.union(rhs)
873 if isinstance(lhs, dict):
874 return dict((k, union(lhs.get(k), rhs.get(k))) for k in set(lhs).union(rhs))
875 elif isinstance(lhs, list):
876 # Do not go inside the list.
877 return lhs + rhs
878 assert False, type(lhs)
879
880
881def extract_comment(content):
882 """Extracts file level comment."""
883 out = []
884 for line in content.splitlines(True):
885 if line.startswith('#'):
886 out.append(line)
887 else:
888 break
889 return ''.join(out)
890
891
892def eval_content(content):
893 """Evaluates a python file and return the value defined in it.
894
895 Used in practice for .isolate files.
896 """
897 globs = {'__builtins__': None}
898 locs = {}
899 value = eval(content, globs, locs)
900 assert locs == {}, locs
901 assert globs == {'__builtins__': None}, globs
902 return value
903
904
905def verify_variables(variables):
906 """Verifies the |variables| dictionary is in the expected format."""
907 VALID_VARIABLES = [
908 KEY_TOUCHED,
909 KEY_TRACKED,
910 KEY_UNTRACKED,
911 'command',
912 'read_only',
913 ]
914 assert isinstance(variables, dict), variables
915 assert set(VALID_VARIABLES).issuperset(set(variables)), variables.keys()
916 for name, value in variables.iteritems():
917 if name == 'read_only':
918 assert value in (True, False, None), value
919 else:
920 assert isinstance(value, list), value
921 assert all(isinstance(i, basestring) for i in value), value
922
923
924def verify_condition(condition):
925 """Verifies the |condition| dictionary is in the expected format."""
926 VALID_INSIDE_CONDITION = ['variables']
927 assert isinstance(condition, list), condition
928 assert 2 <= len(condition) <= 3, condition
929 assert re.match(r'OS==\"([a-z]+)\"', condition[0]), condition[0]
930 for c in condition[1:]:
931 assert isinstance(c, dict), c
932 assert set(VALID_INSIDE_CONDITION).issuperset(set(c)), c.keys()
933 verify_variables(c.get('variables', {}))
934
935
936def verify_root(value):
937 VALID_ROOTS = ['variables', 'conditions']
938 assert isinstance(value, dict), value
939 assert set(VALID_ROOTS).issuperset(set(value)), value.keys()
940 verify_variables(value.get('variables', {}))
941
942 conditions = value.get('conditions', [])
943 assert isinstance(conditions, list), conditions
944 for condition in conditions:
945 verify_condition(condition)
946
947
948def remove_weak_dependencies(values, key, item, item_oses):
949 """Remove any oses from this key if the item is already under a strong key."""
950 if key == KEY_TOUCHED:
951 for stronger_key in (KEY_TRACKED, KEY_UNTRACKED):
952 oses = values.get(stronger_key, {}).get(item, None)
953 if oses:
954 item_oses -= oses
955
956 return item_oses
957
958
959def invert_map(variables):
960 """Converts a dict(OS, dict(deptype, list(dependencies)) to a flattened view.
961
962 Returns a tuple of:
963 1. dict(deptype, dict(dependency, set(OSes)) for easier processing.
964 2. All the OSes found as a set.
965 """
966 KEYS = (
967 KEY_TOUCHED,
968 KEY_TRACKED,
969 KEY_UNTRACKED,
970 'command',
971 'read_only',
972 )
973 out = dict((key, {}) for key in KEYS)
974 for os_name, values in variables.iteritems():
975 for key in (KEY_TOUCHED, KEY_TRACKED, KEY_UNTRACKED):
976 for item in values.get(key, []):
977 out[key].setdefault(item, set()).add(os_name)
978
979 # command needs special handling.
980 command = tuple(values.get('command', []))
981 out['command'].setdefault(command, set()).add(os_name)
982
983 # read_only needs special handling.
984 out['read_only'].setdefault(values.get('read_only'), set()).add(os_name)
985 return out, set(variables)
986
987
988def reduce_inputs(values, oses):
989 """Reduces the invert_map() output to the strictest minimum list.
990
991 1. Construct the inverse map first.
992 2. Look at each individual file and directory, map where they are used and
993 reconstruct the inverse dictionary.
994 3. Do not convert back to negative if only 2 OSes were merged.
995
996 Returns a tuple of:
997 1. the minimized dictionary
998 2. oses passed through as-is.
999 """
1000 KEYS = (
1001 KEY_TOUCHED,
1002 KEY_TRACKED,
1003 KEY_UNTRACKED,
1004 'command',
1005 'read_only',
1006 )
1007 out = dict((key, {}) for key in KEYS)
1008 assert all(oses), oses
1009 if len(oses) > 2:
1010 for key in KEYS:
1011 for item, item_oses in values.get(key, {}).iteritems():
1012 item_oses = remove_weak_dependencies(values, key, item, item_oses)
1013 if not item_oses:
1014 continue
1015
1016 # Converts all oses.difference('foo') to '!foo'.
1017 assert all(item_oses), item_oses
1018 missing = oses.difference(item_oses)
1019 if len(missing) == 1:
1020 # Replace it with a negative.
1021 out[key][item] = set(['!' + tuple(missing)[0]])
1022 elif not missing:
1023 out[key][item] = set([None])
1024 else:
1025 out[key][item] = set(item_oses)
1026 else:
1027 for key in KEYS:
1028 for item, item_oses in values.get(key, {}).iteritems():
1029 item_oses = remove_weak_dependencies(values, key, item, item_oses)
1030 if not item_oses:
1031 continue
1032
1033 # Converts all oses.difference('foo') to '!foo'.
1034 assert None not in item_oses, item_oses
1035 out[key][item] = set(item_oses)
1036 return out, oses
1037
1038
1039def convert_map_to_isolate_dict(values, oses):
1040 """Regenerates back a .isolate configuration dict from files and dirs
1041 mappings generated from reduce_inputs().
1042 """
1043 # First, inverse the mapping to make it dict first.
1044 config = {}
1045 for key in values:
1046 for item, oses in values[key].iteritems():
1047 if item is None:
1048 # For read_only default.
1049 continue
1050 for cond_os in oses:
1051 cond_key = None if cond_os is None else cond_os.lstrip('!')
1052 # Insert the if/else dicts.
1053 condition_values = config.setdefault(cond_key, [{}, {}])
1054 # If condition is negative, use index 1, else use index 0.
1055 cond_value = condition_values[int((cond_os or '').startswith('!'))]
1056 variables = cond_value.setdefault('variables', {})
1057
1058 if item in (True, False):
1059 # One-off for read_only.
1060 variables[key] = item
1061 else:
1062 if isinstance(item, tuple) and item:
1063 # One-off for command.
1064 # Do not merge lists and do not sort!
1065 # Note that item is a tuple.
1066 assert key not in variables
1067 variables[key] = list(item)
1068 elif item:
1069 # The list of items (files or dirs). Append the new item and keep
1070 # the list sorted.
1071 l = variables.setdefault(key, [])
1072 l.append(item)
1073 l.sort()
1074
1075 out = {}
1076 for o in sorted(config):
1077 d = config[o]
1078 if o is None:
1079 assert not d[1]
1080 out = union(out, d[0])
1081 else:
1082 c = out.setdefault('conditions', [])
1083 if d[1]:
1084 c.append(['OS=="%s"' % o] + d)
1085 else:
1086 c.append(['OS=="%s"' % o] + d[0:1])
1087 return out
1088
1089
1090### Internal state files.
1091
1092
1093class OSSettings(object):
1094 """Represents the dependencies for an OS. The structure is immutable.
1095
1096 It's the .isolate settings for a specific file.
1097 """
1098 def __init__(self, name, values):
1099 self.name = name
1100 verify_variables(values)
1101 self.touched = sorted(values.get(KEY_TOUCHED, []))
1102 self.tracked = sorted(values.get(KEY_TRACKED, []))
1103 self.untracked = sorted(values.get(KEY_UNTRACKED, []))
1104 self.command = values.get('command', [])[:]
1105 self.read_only = values.get('read_only')
1106
1107 def union(self, rhs):
1108 assert self.name == rhs.name
1109 assert not (self.command and rhs.command)
1110 var = {
1111 KEY_TOUCHED: sorted(self.touched + rhs.touched),
1112 KEY_TRACKED: sorted(self.tracked + rhs.tracked),
1113 KEY_UNTRACKED: sorted(self.untracked + rhs.untracked),
1114 'command': self.command or rhs.command,
1115 'read_only': rhs.read_only if self.read_only is None else self.read_only,
1116 }
1117 return OSSettings(self.name, var)
1118
1119 def flatten(self):
1120 out = {}
1121 if self.command:
1122 out['command'] = self.command
1123 if self.touched:
1124 out[KEY_TOUCHED] = self.touched
1125 if self.tracked:
1126 out[KEY_TRACKED] = self.tracked
1127 if self.untracked:
1128 out[KEY_UNTRACKED] = self.untracked
1129 if self.read_only is not None:
1130 out['read_only'] = self.read_only
1131 return out
1132
1133
1134class Configs(object):
1135 """Represents a processed .isolate file.
1136
1137 Stores the file in a processed way, split by each the OS-specific
1138 configurations.
1139
1140 The self.per_os[None] member contains all the 'else' clauses plus the default
1141 values. It is not included in the flatten() result.
1142 """
1143 def __init__(self, oses, file_comment):
1144 self.file_comment = file_comment
1145 self.per_os = {
1146 None: OSSettings(None, {}),
1147 }
1148 self.per_os.update(dict((name, OSSettings(name, {})) for name in oses))
1149
1150 def union(self, rhs):
1151 items = list(set(self.per_os.keys() + rhs.per_os.keys()))
1152 # Takes the first file comment, prefering lhs.
1153 out = Configs(items, self.file_comment or rhs.file_comment)
1154 for key in items:
1155 out.per_os[key] = union(self.per_os.get(key), rhs.per_os.get(key))
1156 return out
1157
1158 def add_globals(self, values):
1159 for key in self.per_os:
1160 self.per_os[key] = self.per_os[key].union(OSSettings(key, values))
1161
1162 def add_values(self, for_os, values):
1163 self.per_os[for_os] = self.per_os[for_os].union(OSSettings(for_os, values))
1164
1165 def add_negative_values(self, for_os, values):
1166 """Includes the variables to all OSes except |for_os|.
1167
1168 This includes 'None' so unknown OSes gets it too.
1169 """
1170 for key in self.per_os:
1171 if key != for_os:
1172 self.per_os[key] = self.per_os[key].union(OSSettings(key, values))
1173
1174 def flatten(self):
1175 """Returns a flat dictionary representation of the configuration.
1176
1177 Skips None pseudo-OS.
1178 """
1179 return dict(
1180 (k, v.flatten()) for k, v in self.per_os.iteritems() if k is not None)
1181
1182
1183def load_isolate_as_config(value, file_comment, default_oses):
1184 """Parses one .isolate file and returns a Configs() instance.
1185
1186 |value| is the loaded dictionary that was defined in the gyp file.
1187
1188 The expected format is strict, anything diverting from the format below will
1189 throw an assert:
1190 {
1191 'variables': {
1192 'command': [
1193 ...
1194 ],
1195 'isolate_dependency_tracked': [
1196 ...
1197 ],
1198 'isolate_dependency_untracked': [
1199 ...
1200 ],
1201 'read_only': False,
1202 },
1203 'conditions': [
1204 ['OS=="<os>"', {
1205 'variables': {
1206 ...
1207 },
1208 }, { # else
1209 'variables': {
1210 ...
1211 },
1212 }],
1213 ...
1214 ],
1215 }
1216 """
1217 verify_root(value)
1218
1219 # Scan to get the list of OSes.
1220 conditions = value.get('conditions', [])
1221 oses = set(re.match(r'OS==\"([a-z]+)\"', c[0]).group(1) for c in conditions)
1222 oses = oses.union(default_oses)
1223 configs = Configs(oses, file_comment)
1224
1225 # Global level variables.
1226 configs.add_globals(value.get('variables', {}))
1227
1228 # OS specific variables.
1229 for condition in conditions:
1230 condition_os = re.match(r'OS==\"([a-z]+)\"', condition[0]).group(1)
1231 configs.add_values(condition_os, condition[1].get('variables', {}))
1232 if len(condition) > 2:
1233 configs.add_negative_values(
1234 condition_os, condition[2].get('variables', {}))
1235 return configs
1236
1237
1238def load_isolate_for_flavor(content, flavor):
1239 """Loads the .isolate file and returns the information unprocessed.
1240
1241 Returns the command, dependencies and read_only flag. The dependencies are
1242 fixed to use os.path.sep.
1243 """
1244 # Load the .isolate file, process its conditions, retrieve the command and
1245 # dependencies.
1246 configs = load_isolate_as_config(eval_content(content), None, DEFAULT_OSES)
1247 config = configs.per_os.get(flavor) or configs.per_os.get(None)
1248 if not config:
1249 raise ExecutionError('Failed to load configuration for \'%s\'' % flavor)
1250 # Merge tracked and untracked dependencies, isolate.py doesn't care about the
1251 # trackability of the dependencies, only the build tool does.
1252 dependencies = [
1253 f.replace('/', os.path.sep) for f in config.tracked + config.untracked
1254 ]
1255 touched = [f.replace('/', os.path.sep) for f in config.touched]
1256 return config.command, dependencies, touched, config.read_only
1257
1258
1259class Flattenable(object):
1260 """Represents data that can be represented as a json file."""
1261 MEMBERS = ()
1262
1263 def flatten(self):
1264 """Returns a json-serializable version of itself.
1265
1266 Skips None entries.
1267 """
1268 items = ((member, getattr(self, member)) for member in self.MEMBERS)
1269 return dict((member, value) for member, value in items if value is not None)
1270
1271 @classmethod
1272 def load(cls, data):
1273 """Loads a flattened version."""
1274 data = data.copy()
1275 out = cls()
1276 for member in out.MEMBERS:
1277 if member in data:
1278 # Access to a protected member XXX of a client class
1279 # pylint: disable=W0212
1280 out._load_member(member, data.pop(member))
1281 if data:
1282 raise ValueError(
1283 'Found unexpected entry %s while constructing an object %s' %
1284 (data, cls.__name__), data, cls.__name__)
1285 return out
1286
1287 def _load_member(self, member, value):
1288 """Loads a member into self."""
1289 setattr(self, member, value)
1290
1291 @classmethod
1292 def load_file(cls, filename):
1293 """Loads the data from a file or return an empty instance."""
1294 out = cls()
1295 try:
1296 out = cls.load(trace_inputs.read_json(filename))
1297 logging.debug('Loaded %s(%s)' % (cls.__name__, filename))
1298 except (IOError, ValueError):
1299 logging.warn('Failed to load %s' % filename)
1300 return out
1301
1302
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001303class IsolatedFile(Flattenable):
1304 """Describes the content of a .isolated file.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001305
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001306 This file is used by run_isolated.py so its content is strictly only
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001307 what is necessary to run the test outside of a checkout.
1308
1309 It is important to note that the 'files' dict keys are using native OS path
1310 separator instead of '/' used in .isolate file.
1311 """
1312 MEMBERS = (
1313 'command',
1314 'files',
1315 'os',
1316 'read_only',
1317 'relative_cwd',
1318 )
1319
1320 os = get_flavor()
1321
1322 def __init__(self):
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001323 super(IsolatedFile, self).__init__()
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001324 self.command = []
1325 self.files = {}
1326 self.read_only = None
1327 self.relative_cwd = None
1328
1329 def update(self, command, infiles, touched, read_only, relative_cwd):
1330 """Updates the result state with new information."""
1331 self.command = command
1332 # Add new files.
1333 for f in infiles:
1334 self.files.setdefault(f, {})
1335 for f in touched:
1336 self.files.setdefault(f, {})['touched_only'] = True
1337 # Prune extraneous files that are not a dependency anymore.
1338 for f in set(self.files).difference(set(infiles).union(touched)):
1339 del self.files[f]
1340 if read_only is not None:
1341 self.read_only = read_only
1342 self.relative_cwd = relative_cwd
1343
1344 def _load_member(self, member, value):
1345 if member == 'os':
1346 if value != self.os:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001347 raise run_isolated.ConfigError(
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001348 'The .isolated file was created on another platform')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001349 else:
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001350 super(IsolatedFile, self)._load_member(member, value)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001351
1352 def __str__(self):
1353 out = '%s(\n' % self.__class__.__name__
1354 out += ' command: %s\n' % self.command
1355 out += ' files: %d\n' % len(self.files)
1356 out += ' read_only: %s\n' % self.read_only
1357 out += ' relative_cwd: %s)' % self.relative_cwd
1358 return out
1359
1360
1361class SavedState(Flattenable):
1362 """Describes the content of a .state file.
1363
1364 The items in this file are simply to improve the developer's life and aren't
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001365 used by run_isolated.py. This file can always be safely removed.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001366
1367 isolate_file permits to find back root_dir, variables are used for stateful
1368 rerun.
1369 """
1370 MEMBERS = (
1371 'isolate_file',
1372 'variables',
1373 )
1374
1375 def __init__(self):
1376 super(SavedState, self).__init__()
1377 self.isolate_file = None
1378 self.variables = {}
1379
1380 def update(self, isolate_file, variables):
1381 """Updates the saved state with new information."""
1382 self.isolate_file = isolate_file
1383 self.variables.update(variables)
1384
1385 @classmethod
1386 def load(cls, data):
1387 out = super(SavedState, cls).load(data)
1388 if out.isolate_file:
1389 out.isolate_file = trace_inputs.get_native_path_case(out.isolate_file)
1390 return out
1391
1392 def __str__(self):
1393 out = '%s(\n' % self.__class__.__name__
1394 out += ' isolate_file: %s\n' % self.isolate_file
1395 out += ' variables: %s' % ''.join(
1396 '\n %s=%s' % (k, self.variables[k]) for k in sorted(self.variables))
1397 out += ')'
1398 return out
1399
1400
1401class CompleteState(object):
1402 """Contains all the state to run the task at hand."""
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001403 def __init__(self, isolated_filepath, isolated, saved_state):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001404 super(CompleteState, self).__init__()
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001405 self.isolated_filepath = isolated_filepath
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001406 # Contains the data that will be used by run_isolated.py
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001407 self.isolated = isolated
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001408 # Contains the data to ease developer's use-case but that is not strictly
1409 # necessary.
1410 self.saved_state = saved_state
1411
1412 @classmethod
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001413 def load_files(cls, isolated_filepath):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001414 """Loads state from disk."""
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001415 assert os.path.isabs(isolated_filepath), isolated_filepath
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001416 return cls(
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001417 isolated_filepath,
1418 IsolatedFile.load_file(isolated_filepath),
1419 SavedState.load_file(isolatedfile_to_state(isolated_filepath)))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001420
1421 def load_isolate(self, isolate_file, variables):
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001422 """Updates self.isolated and self.saved_state with information loaded from a
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001423 .isolate file.
1424
1425 Processes the loaded data, deduce root_dir, relative_cwd.
1426 """
1427 # Make sure to not depend on os.getcwd().
1428 assert os.path.isabs(isolate_file), isolate_file
1429 logging.info(
1430 'CompleteState.load_isolate(%s, %s)' % (isolate_file, variables))
1431 relative_base_dir = os.path.dirname(isolate_file)
1432
1433 # Processes the variables and update the saved state.
1434 variables = process_variables(variables, relative_base_dir)
1435 self.saved_state.update(isolate_file, variables)
1436
1437 with open(isolate_file, 'r') as f:
1438 # At that point, variables are not replaced yet in command and infiles.
1439 # infiles may contain directory entries and is in posix style.
1440 command, infiles, touched, read_only = load_isolate_for_flavor(
1441 f.read(), get_flavor())
1442 command = [eval_variables(i, self.saved_state.variables) for i in command]
1443 infiles = [eval_variables(f, self.saved_state.variables) for f in infiles]
1444 touched = [eval_variables(f, self.saved_state.variables) for f in touched]
1445 # root_dir is automatically determined by the deepest root accessed with the
1446 # form '../../foo/bar'.
1447 root_dir = determine_root_dir(relative_base_dir, infiles + touched)
1448 # The relative directory is automatically determined by the relative path
1449 # between root_dir and the directory containing the .isolate file,
1450 # isolate_base_dir.
1451 relative_cwd = os.path.relpath(relative_base_dir, root_dir)
1452 # Normalize the files based to root_dir. It is important to keep the
1453 # trailing os.path.sep at that step.
1454 infiles = [
1455 relpath(normpath(os.path.join(relative_base_dir, f)), root_dir)
1456 for f in infiles
1457 ]
1458 touched = [
1459 relpath(normpath(os.path.join(relative_base_dir, f)), root_dir)
1460 for f in touched
1461 ]
1462 # Expand the directories by listing each file inside. Up to now, trailing
1463 # os.path.sep must be kept. Do not expand 'touched'.
1464 infiles = expand_directories_and_symlinks(
1465 root_dir,
1466 infiles,
1467 lambda x: re.match(r'.*\.(git|svn|pyc)$', x))
1468
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001469 # Finally, update the new stuff in the foo.isolated file, the file that is
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001470 # used by run_isolated.py.
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001471 self.isolated.update(command, infiles, touched, read_only, relative_cwd)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001472 logging.debug(self)
1473
1474 def process_inputs(self, level):
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001475 """Updates self.isolated.files with the files' mode and hash.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001476
1477 See process_input() for more information.
1478 """
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001479 for infile in sorted(self.isolated.files):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001480 filepath = os.path.join(self.root_dir, infile)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001481 self.isolated.files[infile] = process_input(
1482 filepath, self.isolated.files[infile], level, self.isolated.read_only)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001483
1484 def save_files(self):
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001485 """Saves both self.isolated and self.saved_state."""
1486 logging.debug('Dumping to %s' % self.isolated_filepath)
1487 trace_inputs.write_json(
1488 self.isolated_filepath, self.isolated.flatten(), True)
1489 total_bytes = sum(i
1490 .get('size', 0) for i in self.isolated.files.itervalues())
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001491 if total_bytes:
1492 logging.debug('Total size: %d bytes' % total_bytes)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001493 saved_state_file = isolatedfile_to_state(self.isolated_filepath)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001494 logging.debug('Dumping to %s' % saved_state_file)
1495 trace_inputs.write_json(saved_state_file, self.saved_state.flatten(), True)
1496
1497 @property
1498 def root_dir(self):
1499 """isolate_file is always inside relative_cwd relative to root_dir."""
1500 isolate_dir = os.path.dirname(self.saved_state.isolate_file)
1501 # Special case '.'.
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001502 if self.isolated.relative_cwd == '.':
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001503 return isolate_dir
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001504 assert isolate_dir.endswith(self.isolated.relative_cwd), (
1505 isolate_dir, self.isolated.relative_cwd)
1506 return isolate_dir[:-(len(self.isolated.relative_cwd) + 1)]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001507
1508 @property
1509 def resultdir(self):
1510 """Directory containing the results, usually equivalent to the variable
1511 PRODUCT_DIR.
1512 """
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001513 return os.path.dirname(self.isolated_filepath)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001514
1515 def __str__(self):
1516 def indent(data, indent_length):
1517 """Indents text."""
1518 spacing = ' ' * indent_length
1519 return ''.join(spacing + l for l in str(data).splitlines(True))
1520
1521 out = '%s(\n' % self.__class__.__name__
1522 out += ' root_dir: %s\n' % self.root_dir
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001523 out += ' result: %s\n' % indent(self.isolated, 2)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001524 out += ' saved_state: %s)' % indent(self.saved_state, 2)
1525 return out
1526
1527
1528def load_complete_state(options, level):
1529 """Loads a CompleteState.
1530
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001531 This includes data from .isolate, .isolated and .state files.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001532
1533 Arguments:
1534 options: Options instance generated with OptionParserIsolate.
1535 level: Amount of data to fetch.
1536 """
1537 if options.result:
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001538 # Load the previous state if it was present. Namely, "foo.isolated" and
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001539 # "foo.state".
1540 complete_state = CompleteState.load_files(options.result)
1541 else:
1542 # Constructs a dummy object that cannot be saved. Useful for temporary
1543 # commands like 'run'.
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001544 complete_state = CompleteState(None, IsolatedFile(), SavedState())
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001545 options.isolate = options.isolate or complete_state.saved_state.isolate_file
1546 if not options.isolate:
1547 raise ExecutionError('A .isolate file is required.')
1548 if (complete_state.saved_state.isolate_file and
1549 options.isolate != complete_state.saved_state.isolate_file):
1550 raise ExecutionError(
1551 '%s and %s do not match.' % (
1552 options.isolate, complete_state.saved_state.isolate_file))
1553
1554 # Then load the .isolate and expands directories.
1555 complete_state.load_isolate(options.isolate, options.variables)
1556
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001557 # Regenerate complete_state.isolated.files.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001558 complete_state.process_inputs(level)
1559 return complete_state
1560
1561
1562def read_trace_as_isolate_dict(complete_state):
1563 """Reads a trace and returns the .isolate dictionary."""
1564 api = trace_inputs.get_api()
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001565 logfile = complete_state.isolated_filepath + '.log'
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001566 if not os.path.isfile(logfile):
1567 raise ExecutionError(
1568 'No log file \'%s\' to read, did you forget to \'trace\'?' % logfile)
1569 try:
1570 results = trace_inputs.load_trace(
1571 logfile, complete_state.root_dir, api, default_blacklist)
1572 tracked, touched = split_touched(results.existent)
1573 value = generate_isolate(
1574 tracked,
1575 [],
1576 touched,
1577 complete_state.root_dir,
1578 complete_state.saved_state.variables,
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001579 complete_state.isolated.relative_cwd)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001580 return value
1581 except trace_inputs.TracingFailure, e:
1582 raise ExecutionError(
1583 'Reading traces failed for: %s\n%s' %
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001584 (' '.join(complete_state.isolated.command), str(e)))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001585
1586
1587def print_all(comment, data, stream):
1588 """Prints a complete .isolate file and its top-level file comment into a
1589 stream.
1590 """
1591 if comment:
1592 stream.write(comment)
1593 pretty_print(data, stream)
1594
1595
1596def merge(complete_state):
1597 """Reads a trace and merges it back into the source .isolate file."""
1598 value = read_trace_as_isolate_dict(complete_state)
1599
1600 # Now take that data and union it into the original .isolate file.
1601 with open(complete_state.saved_state.isolate_file, 'r') as f:
1602 prev_content = f.read()
1603 prev_config = load_isolate_as_config(
1604 eval_content(prev_content),
1605 extract_comment(prev_content),
1606 DEFAULT_OSES)
1607 new_config = load_isolate_as_config(value, '', DEFAULT_OSES)
1608 config = union(prev_config, new_config)
1609 # pylint: disable=E1103
1610 data = convert_map_to_isolate_dict(
1611 *reduce_inputs(*invert_map(config.flatten())))
1612 print 'Updating %s' % complete_state.saved_state.isolate_file
1613 with open(complete_state.saved_state.isolate_file, 'wb') as f:
1614 print_all(config.file_comment, data, f)
1615
1616
1617def CMDcheck(args):
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001618 """Checks that all the inputs are present and update .isolated."""
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001619 parser = OptionParserIsolate(command='check')
1620 options, _ = parser.parse_args(args)
1621 complete_state = load_complete_state(options, NO_INFO)
1622
1623 # Nothing is done specifically. Just store the result and state.
1624 complete_state.save_files()
1625 return 0
1626
1627
1628def CMDhashtable(args):
1629 """Creates a hash table content addressed object store.
1630
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001631 All the files listed in the .isolated file are put in the output directory
1632 with the file name being the sha-1 of the file's content.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001633 """
1634 parser = OptionParserIsolate(command='hashtable')
1635 options, _ = parser.parse_args(args)
1636
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001637 with run_isolated.Profiler('GenerateHashtable'):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001638 success = False
1639 try:
1640 complete_state = load_complete_state(options, WITH_HASH)
1641 options.outdir = (
1642 options.outdir or os.path.join(complete_state.resultdir, 'hashtable'))
1643 # Make sure that complete_state isn't modified until save_files() is
1644 # called, because any changes made to it here will propagate to the files
1645 # created (which is probably not intended).
1646 complete_state.save_files()
1647
1648 logging.info('Creating content addressed object store with %d item',
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001649 len(complete_state.isolated.files))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001650
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001651 with open(complete_state.isolated_filepath, 'rb') as f:
maruel@chromium.org861a5e72012-10-09 14:49:42 +00001652 content = f.read()
1653 manifest_hash = hashlib.sha1(content).hexdigest()
1654 manifest_metadata = {'sha-1': manifest_hash, 'size': len(content)}
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001655
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001656 infiles = complete_state.isolated.files
1657 infiles[complete_state.isolated_filepath] = manifest_metadata
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001658
1659 if re.match(r'^https?://.+$', options.outdir):
1660 upload_sha1_tree(
1661 base_url=options.outdir,
1662 indir=complete_state.root_dir,
1663 infiles=infiles)
1664 else:
1665 recreate_tree(
1666 outdir=options.outdir,
1667 indir=complete_state.root_dir,
1668 infiles=infiles,
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001669 action=run_isolated.HARDLINK,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001670 as_sha1=True)
1671 success = True
1672 finally:
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001673 # If the command failed, delete the .isolated file if it exists. This is
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001674 # important so no stale swarm job is executed.
1675 if not success and os.path.isfile(options.result):
1676 os.remove(options.result)
1677
1678
1679def CMDnoop(args):
1680 """Touches --result but does nothing else.
1681
1682 This mode is to help transition since some builders do not have all the test
1683 data files checked out. Touch result_file and exit silently.
1684 """
1685 parser = OptionParserIsolate(command='noop')
1686 options, _ = parser.parse_args(args)
1687 # In particular, do not call load_complete_state().
1688 open(options.result, 'a').close()
1689 return 0
1690
1691
1692def CMDmerge(args):
1693 """Reads and merges the data from the trace back into the original .isolate.
1694
1695 Ignores --outdir.
1696 """
1697 parser = OptionParserIsolate(command='merge', require_result=False)
1698 options, _ = parser.parse_args(args)
1699 complete_state = load_complete_state(options, NO_INFO)
1700 merge(complete_state)
1701 return 0
1702
1703
1704def CMDread(args):
1705 """Reads the trace file generated with command 'trace'.
1706
1707 Ignores --outdir.
1708 """
1709 parser = OptionParserIsolate(command='read', require_result=False)
1710 options, _ = parser.parse_args(args)
1711 complete_state = load_complete_state(options, NO_INFO)
1712 value = read_trace_as_isolate_dict(complete_state)
1713 pretty_print(value, sys.stdout)
1714 return 0
1715
1716
1717def CMDremap(args):
1718 """Creates a directory with all the dependencies mapped into it.
1719
1720 Useful to test manually why a test is failing. The target executable is not
1721 run.
1722 """
1723 parser = OptionParserIsolate(command='remap', require_result=False)
1724 options, _ = parser.parse_args(args)
1725 complete_state = load_complete_state(options, STATS_ONLY)
1726
1727 if not options.outdir:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001728 options.outdir = run_isolated.make_temp_dir(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001729 'isolate', complete_state.root_dir)
1730 else:
1731 if not os.path.isdir(options.outdir):
1732 os.makedirs(options.outdir)
1733 print 'Remapping into %s' % options.outdir
1734 if len(os.listdir(options.outdir)):
1735 raise ExecutionError('Can\'t remap in a non-empty directory')
1736 recreate_tree(
1737 outdir=options.outdir,
1738 indir=complete_state.root_dir,
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001739 infiles=complete_state.isolated.files,
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001740 action=run_isolated.HARDLINK,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001741 as_sha1=False)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001742 if complete_state.isolated.read_only:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001743 run_isolated.make_writable(options.outdir, True)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001744
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001745 if complete_state.isolated_filepath:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001746 complete_state.save_files()
1747 return 0
1748
1749
1750def CMDrun(args):
1751 """Runs the test executable in an isolated (temporary) directory.
1752
1753 All the dependencies are mapped into the temporary directory and the
1754 directory is cleaned up after the target exits. Warning: if -outdir is
1755 specified, it is deleted upon exit.
1756
1757 Argument processing stops at the first non-recognized argument and these
1758 arguments are appended to the command line of the target to run. For example,
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001759 use: isolate.py -r foo.isolated -- --gtest_filter=Foo.Bar
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001760 """
1761 parser = OptionParserIsolate(command='run', require_result=False)
1762 parser.enable_interspersed_args()
1763 options, args = parser.parse_args(args)
1764 complete_state = load_complete_state(options, STATS_ONLY)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001765 cmd = complete_state.isolated.command + args
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001766 if not cmd:
1767 raise ExecutionError('No command to run')
1768 cmd = trace_inputs.fix_python_path(cmd)
1769 try:
1770 if not options.outdir:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001771 options.outdir = run_isolated.make_temp_dir(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001772 'isolate', complete_state.root_dir)
1773 else:
1774 if not os.path.isdir(options.outdir):
1775 os.makedirs(options.outdir)
1776 recreate_tree(
1777 outdir=options.outdir,
1778 indir=complete_state.root_dir,
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001779 infiles=complete_state.isolated.files,
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001780 action=run_isolated.HARDLINK,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001781 as_sha1=False)
1782 cwd = os.path.normpath(
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001783 os.path.join(options.outdir, complete_state.isolated.relative_cwd))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001784 if not os.path.isdir(cwd):
1785 # It can happen when no files are mapped from the directory containing the
1786 # .isolate file. But the directory must exist to be the current working
1787 # directory.
1788 os.makedirs(cwd)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001789 if complete_state.isolated.read_only:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001790 run_isolated.make_writable(options.outdir, True)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001791 logging.info('Running %s, cwd=%s' % (cmd, cwd))
1792 result = subprocess.call(cmd, cwd=cwd)
1793 finally:
1794 if options.outdir:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001795 run_isolated.rmtree(options.outdir)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001796
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001797 if complete_state.isolated_filepath:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001798 complete_state.save_files()
1799 return result
1800
1801
1802def CMDtrace(args):
1803 """Traces the target using trace_inputs.py.
1804
1805 It runs the executable without remapping it, and traces all the files it and
1806 its child processes access. Then the 'read' command can be used to generate an
1807 updated .isolate file out of it.
1808
1809 Argument processing stops at the first non-recognized argument and these
1810 arguments are appended to the command line of the target to run. For example,
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001811 use: isolate.py -r foo.isolated -- --gtest_filter=Foo.Bar
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001812 """
1813 parser = OptionParserIsolate(command='trace')
1814 parser.enable_interspersed_args()
1815 parser.add_option(
1816 '-m', '--merge', action='store_true',
1817 help='After tracing, merge the results back in the .isolate file')
1818 options, args = parser.parse_args(args)
1819 complete_state = load_complete_state(options, STATS_ONLY)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001820 cmd = complete_state.isolated.command + args
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001821 if not cmd:
1822 raise ExecutionError('No command to run')
1823 cmd = trace_inputs.fix_python_path(cmd)
1824 cwd = os.path.normpath(os.path.join(
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001825 complete_state.root_dir, complete_state.isolated.relative_cwd))
maruel@chromium.org808f6af2012-10-11 14:08:08 +00001826 cmd[0] = os.path.normpath(os.path.join(cwd, cmd[0]))
1827 if not os.path.isfile(cmd[0]):
1828 raise ExecutionError(
1829 'Tracing failed for: %s\nIt doesn\'t exit' % ' '.join(cmd))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001830 logging.info('Running %s, cwd=%s' % (cmd, cwd))
1831 api = trace_inputs.get_api()
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001832 logfile = complete_state.isolated_filepath + '.log'
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001833 api.clean_trace(logfile)
1834 try:
1835 with api.get_tracer(logfile) as tracer:
1836 result, _ = tracer.trace(
1837 cmd,
1838 cwd,
1839 'default',
1840 True)
1841 except trace_inputs.TracingFailure, e:
1842 raise ExecutionError('Tracing failed for: %s\n%s' % (' '.join(cmd), str(e)))
1843
1844 complete_state.save_files()
1845
1846 if options.merge:
1847 merge(complete_state)
1848
1849 return result
1850
1851
1852class OptionParserIsolate(trace_inputs.OptionParserWithNiceDescription):
1853 """Adds automatic --isolate, --result, --out and --variables handling."""
1854 def __init__(self, require_result=True, **kwargs):
maruel@chromium.org55276902012-10-05 20:56:19 +00001855 trace_inputs.OptionParserWithNiceDescription.__init__(
1856 self,
1857 verbose=int(os.environ.get('ISOLATE_DEBUG', 0)),
1858 **kwargs)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001859 default_variables = [('OS', get_flavor())]
1860 if sys.platform in ('win32', 'cygwin'):
1861 default_variables.append(('EXECUTABLE_SUFFIX', '.exe'))
1862 else:
1863 default_variables.append(('EXECUTABLE_SUFFIX', ''))
1864 group = optparse.OptionGroup(self, "Common options")
1865 group.add_option(
1866 '-r', '--result',
1867 metavar='FILE',
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001868 help='.isolated file to store the json manifest')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001869 group.add_option(
1870 '-i', '--isolate',
1871 metavar='FILE',
1872 help='.isolate file to load the dependency data from')
1873 group.add_option(
1874 '-V', '--variable',
1875 nargs=2,
1876 action='append',
1877 default=default_variables,
1878 dest='variables',
1879 metavar='FOO BAR',
1880 help='Variables to process in the .isolate file, default: %default. '
1881 'Variables are persistent accross calls, they are saved inside '
1882 '<results>.state')
1883 group.add_option(
1884 '-o', '--outdir', metavar='DIR',
1885 help='Directory used to recreate the tree or store the hash table. '
1886 'If the environment variable ISOLATE_HASH_TABLE_DIR exists, it '
1887 'will be used. Otherwise, for run and remap, uses a /tmp '
1888 'subdirectory. For the other modes, defaults to the directory '
1889 'containing --result')
1890 self.add_option_group(group)
1891 self.require_result = require_result
1892
1893 def parse_args(self, *args, **kwargs):
1894 """Makes sure the paths make sense.
1895
1896 On Windows, / and \ are often mixed together in a path.
1897 """
1898 options, args = trace_inputs.OptionParserWithNiceDescription.parse_args(
1899 self, *args, **kwargs)
1900 if not self.allow_interspersed_args and args:
1901 self.error('Unsupported argument: %s' % args)
1902
1903 options.variables = dict(options.variables)
1904
1905 if self.require_result and not options.result:
1906 self.error('--result is required.')
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001907 if options.result and not options.result.endswith('.isolated'):
1908 self.error('--result value must end with \'.isolated\'')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001909
1910 if options.result:
1911 options.result = os.path.abspath(options.result.replace('/', os.path.sep))
1912
1913 if options.isolate:
1914 options.isolate = trace_inputs.get_native_path_case(
1915 os.path.abspath(
1916 options.isolate.replace('/', os.path.sep)))
1917
1918 if options.outdir and not re.match(r'^https?://.+$', options.outdir):
1919 options.outdir = os.path.abspath(
1920 options.outdir.replace('/', os.path.sep))
1921
1922 return options, args
1923
1924
1925### Glue code to make all the commands works magically.
1926
1927
1928CMDhelp = trace_inputs.CMDhelp
1929
1930
1931def main(argv):
1932 try:
1933 return trace_inputs.main_impl(argv)
1934 except (
1935 ExecutionError,
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001936 run_isolated.MappingError,
1937 run_isolated.ConfigError) as e:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001938 sys.stderr.write('\nError: ')
1939 sys.stderr.write(str(e))
1940 sys.stderr.write('\n')
1941 return 1
1942
1943
1944if __name__ == '__main__':
1945 sys.exit(main(sys.argv[1:]))