blob: fd9002580fb6a346936fb3fae7da3bbb6a1dba14 [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().
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +000037SHA_1_NULL = hashlib.sha1().hexdigest()
38
39PATH_VARIABLES = ('DEPTH', 'PRODUCT_DIR')
40DEFAULT_OSES = ('linux', 'mac', 'win')
41
42# Files that should be 0-length when mapped.
43KEY_TOUCHED = 'isolate_dependency_touched'
44# Files that should be tracked by the build tool.
45KEY_TRACKED = 'isolate_dependency_tracked'
46# Files that should not be tracked by the build tool.
47KEY_UNTRACKED = 'isolate_dependency_untracked'
48
49_GIT_PATH = os.path.sep + '.git'
50_SVN_PATH = os.path.sep + '.svn'
51
52# The maximum number of upload attempts to try when uploading a single file.
53MAX_UPLOAD_ATTEMPTS = 5
54
55# The minimum size of files to upload directly to the blobstore.
56MIN_SIZE_FOR_DIRECT_BLOBSTORE = 20 * 8
57
58
59class ExecutionError(Exception):
60 """A generic error occurred."""
61 def __str__(self):
62 return self.args[0]
63
64
65### Path handling code.
66
67
68def relpath(path, root):
69 """os.path.relpath() that keeps trailing os.path.sep."""
70 out = os.path.relpath(path, root)
71 if path.endswith(os.path.sep):
72 out += os.path.sep
73 return out
74
75
76def normpath(path):
77 """os.path.normpath() that keeps trailing os.path.sep."""
78 out = os.path.normpath(path)
79 if path.endswith(os.path.sep):
80 out += os.path.sep
81 return out
82
83
84def posix_relpath(path, root):
85 """posix.relpath() that keeps trailing slash."""
86 out = posixpath.relpath(path, root)
87 if path.endswith('/'):
88 out += '/'
89 return out
90
91
92def cleanup_path(x):
93 """Cleans up a relative path. Converts any os.path.sep to '/' on Windows."""
94 if x:
95 x = x.rstrip(os.path.sep).replace(os.path.sep, '/')
96 if x == '.':
97 x = ''
98 if x:
99 x += '/'
100 return x
101
102
103def default_blacklist(f):
104 """Filters unimportant files normally ignored."""
105 return (
106 f.endswith(('.pyc', '.run_test_cases', 'testserver.log')) or
107 _GIT_PATH in f or
108 _SVN_PATH in f or
109 f in ('.git', '.svn'))
110
111
112def expand_directory_and_symlink(indir, relfile, blacklist):
113 """Expands a single input. It can result in multiple outputs.
114
115 This function is recursive when relfile is a directory or a symlink.
116
117 Note: this code doesn't properly handle recursive symlink like one created
118 with:
119 ln -s .. foo
120 """
121 if os.path.isabs(relfile):
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000122 raise run_isolated.MappingError(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000123 'Can\'t map absolute path %s' % relfile)
124
125 infile = normpath(os.path.join(indir, relfile))
126 if not infile.startswith(indir):
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000127 raise run_isolated.MappingError(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000128 'Can\'t map file %s outside %s' % (infile, indir))
129
130 if sys.platform != 'win32':
131 # Look if any item in relfile is a symlink.
132 base, symlink, rest = trace_inputs.split_at_symlink(indir, relfile)
133 if symlink:
134 # Append everything pointed by the symlink. If the symlink is recursive,
135 # this code blows up.
136 symlink_relfile = os.path.join(base, symlink)
137 symlink_path = os.path.join(indir, symlink_relfile)
maruel@chromium.orgf43e68b2012-10-15 20:23:10 +0000138 # readlink doesn't exist on Windows.
139 pointed = os.readlink(symlink_path) # pylint: disable=E1101
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000140 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))
maruel@chromium.orgf43e68b2012-10-15 20:23:10 +0000255 # symlink doesn't exist on Windows.
256 os.symlink(pointed, outfile) # pylint: disable=E1101
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000257 else:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000258 run_isolated.link_file(outfile, infile, action)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000259
260
261def encode_multipart_formdata(fields, files,
262 mime_mapper=lambda _: 'application/octet-stream'):
263 """Encodes a Multipart form data object.
264
265 Args:
266 fields: a sequence (name, value) elements for
267 regular form fields.
268 files: a sequence of (name, filename, value) elements for data to be
269 uploaded as files.
270 mime_mapper: function to return the mime type from the filename.
271 Returns:
272 content_type: for httplib.HTTP instance
273 body: for httplib.HTTP instance
274 """
275 boundary = hashlib.md5(str(time.time())).hexdigest()
276 body_list = []
277 for (key, value) in fields:
maruel@chromium.org861a5e72012-10-09 14:49:42 +0000278 if isinstance(key, unicode):
279 value = key.encode('utf-8')
280 if isinstance(value, unicode):
281 value = value.encode('utf-8')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000282 body_list.append('--' + boundary)
283 body_list.append('Content-Disposition: form-data; name="%s"' % key)
284 body_list.append('')
285 body_list.append(value)
286 body_list.append('--' + boundary)
287 body_list.append('')
288 for (key, filename, value) in files:
maruel@chromium.org861a5e72012-10-09 14:49:42 +0000289 if isinstance(key, unicode):
290 value = key.encode('utf-8')
291 if isinstance(filename, unicode):
292 value = filename.encode('utf-8')
293 if isinstance(value, unicode):
294 value = value.encode('utf-8')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000295 body_list.append('--' + boundary)
296 body_list.append('Content-Disposition: form-data; name="%s"; '
297 'filename="%s"' % (key, filename))
298 body_list.append('Content-Type: %s' % mime_mapper(filename))
299 body_list.append('')
300 body_list.append(value)
301 body_list.append('--' + boundary)
302 body_list.append('')
303 if body_list:
304 body_list[-2] += '--'
305 body = '\r\n'.join(body_list)
306 content_type = 'multipart/form-data; boundary=%s' % boundary
307 return content_type, body
308
309
310def upload_hash_content(url, params=None, payload=None,
311 content_type='application/octet-stream'):
312 """Uploads the given hash contents.
313
314 Arguments:
315 url: The url to upload the hash contents to.
316 params: The params to include with the upload.
317 payload: The data to upload.
318 content_type: The content_type of the data being uploaded.
319 """
320 if params:
321 url = url + '?' + urllib.urlencode(params)
322 request = urllib2.Request(url, data=payload)
323 request.add_header('Content-Type', content_type)
324 request.add_header('Content-Length', len(payload or ''))
325
326 return urllib2.urlopen(request)
327
328
329def upload_hash_content_to_blobstore(generate_upload_url, params,
330 hash_data):
331 """Uploads the given hash contents directly to the blobsotre via a generated
332 url.
333
334 Arguments:
335 generate_upload_url: The url to get the new upload url from.
336 params: The params to include with the upload.
337 hash_contents: The contents to upload.
338 """
339 content_type, body = encode_multipart_formdata(
maruel@chromium.org861a5e72012-10-09 14:49:42 +0000340 params.items(), [('hash_contents', 'hash_content', hash_data)])
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000341
342 logging.debug('Generating url to directly upload file to blobstore')
343 response = urllib2.urlopen(generate_upload_url)
344 upload_url = response.read()
345
346 if not upload_url:
347 logging.error('Unable to generate upload url')
348 return
349
350 return upload_hash_content(upload_url, payload=body,
351 content_type=content_type)
352
353
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000354class UploadRemote(run_isolated.Remote):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000355 @staticmethod
356 def get_file_handler(base_url):
357 def upload_file(hash_data, hash_key):
358 params = {'hash_key': hash_key}
359 if len(hash_data) > MIN_SIZE_FOR_DIRECT_BLOBSTORE:
360 upload_hash_content_to_blobstore(
361 base_url.rstrip('/') + '/content/generate_blobstore_url',
362 params, hash_data)
363 else:
364 upload_hash_content(
365 base_url.rstrip('/') + '/content/store', params, hash_data)
366 return upload_file
367
368
369def url_open(url, data=None, max_retries=MAX_UPLOAD_ATTEMPTS):
370 """Opens the given url with the given data, repeating up to max_retries
371 times if it encounters an error.
372
373 Arguments:
374 url: The url to open.
375 data: The data to send to the url.
376 max_retries: The maximum number of times to try connecting to the url.
377
378 Returns:
379 The response from the url, or it raises an exception it it failed to get
380 a response.
381 """
maruel@chromium.orgd2434882012-10-11 14:08:46 +0000382 response = None
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000383 for _ in range(max_retries):
384 try:
385 response = urllib2.urlopen(url, data=data)
386 except urllib2.URLError as e:
387 logging.warning('Unable to connect to %s, error msg: %s', url, e)
388 time.sleep(1)
389
390 # If we get no response from the server after max_retries, assume it
391 # is down and raise an exception
392 if response is None:
maruel@chromium.orgd2434882012-10-11 14:08:46 +0000393 raise run_isolated.MappingError(
394 'Unable to connect to server, %s, to see which files are presents' %
395 url)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000396
397 return response
398
399
400def update_files_to_upload(query_url, queries, files_to_upload):
401 """Queries the server to see which files from this batch already exist there.
402
403 Arguments:
404 queries: The hash files to potential upload to the server.
405 files_to_upload: Any new files that need to be upload are added to
406 this list.
407 """
408 body = ''.join(
409 (binascii.unhexlify(meta_data['sha-1']) for (_, meta_data) in queries))
410 response = url_open(query_url, data=body).read()
411 if len(queries) != len(response):
maruel@chromium.orgb8375c22012-10-05 18:10:01 +0000412 raise run_isolated.MappingError(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000413 'Got an incorrect number of responses from the server. Expected %d, '
414 'but got %d' % (len(queries), len(response)))
415
416 for i in range(len(response)):
417 if response[i] == chr(0):
418 files_to_upload.append(queries[i])
419 else:
420 logging.debug('Hash for %s already exists on the server, no need '
421 'to upload again', queries[i][0])
422
423
424def upload_sha1_tree(base_url, indir, infiles):
425 """Uploads the given tree to the given url.
426
427 Arguments:
428 base_url: The base url, it is assume that |base_url|/has/ can be used to
429 query if an element was already uploaded, and |base_url|/store/
430 can be used to upload a new element.
431 indir: Root directory the infiles are based in.
432 infiles: dict of files to map from |indir| to |outdir|.
433 """
434 logging.info('upload tree(base_url=%s, indir=%s, files=%d)' %
435 (base_url, indir, len(infiles)))
436
437 # Generate the list of files that need to be uploaded (since some may already
438 # be on the server.
439 base_url = base_url.rstrip('/')
440 contains_hash_url = base_url + '/content/contains'
441 to_upload = []
442 next_queries = []
443 for relfile, metadata in infiles.iteritems():
444 if 'link' in metadata:
445 # Skip links when uploading.
446 continue
447
448 next_queries.append((relfile, metadata))
449 if len(next_queries) == 1000:
450 update_files_to_upload(contains_hash_url, next_queries, to_upload)
451 next_queries = []
452
453 if next_queries:
454 update_files_to_upload(contains_hash_url, next_queries, to_upload)
455
456
457 # Upload the required files.
458 remote_uploader = UploadRemote(base_url)
459 for relfile, metadata in to_upload:
460 # TODO(csharp): Fix crbug.com/150823 and enable the touched logic again.
461 # if metadata.get('touched_only') == True:
462 # hash_data = ''
463 infile = os.path.join(indir, relfile)
464 with open(infile, 'rb') as f:
465 hash_data = f.read()
csharp@chromium.orgd62bcb92012-10-16 17:45:33 +0000466 priority = (run_isolated.Remote.HIGH if metadata.get('priority', '1') == '0'
467 else run_isolated.Remote.MED)
468 remote_uploader.add_item(priority, hash_data, metadata['sha-1'])
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000469 remote_uploader.join()
470
471 exception = remote_uploader.next_exception()
472 if exception:
maruel@chromium.org861a5e72012-10-09 14:49:42 +0000473 raise exception[0], exception[1], exception[2]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000474
475
maruel@chromium.orgf2826a32012-10-16 18:26:17 +0000476def process_input(filepath, prevdict, read_only):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000477 """Processes an input file, a dependency, and return meta data about it.
478
479 Arguments:
480 - filepath: File to act on.
481 - prevdict: the previous dictionary. It is used to retrieve the cached sha-1
482 to skip recalculating the hash.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000483 - 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:
maruel@chromium.orgf2826a32012-10-16 18:26:17 +0000489 - Retrieves the file mode, file size, file timestamp, file link
490 destination if it is a file link and calcultate the SHA-1 of the file's
491 content if the path points to a file and not a symlink.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000492 """
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000493 out = {}
494 # TODO(csharp): Fix crbug.com/150823 and enable the touched logic again.
495 # if prevdict.get('touched_only') == True:
496 # # The file's content is ignored. Skip the time and hard code mode.
497 # if get_flavor() != 'win':
498 # out['mode'] = stat.S_IRUSR | stat.S_IRGRP
499 # out['size'] = 0
500 # out['sha-1'] = SHA_1_NULL
501 # out['touched_only'] = True
502 # return out
503
maruel@chromium.orgf2826a32012-10-16 18:26:17 +0000504 # Always check the file stat and check if it is a link. The timestamp is used
505 # to know if the file's content/symlink destination should be looked into.
506 # E.g. only reuse from prevdict if the timestamp hasn't changed.
507 # There is the risk of the file's timestamp being reset to its last value
508 # manually while its content changed. We don't protect against that use case.
509 try:
510 filestats = os.lstat(filepath)
511 except OSError:
512 # The file is not present.
513 raise run_isolated.MappingError('%s is missing' % filepath)
514 is_link = stat.S_ISLNK(filestats.st_mode)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000515
maruel@chromium.orgf2826a32012-10-16 18:26:17 +0000516 if get_flavor() != 'win':
517 # Ignore file mode on Windows since it's not really useful there.
518 filemode = stat.S_IMODE(filestats.st_mode)
519 # Remove write access for group and all access to 'others'.
520 filemode &= ~(stat.S_IWGRP | stat.S_IRWXO)
521 if read_only:
522 filemode &= ~stat.S_IWUSR
523 if filemode & stat.S_IXUSR:
524 filemode |= stat.S_IXGRP
525 else:
526 filemode &= ~stat.S_IXGRP
527 out['mode'] = filemode
528
529 # Used to skip recalculating the hash or link destination. Use the most recent
530 # update time.
531 # TODO(maruel): Save it in the .state file instead of .isolated so the
532 # .isolated file is deterministic.
533 out['timestamp'] = int(round(filestats.st_mtime))
534
535 if not is_link:
536 out['size'] = filestats.st_size
537 # If the timestamp wasn't updated and the file size is still the same, carry
538 # on the sha-1.
539 if (prevdict.get('timestamp') == out['timestamp'] and
540 prevdict.get('size') == out['size']):
541 # Reuse the previous hash if available.
542 out['sha-1'] = prevdict.get('sha-1')
543 if not out.get('sha-1'):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000544 with open(filepath, 'rb') as f:
545 out['sha-1'] = hashlib.sha1(f.read()).hexdigest()
maruel@chromium.orgf2826a32012-10-16 18:26:17 +0000546 else:
547 # If the timestamp wasn't updated, carry on the link destination.
548 if prevdict.get('timestamp') == out['timestamp']:
549 # Reuse the previous link destination if available.
550 out['link'] = prevdict.get('link')
551 if out.get('link') is None:
552 out['link'] = os.readlink(filepath) # pylint: disable=E1101
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000553 return out
554
555
556### Variable stuff.
557
558
maruel@chromium.org4b57f692012-10-05 20:33:09 +0000559def isolatedfile_to_state(filename):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000560 """Replaces the file's extension."""
maruel@chromium.org4d52ce42012-10-05 12:22:35 +0000561 return filename + '.state'
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000562
563
564def determine_root_dir(relative_root, infiles):
565 """For a list of infiles, determines the deepest root directory that is
566 referenced indirectly.
567
568 All arguments must be using os.path.sep.
569 """
570 # The trick used to determine the root directory is to look at "how far" back
571 # up it is looking up.
572 deepest_root = relative_root
573 for i in infiles:
574 x = relative_root
575 while i.startswith('..' + os.path.sep):
576 i = i[3:]
577 assert not i.startswith(os.path.sep)
578 x = os.path.dirname(x)
579 if deepest_root.startswith(x):
580 deepest_root = x
581 logging.debug(
582 'determine_root_dir(%s, %d files) -> %s' % (
583 relative_root, len(infiles), deepest_root))
584 return deepest_root
585
586
587def replace_variable(part, variables):
588 m = re.match(r'<\(([A-Z_]+)\)', part)
589 if m:
590 if m.group(1) not in variables:
591 raise ExecutionError(
592 'Variable "%s" was not found in %s.\nDid you forget to specify '
593 '--variable?' % (m.group(1), variables))
594 return variables[m.group(1)]
595 return part
596
597
598def process_variables(variables, relative_base_dir):
599 """Processes path variables as a special case and returns a copy of the dict.
600
601 For each 'path' variable: first normalizes it, verifies it exists, converts it
602 to an absolute path, then sets it as relative to relative_base_dir.
603 """
604 variables = variables.copy()
605 for i in PATH_VARIABLES:
606 if i not in variables:
607 continue
608 variable = os.path.normpath(variables[i])
609 if not os.path.isdir(variable):
610 raise ExecutionError('%s=%s is not a directory' % (i, variable))
611 # Variables could contain / or \ on windows. Always normalize to
612 # os.path.sep.
613 variable = os.path.abspath(variable.replace('/', os.path.sep))
614 # All variables are relative to the .isolate file.
615 variables[i] = os.path.relpath(variable, relative_base_dir)
616 return variables
617
618
619def eval_variables(item, variables):
620 """Replaces the .isolate variables in a string item.
621
622 Note that the .isolate format is a subset of the .gyp dialect.
623 """
624 return ''.join(
625 replace_variable(p, variables) for p in re.split(r'(<\([A-Z_]+\))', item))
626
627
628def classify_files(root_dir, tracked, untracked):
629 """Converts the list of files into a .isolate 'variables' dictionary.
630
631 Arguments:
632 - tracked: list of files names to generate a dictionary out of that should
633 probably be tracked.
634 - untracked: list of files names that must not be tracked.
635 """
636 # These directories are not guaranteed to be always present on every builder.
637 OPTIONAL_DIRECTORIES = (
638 'test/data/plugin',
639 'third_party/WebKit/LayoutTests',
640 )
641
642 new_tracked = []
643 new_untracked = list(untracked)
644
645 def should_be_tracked(filepath):
646 """Returns True if it is a file without whitespace in a non-optional
647 directory that has no symlink in its path.
648 """
649 if filepath.endswith('/'):
650 return False
651 if ' ' in filepath:
652 return False
653 if any(i in filepath for i in OPTIONAL_DIRECTORIES):
654 return False
655 # Look if any element in the path is a symlink.
656 split = filepath.split('/')
657 for i in range(len(split)):
658 if os.path.islink(os.path.join(root_dir, '/'.join(split[:i+1]))):
659 return False
660 return True
661
662 for filepath in sorted(tracked):
663 if should_be_tracked(filepath):
664 new_tracked.append(filepath)
665 else:
666 # Anything else.
667 new_untracked.append(filepath)
668
669 variables = {}
670 if new_tracked:
671 variables[KEY_TRACKED] = sorted(new_tracked)
672 if new_untracked:
673 variables[KEY_UNTRACKED] = sorted(new_untracked)
674 return variables
675
676
677def generate_simplified(
678 tracked, untracked, touched, root_dir, variables, relative_cwd):
679 """Generates a clean and complete .isolate 'variables' dictionary.
680
681 Cleans up and extracts only files from within root_dir then processes
682 variables and relative_cwd.
683 """
684 logging.info(
685 'generate_simplified(%d files, %s, %s, %s)' %
686 (len(tracked) + len(untracked) + len(touched),
687 root_dir, variables, relative_cwd))
688 # Constants.
689 # Skip log in PRODUCT_DIR. Note that these are applied on '/' style path
690 # separator.
691 LOG_FILE = re.compile(r'^\<\(PRODUCT_DIR\)\/[^\/]+\.log$')
692 EXECUTABLE = re.compile(
693 r'^(\<\(PRODUCT_DIR\)\/[^\/\.]+)' +
694 re.escape(variables.get('EXECUTABLE_SUFFIX', '')) +
695 r'$')
696
697 # Preparation work.
698 relative_cwd = cleanup_path(relative_cwd)
maruel@chromium.orgec91af12012-10-18 20:45:57 +0000699 assert not os.path.isabs(relative_cwd), relative_cwd
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000700 # Creates the right set of variables here. We only care about PATH_VARIABLES.
701 variables = dict(
702 ('<(%s)' % k, variables[k].replace(os.path.sep, '/'))
703 for k in PATH_VARIABLES if k in variables)
704
705 # Actual work: Process the files.
706 # TODO(maruel): if all the files in a directory are in part tracked and in
707 # part untracked, the directory will not be extracted. Tracked files should be
708 # 'promoted' to be untracked as needed.
709 tracked = trace_inputs.extract_directories(
710 root_dir, tracked, default_blacklist)
711 untracked = trace_inputs.extract_directories(
712 root_dir, untracked, default_blacklist)
713 # touched is not compressed, otherwise it would result in files to be archived
714 # that we don't need.
715
maruel@chromium.orgec91af12012-10-18 20:45:57 +0000716 root_dir_posix = root_dir.replace(os.path.sep, '/')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000717 def fix(f):
718 """Bases the file on the most restrictive variable."""
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000719 # Important, GYP stores the files with / and not \.
720 f = f.replace(os.path.sep, '/')
maruel@chromium.orgec91af12012-10-18 20:45:57 +0000721 logging.debug('fix(%s)' % f)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000722 # If it's not already a variable.
723 if not f.startswith('<'):
724 # relative_cwd is usually the directory containing the gyp file. It may be
725 # empty if the whole directory containing the gyp file is needed.
maruel@chromium.org8b056ba2012-10-16 14:04:49 +0000726 # Use absolute paths in case cwd_dir is outside of root_dir.
maruel@chromium.orgec91af12012-10-18 20:45:57 +0000727 # Convert the whole thing to / since it's isolate's speak.
maruel@chromium.org8b056ba2012-10-16 14:04:49 +0000728 f = posix_relpath(
maruel@chromium.orgec91af12012-10-18 20:45:57 +0000729 posixpath.join(root_dir_posix, f),
730 posixpath.join(root_dir_posix, relative_cwd)) or './'
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000731
732 for variable, root_path in variables.iteritems():
733 if f.startswith(root_path):
734 f = variable + f[len(root_path):]
maruel@chromium.org6b365dc2012-10-18 19:17:56 +0000735 logging.debug('Converted to %s' % f)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000736 break
737
738 # Now strips off known files we want to ignore and to any specific mangling
739 # as necessary. It's easier to do it here than generate a blacklist.
740 match = EXECUTABLE.match(f)
741 if match:
742 return match.group(1) + '<(EXECUTABLE_SUFFIX)'
743
744 # Blacklist logs and 'First Run' in the PRODUCT_DIR. First Run is not
745 # created by the compile, but by the test itself.
746 if LOG_FILE.match(f) or f == '<(PRODUCT_DIR)/First Run':
747 return None
748
749 if sys.platform == 'darwin':
750 # On OSX, the name of the output is dependent on gyp define, it can be
751 # 'Google Chrome.app' or 'Chromium.app', same for 'XXX
752 # Framework.framework'. Furthermore, they are versioned with a gyp
753 # variable. To lower the complexity of the .isolate file, remove all the
754 # individual entries that show up under any of the 4 entries and replace
755 # them with the directory itself. Overall, this results in a bit more
756 # files than strictly necessary.
757 OSX_BUNDLES = (
758 '<(PRODUCT_DIR)/Chromium Framework.framework/',
759 '<(PRODUCT_DIR)/Chromium.app/',
760 '<(PRODUCT_DIR)/Google Chrome Framework.framework/',
761 '<(PRODUCT_DIR)/Google Chrome.app/',
762 )
763 for prefix in OSX_BUNDLES:
764 if f.startswith(prefix):
765 # Note this result in duplicate values, so the a set() must be used to
766 # remove duplicates.
767 return prefix
768
769 return f
770
771 tracked = set(filter(None, (fix(f.path) for f in tracked)))
772 untracked = set(filter(None, (fix(f.path) for f in untracked)))
773 touched = set(filter(None, (fix(f.path) for f in touched)))
774 out = classify_files(root_dir, tracked, untracked)
775 if touched:
776 out[KEY_TOUCHED] = sorted(touched)
777 return out
778
779
780def generate_isolate(
781 tracked, untracked, touched, root_dir, variables, relative_cwd):
782 """Generates a clean and complete .isolate file."""
783 result = generate_simplified(
784 tracked, untracked, touched, root_dir, variables, relative_cwd)
785 return {
786 'conditions': [
787 ['OS=="%s"' % get_flavor(), {
788 'variables': result,
789 }],
790 ],
791 }
792
793
794def split_touched(files):
795 """Splits files that are touched vs files that are read."""
796 tracked = []
797 touched = []
798 for f in files:
799 if f.size:
800 tracked.append(f)
801 else:
802 touched.append(f)
803 return tracked, touched
804
805
806def pretty_print(variables, stdout):
807 """Outputs a gyp compatible list from the decoded variables.
808
809 Similar to pprint.print() but with NIH syndrome.
810 """
811 # Order the dictionary keys by these keys in priority.
812 ORDER = (
813 'variables', 'condition', 'command', 'relative_cwd', 'read_only',
814 KEY_TRACKED, KEY_UNTRACKED)
815
816 def sorting_key(x):
817 """Gives priority to 'most important' keys before the others."""
818 if x in ORDER:
819 return str(ORDER.index(x))
820 return x
821
822 def loop_list(indent, items):
823 for item in items:
824 if isinstance(item, basestring):
825 stdout.write('%s\'%s\',\n' % (indent, item))
826 elif isinstance(item, dict):
827 stdout.write('%s{\n' % indent)
828 loop_dict(indent + ' ', item)
829 stdout.write('%s},\n' % indent)
830 elif isinstance(item, list):
831 # A list inside a list will write the first item embedded.
832 stdout.write('%s[' % indent)
833 for index, i in enumerate(item):
834 if isinstance(i, basestring):
835 stdout.write(
836 '\'%s\', ' % i.replace('\\', '\\\\').replace('\'', '\\\''))
837 elif isinstance(i, dict):
838 stdout.write('{\n')
839 loop_dict(indent + ' ', i)
840 if index != len(item) - 1:
841 x = ', '
842 else:
843 x = ''
844 stdout.write('%s}%s' % (indent, x))
845 else:
846 assert False
847 stdout.write('],\n')
848 else:
849 assert False
850
851 def loop_dict(indent, items):
852 for key in sorted(items, key=sorting_key):
853 item = items[key]
854 stdout.write("%s'%s': " % (indent, key))
855 if isinstance(item, dict):
856 stdout.write('{\n')
857 loop_dict(indent + ' ', item)
858 stdout.write(indent + '},\n')
859 elif isinstance(item, list):
860 stdout.write('[\n')
861 loop_list(indent + ' ', item)
862 stdout.write(indent + '],\n')
863 elif isinstance(item, basestring):
864 stdout.write(
865 '\'%s\',\n' % item.replace('\\', '\\\\').replace('\'', '\\\''))
866 elif item in (True, False, None):
867 stdout.write('%s\n' % item)
868 else:
869 assert False, item
870
871 stdout.write('{\n')
872 loop_dict(' ', variables)
873 stdout.write('}\n')
874
875
876def union(lhs, rhs):
877 """Merges two compatible datastructures composed of dict/list/set."""
878 assert lhs is not None or rhs is not None
879 if lhs is None:
880 return copy.deepcopy(rhs)
881 if rhs is None:
882 return copy.deepcopy(lhs)
883 assert type(lhs) == type(rhs), (lhs, rhs)
884 if hasattr(lhs, 'union'):
885 # Includes set, OSSettings and Configs.
886 return lhs.union(rhs)
887 if isinstance(lhs, dict):
888 return dict((k, union(lhs.get(k), rhs.get(k))) for k in set(lhs).union(rhs))
889 elif isinstance(lhs, list):
890 # Do not go inside the list.
891 return lhs + rhs
892 assert False, type(lhs)
893
894
895def extract_comment(content):
896 """Extracts file level comment."""
897 out = []
898 for line in content.splitlines(True):
899 if line.startswith('#'):
900 out.append(line)
901 else:
902 break
903 return ''.join(out)
904
905
906def eval_content(content):
907 """Evaluates a python file and return the value defined in it.
908
909 Used in practice for .isolate files.
910 """
911 globs = {'__builtins__': None}
912 locs = {}
913 value = eval(content, globs, locs)
914 assert locs == {}, locs
915 assert globs == {'__builtins__': None}, globs
916 return value
917
918
919def verify_variables(variables):
920 """Verifies the |variables| dictionary is in the expected format."""
921 VALID_VARIABLES = [
922 KEY_TOUCHED,
923 KEY_TRACKED,
924 KEY_UNTRACKED,
925 'command',
926 'read_only',
927 ]
928 assert isinstance(variables, dict), variables
929 assert set(VALID_VARIABLES).issuperset(set(variables)), variables.keys()
930 for name, value in variables.iteritems():
931 if name == 'read_only':
932 assert value in (True, False, None), value
933 else:
934 assert isinstance(value, list), value
935 assert all(isinstance(i, basestring) for i in value), value
936
937
938def verify_condition(condition):
939 """Verifies the |condition| dictionary is in the expected format."""
940 VALID_INSIDE_CONDITION = ['variables']
941 assert isinstance(condition, list), condition
942 assert 2 <= len(condition) <= 3, condition
943 assert re.match(r'OS==\"([a-z]+)\"', condition[0]), condition[0]
944 for c in condition[1:]:
945 assert isinstance(c, dict), c
946 assert set(VALID_INSIDE_CONDITION).issuperset(set(c)), c.keys()
947 verify_variables(c.get('variables', {}))
948
949
950def verify_root(value):
951 VALID_ROOTS = ['variables', 'conditions']
952 assert isinstance(value, dict), value
953 assert set(VALID_ROOTS).issuperset(set(value)), value.keys()
954 verify_variables(value.get('variables', {}))
955
956 conditions = value.get('conditions', [])
957 assert isinstance(conditions, list), conditions
958 for condition in conditions:
959 verify_condition(condition)
960
961
962def remove_weak_dependencies(values, key, item, item_oses):
963 """Remove any oses from this key if the item is already under a strong key."""
964 if key == KEY_TOUCHED:
965 for stronger_key in (KEY_TRACKED, KEY_UNTRACKED):
966 oses = values.get(stronger_key, {}).get(item, None)
967 if oses:
968 item_oses -= oses
969
970 return item_oses
971
972
973def invert_map(variables):
974 """Converts a dict(OS, dict(deptype, list(dependencies)) to a flattened view.
975
976 Returns a tuple of:
977 1. dict(deptype, dict(dependency, set(OSes)) for easier processing.
978 2. All the OSes found as a set.
979 """
980 KEYS = (
981 KEY_TOUCHED,
982 KEY_TRACKED,
983 KEY_UNTRACKED,
984 'command',
985 'read_only',
986 )
987 out = dict((key, {}) for key in KEYS)
988 for os_name, values in variables.iteritems():
989 for key in (KEY_TOUCHED, KEY_TRACKED, KEY_UNTRACKED):
990 for item in values.get(key, []):
991 out[key].setdefault(item, set()).add(os_name)
992
993 # command needs special handling.
994 command = tuple(values.get('command', []))
995 out['command'].setdefault(command, set()).add(os_name)
996
997 # read_only needs special handling.
998 out['read_only'].setdefault(values.get('read_only'), set()).add(os_name)
999 return out, set(variables)
1000
1001
1002def reduce_inputs(values, oses):
1003 """Reduces the invert_map() output to the strictest minimum list.
1004
1005 1. Construct the inverse map first.
1006 2. Look at each individual file and directory, map where they are used and
1007 reconstruct the inverse dictionary.
1008 3. Do not convert back to negative if only 2 OSes were merged.
1009
1010 Returns a tuple of:
1011 1. the minimized dictionary
1012 2. oses passed through as-is.
1013 """
1014 KEYS = (
1015 KEY_TOUCHED,
1016 KEY_TRACKED,
1017 KEY_UNTRACKED,
1018 'command',
1019 'read_only',
1020 )
1021 out = dict((key, {}) for key in KEYS)
1022 assert all(oses), oses
1023 if len(oses) > 2:
1024 for key in KEYS:
1025 for item, item_oses in values.get(key, {}).iteritems():
1026 item_oses = remove_weak_dependencies(values, key, item, item_oses)
1027 if not item_oses:
1028 continue
1029
1030 # Converts all oses.difference('foo') to '!foo'.
1031 assert all(item_oses), item_oses
1032 missing = oses.difference(item_oses)
1033 if len(missing) == 1:
1034 # Replace it with a negative.
1035 out[key][item] = set(['!' + tuple(missing)[0]])
1036 elif not missing:
1037 out[key][item] = set([None])
1038 else:
1039 out[key][item] = set(item_oses)
1040 else:
1041 for key in KEYS:
1042 for item, item_oses in values.get(key, {}).iteritems():
1043 item_oses = remove_weak_dependencies(values, key, item, item_oses)
1044 if not item_oses:
1045 continue
1046
1047 # Converts all oses.difference('foo') to '!foo'.
1048 assert None not in item_oses, item_oses
1049 out[key][item] = set(item_oses)
1050 return out, oses
1051
1052
1053def convert_map_to_isolate_dict(values, oses):
1054 """Regenerates back a .isolate configuration dict from files and dirs
1055 mappings generated from reduce_inputs().
1056 """
1057 # First, inverse the mapping to make it dict first.
1058 config = {}
1059 for key in values:
1060 for item, oses in values[key].iteritems():
1061 if item is None:
1062 # For read_only default.
1063 continue
1064 for cond_os in oses:
1065 cond_key = None if cond_os is None else cond_os.lstrip('!')
1066 # Insert the if/else dicts.
1067 condition_values = config.setdefault(cond_key, [{}, {}])
1068 # If condition is negative, use index 1, else use index 0.
1069 cond_value = condition_values[int((cond_os or '').startswith('!'))]
1070 variables = cond_value.setdefault('variables', {})
1071
1072 if item in (True, False):
1073 # One-off for read_only.
1074 variables[key] = item
1075 else:
1076 if isinstance(item, tuple) and item:
1077 # One-off for command.
1078 # Do not merge lists and do not sort!
1079 # Note that item is a tuple.
1080 assert key not in variables
1081 variables[key] = list(item)
1082 elif item:
1083 # The list of items (files or dirs). Append the new item and keep
1084 # the list sorted.
1085 l = variables.setdefault(key, [])
1086 l.append(item)
1087 l.sort()
1088
1089 out = {}
1090 for o in sorted(config):
1091 d = config[o]
1092 if o is None:
1093 assert not d[1]
1094 out = union(out, d[0])
1095 else:
1096 c = out.setdefault('conditions', [])
1097 if d[1]:
1098 c.append(['OS=="%s"' % o] + d)
1099 else:
1100 c.append(['OS=="%s"' % o] + d[0:1])
1101 return out
1102
1103
1104### Internal state files.
1105
1106
1107class OSSettings(object):
1108 """Represents the dependencies for an OS. The structure is immutable.
1109
1110 It's the .isolate settings for a specific file.
1111 """
1112 def __init__(self, name, values):
1113 self.name = name
1114 verify_variables(values)
1115 self.touched = sorted(values.get(KEY_TOUCHED, []))
1116 self.tracked = sorted(values.get(KEY_TRACKED, []))
1117 self.untracked = sorted(values.get(KEY_UNTRACKED, []))
1118 self.command = values.get('command', [])[:]
1119 self.read_only = values.get('read_only')
1120
1121 def union(self, rhs):
1122 assert self.name == rhs.name
1123 assert not (self.command and rhs.command)
1124 var = {
1125 KEY_TOUCHED: sorted(self.touched + rhs.touched),
1126 KEY_TRACKED: sorted(self.tracked + rhs.tracked),
1127 KEY_UNTRACKED: sorted(self.untracked + rhs.untracked),
1128 'command': self.command or rhs.command,
1129 'read_only': rhs.read_only if self.read_only is None else self.read_only,
1130 }
1131 return OSSettings(self.name, var)
1132
1133 def flatten(self):
1134 out = {}
1135 if self.command:
1136 out['command'] = self.command
1137 if self.touched:
1138 out[KEY_TOUCHED] = self.touched
1139 if self.tracked:
1140 out[KEY_TRACKED] = self.tracked
1141 if self.untracked:
1142 out[KEY_UNTRACKED] = self.untracked
1143 if self.read_only is not None:
1144 out['read_only'] = self.read_only
1145 return out
1146
1147
1148class Configs(object):
1149 """Represents a processed .isolate file.
1150
1151 Stores the file in a processed way, split by each the OS-specific
1152 configurations.
1153
1154 The self.per_os[None] member contains all the 'else' clauses plus the default
1155 values. It is not included in the flatten() result.
1156 """
1157 def __init__(self, oses, file_comment):
1158 self.file_comment = file_comment
1159 self.per_os = {
1160 None: OSSettings(None, {}),
1161 }
1162 self.per_os.update(dict((name, OSSettings(name, {})) for name in oses))
1163
1164 def union(self, rhs):
1165 items = list(set(self.per_os.keys() + rhs.per_os.keys()))
1166 # Takes the first file comment, prefering lhs.
1167 out = Configs(items, self.file_comment or rhs.file_comment)
1168 for key in items:
1169 out.per_os[key] = union(self.per_os.get(key), rhs.per_os.get(key))
1170 return out
1171
1172 def add_globals(self, values):
1173 for key in self.per_os:
1174 self.per_os[key] = self.per_os[key].union(OSSettings(key, values))
1175
1176 def add_values(self, for_os, values):
1177 self.per_os[for_os] = self.per_os[for_os].union(OSSettings(for_os, values))
1178
1179 def add_negative_values(self, for_os, values):
1180 """Includes the variables to all OSes except |for_os|.
1181
1182 This includes 'None' so unknown OSes gets it too.
1183 """
1184 for key in self.per_os:
1185 if key != for_os:
1186 self.per_os[key] = self.per_os[key].union(OSSettings(key, values))
1187
1188 def flatten(self):
1189 """Returns a flat dictionary representation of the configuration.
1190
1191 Skips None pseudo-OS.
1192 """
1193 return dict(
1194 (k, v.flatten()) for k, v in self.per_os.iteritems() if k is not None)
1195
1196
1197def load_isolate_as_config(value, file_comment, default_oses):
1198 """Parses one .isolate file and returns a Configs() instance.
1199
1200 |value| is the loaded dictionary that was defined in the gyp file.
1201
1202 The expected format is strict, anything diverting from the format below will
1203 throw an assert:
1204 {
1205 'variables': {
1206 'command': [
1207 ...
1208 ],
1209 'isolate_dependency_tracked': [
1210 ...
1211 ],
1212 'isolate_dependency_untracked': [
1213 ...
1214 ],
1215 'read_only': False,
1216 },
1217 'conditions': [
1218 ['OS=="<os>"', {
1219 'variables': {
1220 ...
1221 },
1222 }, { # else
1223 'variables': {
1224 ...
1225 },
1226 }],
1227 ...
1228 ],
1229 }
1230 """
1231 verify_root(value)
1232
1233 # Scan to get the list of OSes.
1234 conditions = value.get('conditions', [])
1235 oses = set(re.match(r'OS==\"([a-z]+)\"', c[0]).group(1) for c in conditions)
1236 oses = oses.union(default_oses)
1237 configs = Configs(oses, file_comment)
1238
1239 # Global level variables.
1240 configs.add_globals(value.get('variables', {}))
1241
1242 # OS specific variables.
1243 for condition in conditions:
1244 condition_os = re.match(r'OS==\"([a-z]+)\"', condition[0]).group(1)
1245 configs.add_values(condition_os, condition[1].get('variables', {}))
1246 if len(condition) > 2:
1247 configs.add_negative_values(
1248 condition_os, condition[2].get('variables', {}))
1249 return configs
1250
1251
1252def load_isolate_for_flavor(content, flavor):
1253 """Loads the .isolate file and returns the information unprocessed.
1254
1255 Returns the command, dependencies and read_only flag. The dependencies are
1256 fixed to use os.path.sep.
1257 """
1258 # Load the .isolate file, process its conditions, retrieve the command and
1259 # dependencies.
1260 configs = load_isolate_as_config(eval_content(content), None, DEFAULT_OSES)
1261 config = configs.per_os.get(flavor) or configs.per_os.get(None)
1262 if not config:
1263 raise ExecutionError('Failed to load configuration for \'%s\'' % flavor)
1264 # Merge tracked and untracked dependencies, isolate.py doesn't care about the
1265 # trackability of the dependencies, only the build tool does.
1266 dependencies = [
1267 f.replace('/', os.path.sep) for f in config.tracked + config.untracked
1268 ]
1269 touched = [f.replace('/', os.path.sep) for f in config.touched]
1270 return config.command, dependencies, touched, config.read_only
1271
1272
1273class Flattenable(object):
1274 """Represents data that can be represented as a json file."""
1275 MEMBERS = ()
1276
1277 def flatten(self):
1278 """Returns a json-serializable version of itself.
1279
1280 Skips None entries.
1281 """
1282 items = ((member, getattr(self, member)) for member in self.MEMBERS)
1283 return dict((member, value) for member, value in items if value is not None)
1284
1285 @classmethod
1286 def load(cls, data):
1287 """Loads a flattened version."""
1288 data = data.copy()
1289 out = cls()
1290 for member in out.MEMBERS:
1291 if member in data:
1292 # Access to a protected member XXX of a client class
1293 # pylint: disable=W0212
1294 out._load_member(member, data.pop(member))
1295 if data:
1296 raise ValueError(
1297 'Found unexpected entry %s while constructing an object %s' %
1298 (data, cls.__name__), data, cls.__name__)
1299 return out
1300
1301 def _load_member(self, member, value):
1302 """Loads a member into self."""
1303 setattr(self, member, value)
1304
1305 @classmethod
1306 def load_file(cls, filename):
1307 """Loads the data from a file or return an empty instance."""
1308 out = cls()
1309 try:
1310 out = cls.load(trace_inputs.read_json(filename))
1311 logging.debug('Loaded %s(%s)' % (cls.__name__, filename))
1312 except (IOError, ValueError):
1313 logging.warn('Failed to load %s' % filename)
1314 return out
1315
1316
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001317class IsolatedFile(Flattenable):
1318 """Describes the content of a .isolated file.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001319
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001320 This file is used by run_isolated.py so its content is strictly only
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001321 what is necessary to run the test outside of a checkout.
1322
1323 It is important to note that the 'files' dict keys are using native OS path
1324 separator instead of '/' used in .isolate file.
1325 """
1326 MEMBERS = (
1327 'command',
1328 'files',
1329 'os',
1330 'read_only',
1331 'relative_cwd',
1332 )
1333
1334 os = get_flavor()
1335
1336 def __init__(self):
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001337 super(IsolatedFile, self).__init__()
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001338 self.command = []
1339 self.files = {}
1340 self.read_only = None
1341 self.relative_cwd = None
1342
1343 def update(self, command, infiles, touched, read_only, relative_cwd):
1344 """Updates the result state with new information."""
1345 self.command = command
1346 # Add new files.
1347 for f in infiles:
1348 self.files.setdefault(f, {})
1349 for f in touched:
1350 self.files.setdefault(f, {})['touched_only'] = True
1351 # Prune extraneous files that are not a dependency anymore.
1352 for f in set(self.files).difference(set(infiles).union(touched)):
1353 del self.files[f]
1354 if read_only is not None:
1355 self.read_only = read_only
1356 self.relative_cwd = relative_cwd
1357
1358 def _load_member(self, member, value):
1359 if member == 'os':
1360 if value != self.os:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001361 raise run_isolated.ConfigError(
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001362 'The .isolated file was created on another platform')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001363 else:
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001364 super(IsolatedFile, self)._load_member(member, value)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001365
1366 def __str__(self):
1367 out = '%s(\n' % self.__class__.__name__
1368 out += ' command: %s\n' % self.command
1369 out += ' files: %d\n' % len(self.files)
1370 out += ' read_only: %s\n' % self.read_only
1371 out += ' relative_cwd: %s)' % self.relative_cwd
1372 return out
1373
1374
1375class SavedState(Flattenable):
1376 """Describes the content of a .state file.
1377
1378 The items in this file are simply to improve the developer's life and aren't
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001379 used by run_isolated.py. This file can always be safely removed.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001380
1381 isolate_file permits to find back root_dir, variables are used for stateful
1382 rerun.
1383 """
1384 MEMBERS = (
1385 'isolate_file',
1386 'variables',
1387 )
1388
1389 def __init__(self):
1390 super(SavedState, self).__init__()
1391 self.isolate_file = None
1392 self.variables = {}
1393
1394 def update(self, isolate_file, variables):
1395 """Updates the saved state with new information."""
1396 self.isolate_file = isolate_file
1397 self.variables.update(variables)
1398
1399 @classmethod
1400 def load(cls, data):
1401 out = super(SavedState, cls).load(data)
1402 if out.isolate_file:
1403 out.isolate_file = trace_inputs.get_native_path_case(out.isolate_file)
1404 return out
1405
1406 def __str__(self):
1407 out = '%s(\n' % self.__class__.__name__
1408 out += ' isolate_file: %s\n' % self.isolate_file
1409 out += ' variables: %s' % ''.join(
1410 '\n %s=%s' % (k, self.variables[k]) for k in sorted(self.variables))
1411 out += ')'
1412 return out
1413
1414
1415class CompleteState(object):
1416 """Contains all the state to run the task at hand."""
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001417 def __init__(self, isolated_filepath, isolated, saved_state):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001418 super(CompleteState, self).__init__()
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001419 self.isolated_filepath = isolated_filepath
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001420 # Contains the data that will be used by run_isolated.py
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001421 self.isolated = isolated
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001422 # Contains the data to ease developer's use-case but that is not strictly
1423 # necessary.
1424 self.saved_state = saved_state
1425
1426 @classmethod
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001427 def load_files(cls, isolated_filepath):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001428 """Loads state from disk."""
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001429 assert os.path.isabs(isolated_filepath), isolated_filepath
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001430 return cls(
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001431 isolated_filepath,
1432 IsolatedFile.load_file(isolated_filepath),
1433 SavedState.load_file(isolatedfile_to_state(isolated_filepath)))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001434
1435 def load_isolate(self, isolate_file, variables):
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001436 """Updates self.isolated and self.saved_state with information loaded from a
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001437 .isolate file.
1438
1439 Processes the loaded data, deduce root_dir, relative_cwd.
1440 """
1441 # Make sure to not depend on os.getcwd().
1442 assert os.path.isabs(isolate_file), isolate_file
1443 logging.info(
1444 'CompleteState.load_isolate(%s, %s)' % (isolate_file, variables))
1445 relative_base_dir = os.path.dirname(isolate_file)
1446
1447 # Processes the variables and update the saved state.
1448 variables = process_variables(variables, relative_base_dir)
1449 self.saved_state.update(isolate_file, variables)
1450
1451 with open(isolate_file, 'r') as f:
1452 # At that point, variables are not replaced yet in command and infiles.
1453 # infiles may contain directory entries and is in posix style.
1454 command, infiles, touched, read_only = load_isolate_for_flavor(
1455 f.read(), get_flavor())
1456 command = [eval_variables(i, self.saved_state.variables) for i in command]
1457 infiles = [eval_variables(f, self.saved_state.variables) for f in infiles]
1458 touched = [eval_variables(f, self.saved_state.variables) for f in touched]
1459 # root_dir is automatically determined by the deepest root accessed with the
1460 # form '../../foo/bar'.
1461 root_dir = determine_root_dir(relative_base_dir, infiles + touched)
1462 # The relative directory is automatically determined by the relative path
1463 # between root_dir and the directory containing the .isolate file,
1464 # isolate_base_dir.
1465 relative_cwd = os.path.relpath(relative_base_dir, root_dir)
1466 # Normalize the files based to root_dir. It is important to keep the
1467 # trailing os.path.sep at that step.
1468 infiles = [
1469 relpath(normpath(os.path.join(relative_base_dir, f)), root_dir)
1470 for f in infiles
1471 ]
1472 touched = [
1473 relpath(normpath(os.path.join(relative_base_dir, f)), root_dir)
1474 for f in touched
1475 ]
1476 # Expand the directories by listing each file inside. Up to now, trailing
1477 # os.path.sep must be kept. Do not expand 'touched'.
1478 infiles = expand_directories_and_symlinks(
1479 root_dir,
1480 infiles,
1481 lambda x: re.match(r'.*\.(git|svn|pyc)$', x))
1482
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001483 # Finally, update the new stuff in the foo.isolated file, the file that is
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001484 # used by run_isolated.py.
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001485 self.isolated.update(command, infiles, touched, read_only, relative_cwd)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001486 logging.debug(self)
1487
maruel@chromium.org9268f042012-10-17 17:36:41 +00001488 def process_inputs(self, subdir):
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001489 """Updates self.isolated.files with the files' mode and hash.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001490
maruel@chromium.org9268f042012-10-17 17:36:41 +00001491 If |subdir| is specified, filters to a subdirectory. The resulting .isolated
1492 file is tainted.
1493
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001494 See process_input() for more information.
1495 """
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001496 for infile in sorted(self.isolated.files):
maruel@chromium.org9268f042012-10-17 17:36:41 +00001497 if subdir and not infile.startswith(subdir):
1498 self.isolated.files.pop(infile)
1499 else:
1500 filepath = os.path.join(self.root_dir, infile)
1501 self.isolated.files[infile] = process_input(
1502 filepath, self.isolated.files[infile], self.isolated.read_only)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001503
1504 def save_files(self):
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001505 """Saves both self.isolated and self.saved_state."""
1506 logging.debug('Dumping to %s' % self.isolated_filepath)
1507 trace_inputs.write_json(
1508 self.isolated_filepath, self.isolated.flatten(), True)
1509 total_bytes = sum(i
1510 .get('size', 0) for i in self.isolated.files.itervalues())
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001511 if total_bytes:
1512 logging.debug('Total size: %d bytes' % total_bytes)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001513 saved_state_file = isolatedfile_to_state(self.isolated_filepath)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001514 logging.debug('Dumping to %s' % saved_state_file)
1515 trace_inputs.write_json(saved_state_file, self.saved_state.flatten(), True)
1516
1517 @property
1518 def root_dir(self):
1519 """isolate_file is always inside relative_cwd relative to root_dir."""
maruel@chromium.orgb253fb82012-10-16 21:44:48 +00001520 if not self.saved_state.isolate_file:
1521 raise ExecutionError('Please specify --isolate')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001522 isolate_dir = os.path.dirname(self.saved_state.isolate_file)
1523 # Special case '.'.
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001524 if self.isolated.relative_cwd == '.':
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001525 return isolate_dir
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001526 assert isolate_dir.endswith(self.isolated.relative_cwd), (
1527 isolate_dir, self.isolated.relative_cwd)
1528 return isolate_dir[:-(len(self.isolated.relative_cwd) + 1)]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001529
1530 @property
1531 def resultdir(self):
1532 """Directory containing the results, usually equivalent to the variable
1533 PRODUCT_DIR.
1534 """
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001535 return os.path.dirname(self.isolated_filepath)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001536
1537 def __str__(self):
1538 def indent(data, indent_length):
1539 """Indents text."""
1540 spacing = ' ' * indent_length
1541 return ''.join(spacing + l for l in str(data).splitlines(True))
1542
1543 out = '%s(\n' % self.__class__.__name__
1544 out += ' root_dir: %s\n' % self.root_dir
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001545 out += ' result: %s\n' % indent(self.isolated, 2)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001546 out += ' saved_state: %s)' % indent(self.saved_state, 2)
1547 return out
1548
1549
maruel@chromium.org9268f042012-10-17 17:36:41 +00001550def load_complete_state(options, subdir):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001551 """Loads a CompleteState.
1552
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001553 This includes data from .isolate, .isolated and .state files.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001554
1555 Arguments:
1556 options: Options instance generated with OptionParserIsolate.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001557 """
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001558 if options.isolated:
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001559 # Load the previous state if it was present. Namely, "foo.isolated" and
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001560 # "foo.state".
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001561 complete_state = CompleteState.load_files(options.isolated)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001562 else:
1563 # Constructs a dummy object that cannot be saved. Useful for temporary
1564 # commands like 'run'.
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001565 complete_state = CompleteState(None, IsolatedFile(), SavedState())
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001566 options.isolate = options.isolate or complete_state.saved_state.isolate_file
1567 if not options.isolate:
1568 raise ExecutionError('A .isolate file is required.')
1569 if (complete_state.saved_state.isolate_file and
1570 options.isolate != complete_state.saved_state.isolate_file):
1571 raise ExecutionError(
1572 '%s and %s do not match.' % (
1573 options.isolate, complete_state.saved_state.isolate_file))
1574
1575 # Then load the .isolate and expands directories.
1576 complete_state.load_isolate(options.isolate, options.variables)
1577
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001578 # Regenerate complete_state.isolated.files.
maruel@chromium.org9268f042012-10-17 17:36:41 +00001579 if subdir:
1580 subdir = eval_variables(subdir, complete_state.saved_state.variables)
1581 subdir = subdir.replace('/', os.path.sep)
1582 complete_state.process_inputs(subdir)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001583 return complete_state
1584
1585
1586def read_trace_as_isolate_dict(complete_state):
maruel@chromium.orge27c65f2012-10-22 13:56:53 +00001587 """Reads a trace and returns the .isolate dictionary.
1588
1589 Returns exceptions during the log parsing so it can be re-raised.
1590 """
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001591 api = trace_inputs.get_api()
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001592 logfile = complete_state.isolated_filepath + '.log'
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001593 if not os.path.isfile(logfile):
1594 raise ExecutionError(
1595 'No log file \'%s\' to read, did you forget to \'trace\'?' % logfile)
1596 try:
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00001597 data = api.parse_log(logfile, default_blacklist, None)
maruel@chromium.orge27c65f2012-10-22 13:56:53 +00001598 exceptions = [i['exception'] for i in data if 'exception' in i]
1599 results = (i['results'] for i in data if 'results' in i)
1600 results_stripped = (i.strip_root(complete_state.root_dir) for i in results)
1601 files = set(sum((result.existent for result in results_stripped), []))
1602 tracked, touched = split_touched(files)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001603 value = generate_isolate(
1604 tracked,
1605 [],
1606 touched,
1607 complete_state.root_dir,
1608 complete_state.saved_state.variables,
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001609 complete_state.isolated.relative_cwd)
maruel@chromium.orge27c65f2012-10-22 13:56:53 +00001610 return value, exceptions
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001611 except trace_inputs.TracingFailure, e:
1612 raise ExecutionError(
1613 'Reading traces failed for: %s\n%s' %
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001614 (' '.join(complete_state.isolated.command), str(e)))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001615
1616
1617def print_all(comment, data, stream):
1618 """Prints a complete .isolate file and its top-level file comment into a
1619 stream.
1620 """
1621 if comment:
1622 stream.write(comment)
1623 pretty_print(data, stream)
1624
1625
1626def merge(complete_state):
1627 """Reads a trace and merges it back into the source .isolate file."""
maruel@chromium.orge27c65f2012-10-22 13:56:53 +00001628 value, exceptions = read_trace_as_isolate_dict(complete_state)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001629
1630 # Now take that data and union it into the original .isolate file.
1631 with open(complete_state.saved_state.isolate_file, 'r') as f:
1632 prev_content = f.read()
1633 prev_config = load_isolate_as_config(
1634 eval_content(prev_content),
1635 extract_comment(prev_content),
1636 DEFAULT_OSES)
1637 new_config = load_isolate_as_config(value, '', DEFAULT_OSES)
1638 config = union(prev_config, new_config)
1639 # pylint: disable=E1103
1640 data = convert_map_to_isolate_dict(
1641 *reduce_inputs(*invert_map(config.flatten())))
maruel@chromium.orgec91af12012-10-18 20:45:57 +00001642 print('Updating %s' % complete_state.saved_state.isolate_file)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001643 with open(complete_state.saved_state.isolate_file, 'wb') as f:
1644 print_all(config.file_comment, data, f)
maruel@chromium.orge27c65f2012-10-22 13:56:53 +00001645 if exceptions:
1646 # It got an exception, raise the first one.
1647 raise \
1648 exceptions[0][0], \
1649 exceptions[0][1], \
1650 exceptions[0][2]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001651
1652
1653def CMDcheck(args):
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001654 """Checks that all the inputs are present and update .isolated."""
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001655 parser = OptionParserIsolate(command='check')
maruel@chromium.org9268f042012-10-17 17:36:41 +00001656 parser.add_option('--subdir', help='Filters to a subdirectory')
1657 options, args = parser.parse_args(args)
1658 if args:
1659 parser.error('Unsupported argument: %s' % args)
1660 complete_state = load_complete_state(options, options.subdir)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001661
1662 # Nothing is done specifically. Just store the result and state.
1663 complete_state.save_files()
1664 return 0
1665
1666
1667def CMDhashtable(args):
1668 """Creates a hash table content addressed object store.
1669
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001670 All the files listed in the .isolated file are put in the output directory
1671 with the file name being the sha-1 of the file's content.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001672 """
1673 parser = OptionParserIsolate(command='hashtable')
maruel@chromium.org9268f042012-10-17 17:36:41 +00001674 parser.add_option('--subdir', help='Filters to a subdirectory')
1675 options, args = parser.parse_args(args)
1676 if args:
1677 parser.error('Unsupported argument: %s' % args)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001678
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001679 with run_isolated.Profiler('GenerateHashtable'):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001680 success = False
1681 try:
maruel@chromium.org9268f042012-10-17 17:36:41 +00001682 complete_state = load_complete_state(options, options.subdir)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001683 options.outdir = (
1684 options.outdir or os.path.join(complete_state.resultdir, 'hashtable'))
1685 # Make sure that complete_state isn't modified until save_files() is
1686 # called, because any changes made to it here will propagate to the files
1687 # created (which is probably not intended).
1688 complete_state.save_files()
1689
1690 logging.info('Creating content addressed object store with %d item',
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001691 len(complete_state.isolated.files))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001692
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001693 with open(complete_state.isolated_filepath, 'rb') as f:
maruel@chromium.org861a5e72012-10-09 14:49:42 +00001694 content = f.read()
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001695 isolated_hash = hashlib.sha1(content).hexdigest()
1696 isolated_metadata = {
1697 'sha-1': isolated_hash,
csharp@chromium.orgd62bcb92012-10-16 17:45:33 +00001698 'size': len(content),
1699 'priority': '0'
1700 }
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001701
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001702 infiles = complete_state.isolated.files
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001703 infiles[complete_state.isolated_filepath] = isolated_metadata
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001704
1705 if re.match(r'^https?://.+$', options.outdir):
1706 upload_sha1_tree(
1707 base_url=options.outdir,
1708 indir=complete_state.root_dir,
1709 infiles=infiles)
1710 else:
1711 recreate_tree(
1712 outdir=options.outdir,
1713 indir=complete_state.root_dir,
1714 infiles=infiles,
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001715 action=run_isolated.HARDLINK,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001716 as_sha1=True)
1717 success = True
1718 finally:
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001719 # If the command failed, delete the .isolated file if it exists. This is
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001720 # important so no stale swarm job is executed.
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001721 if not success and os.path.isfile(options.isolated):
1722 os.remove(options.isolated)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001723
1724
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001725def CMDmerge(args):
1726 """Reads and merges the data from the trace back into the original .isolate.
1727
1728 Ignores --outdir.
1729 """
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001730 parser = OptionParserIsolate(command='merge', require_isolated=False)
maruel@chromium.org9268f042012-10-17 17:36:41 +00001731 options, args = parser.parse_args(args)
1732 if args:
1733 parser.error('Unsupported argument: %s' % args)
1734 complete_state = load_complete_state(options, None)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001735 merge(complete_state)
1736 return 0
1737
1738
1739def CMDread(args):
1740 """Reads the trace file generated with command 'trace'.
1741
1742 Ignores --outdir.
1743 """
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001744 parser = OptionParserIsolate(command='read', require_isolated=False)
maruel@chromium.org9268f042012-10-17 17:36:41 +00001745 options, args = parser.parse_args(args)
1746 if args:
1747 parser.error('Unsupported argument: %s' % args)
1748 complete_state = load_complete_state(options, None)
maruel@chromium.orge27c65f2012-10-22 13:56:53 +00001749 value, exceptions = read_trace_as_isolate_dict(complete_state)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001750 pretty_print(value, sys.stdout)
maruel@chromium.orge27c65f2012-10-22 13:56:53 +00001751 if exceptions:
1752 # It got an exception, raise the first one.
1753 raise \
1754 exceptions[0][0], \
1755 exceptions[0][1], \
1756 exceptions[0][2]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001757 return 0
1758
1759
1760def CMDremap(args):
1761 """Creates a directory with all the dependencies mapped into it.
1762
1763 Useful to test manually why a test is failing. The target executable is not
1764 run.
1765 """
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001766 parser = OptionParserIsolate(command='remap', require_isolated=False)
maruel@chromium.org9268f042012-10-17 17:36:41 +00001767 options, args = parser.parse_args(args)
1768 if args:
1769 parser.error('Unsupported argument: %s' % args)
1770 complete_state = load_complete_state(options, None)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001771
1772 if not options.outdir:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001773 options.outdir = run_isolated.make_temp_dir(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001774 'isolate', complete_state.root_dir)
1775 else:
1776 if not os.path.isdir(options.outdir):
1777 os.makedirs(options.outdir)
maruel@chromium.orgec91af12012-10-18 20:45:57 +00001778 print('Remapping into %s' % options.outdir)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001779 if len(os.listdir(options.outdir)):
1780 raise ExecutionError('Can\'t remap in a non-empty directory')
1781 recreate_tree(
1782 outdir=options.outdir,
1783 indir=complete_state.root_dir,
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001784 infiles=complete_state.isolated.files,
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001785 action=run_isolated.HARDLINK,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001786 as_sha1=False)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001787 if complete_state.isolated.read_only:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001788 run_isolated.make_writable(options.outdir, True)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001789
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001790 if complete_state.isolated_filepath:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001791 complete_state.save_files()
1792 return 0
1793
1794
1795def CMDrun(args):
1796 """Runs the test executable in an isolated (temporary) directory.
1797
1798 All the dependencies are mapped into the temporary directory and the
1799 directory is cleaned up after the target exits. Warning: if -outdir is
1800 specified, it is deleted upon exit.
1801
1802 Argument processing stops at the first non-recognized argument and these
1803 arguments are appended to the command line of the target to run. For example,
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001804 use: isolate.py --isolated foo.isolated -- --gtest_filter=Foo.Bar
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001805 """
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001806 parser = OptionParserIsolate(command='run', require_isolated=False)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001807 parser.enable_interspersed_args()
1808 options, args = parser.parse_args(args)
maruel@chromium.org9268f042012-10-17 17:36:41 +00001809 complete_state = load_complete_state(options, None)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001810 cmd = complete_state.isolated.command + args
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001811 if not cmd:
1812 raise ExecutionError('No command to run')
1813 cmd = trace_inputs.fix_python_path(cmd)
1814 try:
1815 if not options.outdir:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001816 options.outdir = run_isolated.make_temp_dir(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001817 'isolate', complete_state.root_dir)
1818 else:
1819 if not os.path.isdir(options.outdir):
1820 os.makedirs(options.outdir)
1821 recreate_tree(
1822 outdir=options.outdir,
1823 indir=complete_state.root_dir,
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001824 infiles=complete_state.isolated.files,
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001825 action=run_isolated.HARDLINK,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001826 as_sha1=False)
1827 cwd = os.path.normpath(
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001828 os.path.join(options.outdir, complete_state.isolated.relative_cwd))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001829 if not os.path.isdir(cwd):
1830 # It can happen when no files are mapped from the directory containing the
1831 # .isolate file. But the directory must exist to be the current working
1832 # directory.
1833 os.makedirs(cwd)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001834 if complete_state.isolated.read_only:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001835 run_isolated.make_writable(options.outdir, True)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001836 logging.info('Running %s, cwd=%s' % (cmd, cwd))
1837 result = subprocess.call(cmd, cwd=cwd)
1838 finally:
1839 if options.outdir:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001840 run_isolated.rmtree(options.outdir)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001841
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001842 if complete_state.isolated_filepath:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001843 complete_state.save_files()
1844 return result
1845
1846
1847def CMDtrace(args):
1848 """Traces the target using trace_inputs.py.
1849
1850 It runs the executable without remapping it, and traces all the files it and
1851 its child processes access. Then the 'read' command can be used to generate an
1852 updated .isolate file out of it.
1853
1854 Argument processing stops at the first non-recognized argument and these
1855 arguments are appended to the command line of the target to run. For example,
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001856 use: isolate.py --isolated foo.isolated -- --gtest_filter=Foo.Bar
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001857 """
1858 parser = OptionParserIsolate(command='trace')
1859 parser.enable_interspersed_args()
1860 parser.add_option(
1861 '-m', '--merge', action='store_true',
1862 help='After tracing, merge the results back in the .isolate file')
1863 options, args = parser.parse_args(args)
maruel@chromium.org9268f042012-10-17 17:36:41 +00001864 complete_state = load_complete_state(options, None)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001865 cmd = complete_state.isolated.command + args
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001866 if not cmd:
1867 raise ExecutionError('No command to run')
1868 cmd = trace_inputs.fix_python_path(cmd)
1869 cwd = os.path.normpath(os.path.join(
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001870 complete_state.root_dir, complete_state.isolated.relative_cwd))
maruel@chromium.org808f6af2012-10-11 14:08:08 +00001871 cmd[0] = os.path.normpath(os.path.join(cwd, cmd[0]))
1872 if not os.path.isfile(cmd[0]):
1873 raise ExecutionError(
1874 'Tracing failed for: %s\nIt doesn\'t exit' % ' '.join(cmd))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001875 logging.info('Running %s, cwd=%s' % (cmd, cwd))
1876 api = trace_inputs.get_api()
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001877 logfile = complete_state.isolated_filepath + '.log'
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001878 api.clean_trace(logfile)
1879 try:
1880 with api.get_tracer(logfile) as tracer:
1881 result, _ = tracer.trace(
1882 cmd,
1883 cwd,
1884 'default',
1885 True)
1886 except trace_inputs.TracingFailure, e:
1887 raise ExecutionError('Tracing failed for: %s\n%s' % (' '.join(cmd), str(e)))
1888
csharp@chromium.org5ab1ca92012-10-25 13:37:14 +00001889 if result:
1890 logging.error('Tracer exited with %d, which means the tests probably '
1891 'failed so the trace is probably incomplete.', result)
1892
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001893 complete_state.save_files()
1894
1895 if options.merge:
1896 merge(complete_state)
1897
1898 return result
1899
1900
maruel@chromium.orgb253fb82012-10-16 21:44:48 +00001901def add_variable_option(parser):
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001902 """Adds --isolated and --variable to an OptionParser."""
1903 parser.add_option(
1904 '-s', '--isolated',
1905 metavar='FILE',
1906 help='.isolated file to generate or read')
1907 # Keep for compatibility. TODO(maruel): Remove once not used anymore.
maruel@chromium.orgb253fb82012-10-16 21:44:48 +00001908 parser.add_option(
1909 '-r', '--result',
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001910 dest='isolated',
1911 help=optparse.SUPPRESS_HELP)
maruel@chromium.orgb253fb82012-10-16 21:44:48 +00001912 default_variables = [('OS', get_flavor())]
1913 if sys.platform in ('win32', 'cygwin'):
1914 default_variables.append(('EXECUTABLE_SUFFIX', '.exe'))
1915 else:
1916 default_variables.append(('EXECUTABLE_SUFFIX', ''))
1917 parser.add_option(
1918 '-V', '--variable',
1919 nargs=2,
1920 action='append',
1921 default=default_variables,
1922 dest='variables',
1923 metavar='FOO BAR',
1924 help='Variables to process in the .isolate file, default: %default. '
1925 'Variables are persistent accross calls, they are saved inside '
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001926 '<.isolated>.state')
maruel@chromium.orgb253fb82012-10-16 21:44:48 +00001927
1928
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001929def parse_variable_option(parser, options, require_isolated):
1930 """Processes --isolated and --variable."""
1931 if options.isolated:
1932 options.isolated = os.path.abspath(
1933 options.isolated.replace('/', os.path.sep))
1934 if require_isolated and not options.isolated:
1935 parser.error('--isolated is required.')
1936 if options.isolated and not options.isolated.endswith('.isolated'):
1937 parser.error('--isolated value must end with \'.isolated\'')
maruel@chromium.orgb253fb82012-10-16 21:44:48 +00001938 options.variables = dict(options.variables)
1939
1940
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001941class OptionParserIsolate(trace_inputs.OptionParserWithNiceDescription):
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001942 """Adds automatic --isolate, --isolated, --out and --variable handling."""
1943 def __init__(self, require_isolated=True, **kwargs):
maruel@chromium.org55276902012-10-05 20:56:19 +00001944 trace_inputs.OptionParserWithNiceDescription.__init__(
1945 self,
1946 verbose=int(os.environ.get('ISOLATE_DEBUG', 0)),
1947 **kwargs)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001948 group = optparse.OptionGroup(self, "Common options")
1949 group.add_option(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001950 '-i', '--isolate',
1951 metavar='FILE',
1952 help='.isolate file to load the dependency data from')
maruel@chromium.orgb253fb82012-10-16 21:44:48 +00001953 add_variable_option(group)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001954 group.add_option(
1955 '-o', '--outdir', metavar='DIR',
1956 help='Directory used to recreate the tree or store the hash table. '
1957 'If the environment variable ISOLATE_HASH_TABLE_DIR exists, it '
1958 'will be used. Otherwise, for run and remap, uses a /tmp '
1959 'subdirectory. For the other modes, defaults to the directory '
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001960 'containing --isolated')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001961 self.add_option_group(group)
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001962 self.require_isolated = require_isolated
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001963
1964 def parse_args(self, *args, **kwargs):
1965 """Makes sure the paths make sense.
1966
1967 On Windows, / and \ are often mixed together in a path.
1968 """
1969 options, args = trace_inputs.OptionParserWithNiceDescription.parse_args(
1970 self, *args, **kwargs)
1971 if not self.allow_interspersed_args and args:
1972 self.error('Unsupported argument: %s' % args)
1973
maruel@chromium.org0cd0b182012-10-22 13:34:15 +00001974 parse_variable_option(self, options, self.require_isolated)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001975
1976 if options.isolate:
1977 options.isolate = trace_inputs.get_native_path_case(
1978 os.path.abspath(
1979 options.isolate.replace('/', os.path.sep)))
1980
1981 if options.outdir and not re.match(r'^https?://.+$', options.outdir):
1982 options.outdir = os.path.abspath(
1983 options.outdir.replace('/', os.path.sep))
1984
1985 return options, args
1986
1987
1988### Glue code to make all the commands works magically.
1989
1990
1991CMDhelp = trace_inputs.CMDhelp
1992
1993
1994def main(argv):
1995 try:
1996 return trace_inputs.main_impl(argv)
1997 except (
1998 ExecutionError,
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001999 run_isolated.MappingError,
2000 run_isolated.ConfigError) as e:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002001 sys.stderr.write('\nError: ')
2002 sys.stderr.write(str(e))
2003 sys.stderr.write('\n')
2004 return 1
2005
2006
2007if __name__ == '__main__':
2008 sys.exit(main(sys.argv[1:]))