blob: 89f80b0d29a0bc22e917cb5ebefa952f92873661 [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)
699 # Creates the right set of variables here. We only care about PATH_VARIABLES.
700 variables = dict(
701 ('<(%s)' % k, variables[k].replace(os.path.sep, '/'))
702 for k in PATH_VARIABLES if k in variables)
703
704 # Actual work: Process the files.
705 # TODO(maruel): if all the files in a directory are in part tracked and in
706 # part untracked, the directory will not be extracted. Tracked files should be
707 # 'promoted' to be untracked as needed.
708 tracked = trace_inputs.extract_directories(
709 root_dir, tracked, default_blacklist)
710 untracked = trace_inputs.extract_directories(
711 root_dir, untracked, default_blacklist)
712 # touched is not compressed, otherwise it would result in files to be archived
713 # that we don't need.
714
715 def fix(f):
716 """Bases the file on the most restrictive variable."""
717 logging.debug('fix(%s)' % f)
718 # Important, GYP stores the files with / and not \.
719 f = f.replace(os.path.sep, '/')
720 # If it's not already a variable.
721 if not f.startswith('<'):
722 # relative_cwd is usually the directory containing the gyp file. It may be
723 # empty if the whole directory containing the gyp file is needed.
maruel@chromium.org8b056ba2012-10-16 14:04:49 +0000724 # Use absolute paths in case cwd_dir is outside of root_dir.
725 f = posix_relpath(
726 os.path.join(root_dir, f),
727 os.path.join(root_dir, relative_cwd)) or './'
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000728
729 for variable, root_path in variables.iteritems():
730 if f.startswith(root_path):
731 f = variable + f[len(root_path):]
732 break
733
734 # Now strips off known files we want to ignore and to any specific mangling
735 # as necessary. It's easier to do it here than generate a blacklist.
736 match = EXECUTABLE.match(f)
737 if match:
738 return match.group(1) + '<(EXECUTABLE_SUFFIX)'
739
740 # Blacklist logs and 'First Run' in the PRODUCT_DIR. First Run is not
741 # created by the compile, but by the test itself.
742 if LOG_FILE.match(f) or f == '<(PRODUCT_DIR)/First Run':
743 return None
744
745 if sys.platform == 'darwin':
746 # On OSX, the name of the output is dependent on gyp define, it can be
747 # 'Google Chrome.app' or 'Chromium.app', same for 'XXX
748 # Framework.framework'. Furthermore, they are versioned with a gyp
749 # variable. To lower the complexity of the .isolate file, remove all the
750 # individual entries that show up under any of the 4 entries and replace
751 # them with the directory itself. Overall, this results in a bit more
752 # files than strictly necessary.
753 OSX_BUNDLES = (
754 '<(PRODUCT_DIR)/Chromium Framework.framework/',
755 '<(PRODUCT_DIR)/Chromium.app/',
756 '<(PRODUCT_DIR)/Google Chrome Framework.framework/',
757 '<(PRODUCT_DIR)/Google Chrome.app/',
758 )
759 for prefix in OSX_BUNDLES:
760 if f.startswith(prefix):
761 # Note this result in duplicate values, so the a set() must be used to
762 # remove duplicates.
763 return prefix
764
765 return f
766
767 tracked = set(filter(None, (fix(f.path) for f in tracked)))
768 untracked = set(filter(None, (fix(f.path) for f in untracked)))
769 touched = set(filter(None, (fix(f.path) for f in touched)))
770 out = classify_files(root_dir, tracked, untracked)
771 if touched:
772 out[KEY_TOUCHED] = sorted(touched)
773 return out
774
775
776def generate_isolate(
777 tracked, untracked, touched, root_dir, variables, relative_cwd):
778 """Generates a clean and complete .isolate file."""
779 result = generate_simplified(
780 tracked, untracked, touched, root_dir, variables, relative_cwd)
781 return {
782 'conditions': [
783 ['OS=="%s"' % get_flavor(), {
784 'variables': result,
785 }],
786 ],
787 }
788
789
790def split_touched(files):
791 """Splits files that are touched vs files that are read."""
792 tracked = []
793 touched = []
794 for f in files:
795 if f.size:
796 tracked.append(f)
797 else:
798 touched.append(f)
799 return tracked, touched
800
801
802def pretty_print(variables, stdout):
803 """Outputs a gyp compatible list from the decoded variables.
804
805 Similar to pprint.print() but with NIH syndrome.
806 """
807 # Order the dictionary keys by these keys in priority.
808 ORDER = (
809 'variables', 'condition', 'command', 'relative_cwd', 'read_only',
810 KEY_TRACKED, KEY_UNTRACKED)
811
812 def sorting_key(x):
813 """Gives priority to 'most important' keys before the others."""
814 if x in ORDER:
815 return str(ORDER.index(x))
816 return x
817
818 def loop_list(indent, items):
819 for item in items:
820 if isinstance(item, basestring):
821 stdout.write('%s\'%s\',\n' % (indent, item))
822 elif isinstance(item, dict):
823 stdout.write('%s{\n' % indent)
824 loop_dict(indent + ' ', item)
825 stdout.write('%s},\n' % indent)
826 elif isinstance(item, list):
827 # A list inside a list will write the first item embedded.
828 stdout.write('%s[' % indent)
829 for index, i in enumerate(item):
830 if isinstance(i, basestring):
831 stdout.write(
832 '\'%s\', ' % i.replace('\\', '\\\\').replace('\'', '\\\''))
833 elif isinstance(i, dict):
834 stdout.write('{\n')
835 loop_dict(indent + ' ', i)
836 if index != len(item) - 1:
837 x = ', '
838 else:
839 x = ''
840 stdout.write('%s}%s' % (indent, x))
841 else:
842 assert False
843 stdout.write('],\n')
844 else:
845 assert False
846
847 def loop_dict(indent, items):
848 for key in sorted(items, key=sorting_key):
849 item = items[key]
850 stdout.write("%s'%s': " % (indent, key))
851 if isinstance(item, dict):
852 stdout.write('{\n')
853 loop_dict(indent + ' ', item)
854 stdout.write(indent + '},\n')
855 elif isinstance(item, list):
856 stdout.write('[\n')
857 loop_list(indent + ' ', item)
858 stdout.write(indent + '],\n')
859 elif isinstance(item, basestring):
860 stdout.write(
861 '\'%s\',\n' % item.replace('\\', '\\\\').replace('\'', '\\\''))
862 elif item in (True, False, None):
863 stdout.write('%s\n' % item)
864 else:
865 assert False, item
866
867 stdout.write('{\n')
868 loop_dict(' ', variables)
869 stdout.write('}\n')
870
871
872def union(lhs, rhs):
873 """Merges two compatible datastructures composed of dict/list/set."""
874 assert lhs is not None or rhs is not None
875 if lhs is None:
876 return copy.deepcopy(rhs)
877 if rhs is None:
878 return copy.deepcopy(lhs)
879 assert type(lhs) == type(rhs), (lhs, rhs)
880 if hasattr(lhs, 'union'):
881 # Includes set, OSSettings and Configs.
882 return lhs.union(rhs)
883 if isinstance(lhs, dict):
884 return dict((k, union(lhs.get(k), rhs.get(k))) for k in set(lhs).union(rhs))
885 elif isinstance(lhs, list):
886 # Do not go inside the list.
887 return lhs + rhs
888 assert False, type(lhs)
889
890
891def extract_comment(content):
892 """Extracts file level comment."""
893 out = []
894 for line in content.splitlines(True):
895 if line.startswith('#'):
896 out.append(line)
897 else:
898 break
899 return ''.join(out)
900
901
902def eval_content(content):
903 """Evaluates a python file and return the value defined in it.
904
905 Used in practice for .isolate files.
906 """
907 globs = {'__builtins__': None}
908 locs = {}
909 value = eval(content, globs, locs)
910 assert locs == {}, locs
911 assert globs == {'__builtins__': None}, globs
912 return value
913
914
915def verify_variables(variables):
916 """Verifies the |variables| dictionary is in the expected format."""
917 VALID_VARIABLES = [
918 KEY_TOUCHED,
919 KEY_TRACKED,
920 KEY_UNTRACKED,
921 'command',
922 'read_only',
923 ]
924 assert isinstance(variables, dict), variables
925 assert set(VALID_VARIABLES).issuperset(set(variables)), variables.keys()
926 for name, value in variables.iteritems():
927 if name == 'read_only':
928 assert value in (True, False, None), value
929 else:
930 assert isinstance(value, list), value
931 assert all(isinstance(i, basestring) for i in value), value
932
933
934def verify_condition(condition):
935 """Verifies the |condition| dictionary is in the expected format."""
936 VALID_INSIDE_CONDITION = ['variables']
937 assert isinstance(condition, list), condition
938 assert 2 <= len(condition) <= 3, condition
939 assert re.match(r'OS==\"([a-z]+)\"', condition[0]), condition[0]
940 for c in condition[1:]:
941 assert isinstance(c, dict), c
942 assert set(VALID_INSIDE_CONDITION).issuperset(set(c)), c.keys()
943 verify_variables(c.get('variables', {}))
944
945
946def verify_root(value):
947 VALID_ROOTS = ['variables', 'conditions']
948 assert isinstance(value, dict), value
949 assert set(VALID_ROOTS).issuperset(set(value)), value.keys()
950 verify_variables(value.get('variables', {}))
951
952 conditions = value.get('conditions', [])
953 assert isinstance(conditions, list), conditions
954 for condition in conditions:
955 verify_condition(condition)
956
957
958def remove_weak_dependencies(values, key, item, item_oses):
959 """Remove any oses from this key if the item is already under a strong key."""
960 if key == KEY_TOUCHED:
961 for stronger_key in (KEY_TRACKED, KEY_UNTRACKED):
962 oses = values.get(stronger_key, {}).get(item, None)
963 if oses:
964 item_oses -= oses
965
966 return item_oses
967
968
969def invert_map(variables):
970 """Converts a dict(OS, dict(deptype, list(dependencies)) to a flattened view.
971
972 Returns a tuple of:
973 1. dict(deptype, dict(dependency, set(OSes)) for easier processing.
974 2. All the OSes found as a set.
975 """
976 KEYS = (
977 KEY_TOUCHED,
978 KEY_TRACKED,
979 KEY_UNTRACKED,
980 'command',
981 'read_only',
982 )
983 out = dict((key, {}) for key in KEYS)
984 for os_name, values in variables.iteritems():
985 for key in (KEY_TOUCHED, KEY_TRACKED, KEY_UNTRACKED):
986 for item in values.get(key, []):
987 out[key].setdefault(item, set()).add(os_name)
988
989 # command needs special handling.
990 command = tuple(values.get('command', []))
991 out['command'].setdefault(command, set()).add(os_name)
992
993 # read_only needs special handling.
994 out['read_only'].setdefault(values.get('read_only'), set()).add(os_name)
995 return out, set(variables)
996
997
998def reduce_inputs(values, oses):
999 """Reduces the invert_map() output to the strictest minimum list.
1000
1001 1. Construct the inverse map first.
1002 2. Look at each individual file and directory, map where they are used and
1003 reconstruct the inverse dictionary.
1004 3. Do not convert back to negative if only 2 OSes were merged.
1005
1006 Returns a tuple of:
1007 1. the minimized dictionary
1008 2. oses passed through as-is.
1009 """
1010 KEYS = (
1011 KEY_TOUCHED,
1012 KEY_TRACKED,
1013 KEY_UNTRACKED,
1014 'command',
1015 'read_only',
1016 )
1017 out = dict((key, {}) for key in KEYS)
1018 assert all(oses), oses
1019 if len(oses) > 2:
1020 for key in KEYS:
1021 for item, item_oses in values.get(key, {}).iteritems():
1022 item_oses = remove_weak_dependencies(values, key, item, item_oses)
1023 if not item_oses:
1024 continue
1025
1026 # Converts all oses.difference('foo') to '!foo'.
1027 assert all(item_oses), item_oses
1028 missing = oses.difference(item_oses)
1029 if len(missing) == 1:
1030 # Replace it with a negative.
1031 out[key][item] = set(['!' + tuple(missing)[0]])
1032 elif not missing:
1033 out[key][item] = set([None])
1034 else:
1035 out[key][item] = set(item_oses)
1036 else:
1037 for key in KEYS:
1038 for item, item_oses in values.get(key, {}).iteritems():
1039 item_oses = remove_weak_dependencies(values, key, item, item_oses)
1040 if not item_oses:
1041 continue
1042
1043 # Converts all oses.difference('foo') to '!foo'.
1044 assert None not in item_oses, item_oses
1045 out[key][item] = set(item_oses)
1046 return out, oses
1047
1048
1049def convert_map_to_isolate_dict(values, oses):
1050 """Regenerates back a .isolate configuration dict from files and dirs
1051 mappings generated from reduce_inputs().
1052 """
1053 # First, inverse the mapping to make it dict first.
1054 config = {}
1055 for key in values:
1056 for item, oses in values[key].iteritems():
1057 if item is None:
1058 # For read_only default.
1059 continue
1060 for cond_os in oses:
1061 cond_key = None if cond_os is None else cond_os.lstrip('!')
1062 # Insert the if/else dicts.
1063 condition_values = config.setdefault(cond_key, [{}, {}])
1064 # If condition is negative, use index 1, else use index 0.
1065 cond_value = condition_values[int((cond_os or '').startswith('!'))]
1066 variables = cond_value.setdefault('variables', {})
1067
1068 if item in (True, False):
1069 # One-off for read_only.
1070 variables[key] = item
1071 else:
1072 if isinstance(item, tuple) and item:
1073 # One-off for command.
1074 # Do not merge lists and do not sort!
1075 # Note that item is a tuple.
1076 assert key not in variables
1077 variables[key] = list(item)
1078 elif item:
1079 # The list of items (files or dirs). Append the new item and keep
1080 # the list sorted.
1081 l = variables.setdefault(key, [])
1082 l.append(item)
1083 l.sort()
1084
1085 out = {}
1086 for o in sorted(config):
1087 d = config[o]
1088 if o is None:
1089 assert not d[1]
1090 out = union(out, d[0])
1091 else:
1092 c = out.setdefault('conditions', [])
1093 if d[1]:
1094 c.append(['OS=="%s"' % o] + d)
1095 else:
1096 c.append(['OS=="%s"' % o] + d[0:1])
1097 return out
1098
1099
1100### Internal state files.
1101
1102
1103class OSSettings(object):
1104 """Represents the dependencies for an OS. The structure is immutable.
1105
1106 It's the .isolate settings for a specific file.
1107 """
1108 def __init__(self, name, values):
1109 self.name = name
1110 verify_variables(values)
1111 self.touched = sorted(values.get(KEY_TOUCHED, []))
1112 self.tracked = sorted(values.get(KEY_TRACKED, []))
1113 self.untracked = sorted(values.get(KEY_UNTRACKED, []))
1114 self.command = values.get('command', [])[:]
1115 self.read_only = values.get('read_only')
1116
1117 def union(self, rhs):
1118 assert self.name == rhs.name
1119 assert not (self.command and rhs.command)
1120 var = {
1121 KEY_TOUCHED: sorted(self.touched + rhs.touched),
1122 KEY_TRACKED: sorted(self.tracked + rhs.tracked),
1123 KEY_UNTRACKED: sorted(self.untracked + rhs.untracked),
1124 'command': self.command or rhs.command,
1125 'read_only': rhs.read_only if self.read_only is None else self.read_only,
1126 }
1127 return OSSettings(self.name, var)
1128
1129 def flatten(self):
1130 out = {}
1131 if self.command:
1132 out['command'] = self.command
1133 if self.touched:
1134 out[KEY_TOUCHED] = self.touched
1135 if self.tracked:
1136 out[KEY_TRACKED] = self.tracked
1137 if self.untracked:
1138 out[KEY_UNTRACKED] = self.untracked
1139 if self.read_only is not None:
1140 out['read_only'] = self.read_only
1141 return out
1142
1143
1144class Configs(object):
1145 """Represents a processed .isolate file.
1146
1147 Stores the file in a processed way, split by each the OS-specific
1148 configurations.
1149
1150 The self.per_os[None] member contains all the 'else' clauses plus the default
1151 values. It is not included in the flatten() result.
1152 """
1153 def __init__(self, oses, file_comment):
1154 self.file_comment = file_comment
1155 self.per_os = {
1156 None: OSSettings(None, {}),
1157 }
1158 self.per_os.update(dict((name, OSSettings(name, {})) for name in oses))
1159
1160 def union(self, rhs):
1161 items = list(set(self.per_os.keys() + rhs.per_os.keys()))
1162 # Takes the first file comment, prefering lhs.
1163 out = Configs(items, self.file_comment or rhs.file_comment)
1164 for key in items:
1165 out.per_os[key] = union(self.per_os.get(key), rhs.per_os.get(key))
1166 return out
1167
1168 def add_globals(self, values):
1169 for key in self.per_os:
1170 self.per_os[key] = self.per_os[key].union(OSSettings(key, values))
1171
1172 def add_values(self, for_os, values):
1173 self.per_os[for_os] = self.per_os[for_os].union(OSSettings(for_os, values))
1174
1175 def add_negative_values(self, for_os, values):
1176 """Includes the variables to all OSes except |for_os|.
1177
1178 This includes 'None' so unknown OSes gets it too.
1179 """
1180 for key in self.per_os:
1181 if key != for_os:
1182 self.per_os[key] = self.per_os[key].union(OSSettings(key, values))
1183
1184 def flatten(self):
1185 """Returns a flat dictionary representation of the configuration.
1186
1187 Skips None pseudo-OS.
1188 """
1189 return dict(
1190 (k, v.flatten()) for k, v in self.per_os.iteritems() if k is not None)
1191
1192
1193def load_isolate_as_config(value, file_comment, default_oses):
1194 """Parses one .isolate file and returns a Configs() instance.
1195
1196 |value| is the loaded dictionary that was defined in the gyp file.
1197
1198 The expected format is strict, anything diverting from the format below will
1199 throw an assert:
1200 {
1201 'variables': {
1202 'command': [
1203 ...
1204 ],
1205 'isolate_dependency_tracked': [
1206 ...
1207 ],
1208 'isolate_dependency_untracked': [
1209 ...
1210 ],
1211 'read_only': False,
1212 },
1213 'conditions': [
1214 ['OS=="<os>"', {
1215 'variables': {
1216 ...
1217 },
1218 }, { # else
1219 'variables': {
1220 ...
1221 },
1222 }],
1223 ...
1224 ],
1225 }
1226 """
1227 verify_root(value)
1228
1229 # Scan to get the list of OSes.
1230 conditions = value.get('conditions', [])
1231 oses = set(re.match(r'OS==\"([a-z]+)\"', c[0]).group(1) for c in conditions)
1232 oses = oses.union(default_oses)
1233 configs = Configs(oses, file_comment)
1234
1235 # Global level variables.
1236 configs.add_globals(value.get('variables', {}))
1237
1238 # OS specific variables.
1239 for condition in conditions:
1240 condition_os = re.match(r'OS==\"([a-z]+)\"', condition[0]).group(1)
1241 configs.add_values(condition_os, condition[1].get('variables', {}))
1242 if len(condition) > 2:
1243 configs.add_negative_values(
1244 condition_os, condition[2].get('variables', {}))
1245 return configs
1246
1247
1248def load_isolate_for_flavor(content, flavor):
1249 """Loads the .isolate file and returns the information unprocessed.
1250
1251 Returns the command, dependencies and read_only flag. The dependencies are
1252 fixed to use os.path.sep.
1253 """
1254 # Load the .isolate file, process its conditions, retrieve the command and
1255 # dependencies.
1256 configs = load_isolate_as_config(eval_content(content), None, DEFAULT_OSES)
1257 config = configs.per_os.get(flavor) or configs.per_os.get(None)
1258 if not config:
1259 raise ExecutionError('Failed to load configuration for \'%s\'' % flavor)
1260 # Merge tracked and untracked dependencies, isolate.py doesn't care about the
1261 # trackability of the dependencies, only the build tool does.
1262 dependencies = [
1263 f.replace('/', os.path.sep) for f in config.tracked + config.untracked
1264 ]
1265 touched = [f.replace('/', os.path.sep) for f in config.touched]
1266 return config.command, dependencies, touched, config.read_only
1267
1268
1269class Flattenable(object):
1270 """Represents data that can be represented as a json file."""
1271 MEMBERS = ()
1272
1273 def flatten(self):
1274 """Returns a json-serializable version of itself.
1275
1276 Skips None entries.
1277 """
1278 items = ((member, getattr(self, member)) for member in self.MEMBERS)
1279 return dict((member, value) for member, value in items if value is not None)
1280
1281 @classmethod
1282 def load(cls, data):
1283 """Loads a flattened version."""
1284 data = data.copy()
1285 out = cls()
1286 for member in out.MEMBERS:
1287 if member in data:
1288 # Access to a protected member XXX of a client class
1289 # pylint: disable=W0212
1290 out._load_member(member, data.pop(member))
1291 if data:
1292 raise ValueError(
1293 'Found unexpected entry %s while constructing an object %s' %
1294 (data, cls.__name__), data, cls.__name__)
1295 return out
1296
1297 def _load_member(self, member, value):
1298 """Loads a member into self."""
1299 setattr(self, member, value)
1300
1301 @classmethod
1302 def load_file(cls, filename):
1303 """Loads the data from a file or return an empty instance."""
1304 out = cls()
1305 try:
1306 out = cls.load(trace_inputs.read_json(filename))
1307 logging.debug('Loaded %s(%s)' % (cls.__name__, filename))
1308 except (IOError, ValueError):
1309 logging.warn('Failed to load %s' % filename)
1310 return out
1311
1312
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001313class IsolatedFile(Flattenable):
1314 """Describes the content of a .isolated file.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001315
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001316 This file is used by run_isolated.py so its content is strictly only
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001317 what is necessary to run the test outside of a checkout.
1318
1319 It is important to note that the 'files' dict keys are using native OS path
1320 separator instead of '/' used in .isolate file.
1321 """
1322 MEMBERS = (
1323 'command',
1324 'files',
1325 'os',
1326 'read_only',
1327 'relative_cwd',
1328 )
1329
1330 os = get_flavor()
1331
1332 def __init__(self):
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001333 super(IsolatedFile, self).__init__()
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001334 self.command = []
1335 self.files = {}
1336 self.read_only = None
1337 self.relative_cwd = None
1338
1339 def update(self, command, infiles, touched, read_only, relative_cwd):
1340 """Updates the result state with new information."""
1341 self.command = command
1342 # Add new files.
1343 for f in infiles:
1344 self.files.setdefault(f, {})
1345 for f in touched:
1346 self.files.setdefault(f, {})['touched_only'] = True
1347 # Prune extraneous files that are not a dependency anymore.
1348 for f in set(self.files).difference(set(infiles).union(touched)):
1349 del self.files[f]
1350 if read_only is not None:
1351 self.read_only = read_only
1352 self.relative_cwd = relative_cwd
1353
1354 def _load_member(self, member, value):
1355 if member == 'os':
1356 if value != self.os:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001357 raise run_isolated.ConfigError(
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001358 'The .isolated file was created on another platform')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001359 else:
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001360 super(IsolatedFile, self)._load_member(member, value)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001361
1362 def __str__(self):
1363 out = '%s(\n' % self.__class__.__name__
1364 out += ' command: %s\n' % self.command
1365 out += ' files: %d\n' % len(self.files)
1366 out += ' read_only: %s\n' % self.read_only
1367 out += ' relative_cwd: %s)' % self.relative_cwd
1368 return out
1369
1370
1371class SavedState(Flattenable):
1372 """Describes the content of a .state file.
1373
1374 The items in this file are simply to improve the developer's life and aren't
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001375 used by run_isolated.py. This file can always be safely removed.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001376
1377 isolate_file permits to find back root_dir, variables are used for stateful
1378 rerun.
1379 """
1380 MEMBERS = (
1381 'isolate_file',
1382 'variables',
1383 )
1384
1385 def __init__(self):
1386 super(SavedState, self).__init__()
1387 self.isolate_file = None
1388 self.variables = {}
1389
1390 def update(self, isolate_file, variables):
1391 """Updates the saved state with new information."""
1392 self.isolate_file = isolate_file
1393 self.variables.update(variables)
1394
1395 @classmethod
1396 def load(cls, data):
1397 out = super(SavedState, cls).load(data)
1398 if out.isolate_file:
1399 out.isolate_file = trace_inputs.get_native_path_case(out.isolate_file)
1400 return out
1401
1402 def __str__(self):
1403 out = '%s(\n' % self.__class__.__name__
1404 out += ' isolate_file: %s\n' % self.isolate_file
1405 out += ' variables: %s' % ''.join(
1406 '\n %s=%s' % (k, self.variables[k]) for k in sorted(self.variables))
1407 out += ')'
1408 return out
1409
1410
1411class CompleteState(object):
1412 """Contains all the state to run the task at hand."""
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001413 def __init__(self, isolated_filepath, isolated, saved_state):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001414 super(CompleteState, self).__init__()
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001415 self.isolated_filepath = isolated_filepath
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001416 # Contains the data that will be used by run_isolated.py
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001417 self.isolated = isolated
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001418 # Contains the data to ease developer's use-case but that is not strictly
1419 # necessary.
1420 self.saved_state = saved_state
1421
1422 @classmethod
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001423 def load_files(cls, isolated_filepath):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001424 """Loads state from disk."""
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001425 assert os.path.isabs(isolated_filepath), isolated_filepath
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001426 return cls(
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001427 isolated_filepath,
1428 IsolatedFile.load_file(isolated_filepath),
1429 SavedState.load_file(isolatedfile_to_state(isolated_filepath)))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001430
1431 def load_isolate(self, isolate_file, variables):
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001432 """Updates self.isolated and self.saved_state with information loaded from a
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001433 .isolate file.
1434
1435 Processes the loaded data, deduce root_dir, relative_cwd.
1436 """
1437 # Make sure to not depend on os.getcwd().
1438 assert os.path.isabs(isolate_file), isolate_file
1439 logging.info(
1440 'CompleteState.load_isolate(%s, %s)' % (isolate_file, variables))
1441 relative_base_dir = os.path.dirname(isolate_file)
1442
1443 # Processes the variables and update the saved state.
1444 variables = process_variables(variables, relative_base_dir)
1445 self.saved_state.update(isolate_file, variables)
1446
1447 with open(isolate_file, 'r') as f:
1448 # At that point, variables are not replaced yet in command and infiles.
1449 # infiles may contain directory entries and is in posix style.
1450 command, infiles, touched, read_only = load_isolate_for_flavor(
1451 f.read(), get_flavor())
1452 command = [eval_variables(i, self.saved_state.variables) for i in command]
1453 infiles = [eval_variables(f, self.saved_state.variables) for f in infiles]
1454 touched = [eval_variables(f, self.saved_state.variables) for f in touched]
1455 # root_dir is automatically determined by the deepest root accessed with the
1456 # form '../../foo/bar'.
1457 root_dir = determine_root_dir(relative_base_dir, infiles + touched)
1458 # The relative directory is automatically determined by the relative path
1459 # between root_dir and the directory containing the .isolate file,
1460 # isolate_base_dir.
1461 relative_cwd = os.path.relpath(relative_base_dir, root_dir)
1462 # Normalize the files based to root_dir. It is important to keep the
1463 # trailing os.path.sep at that step.
1464 infiles = [
1465 relpath(normpath(os.path.join(relative_base_dir, f)), root_dir)
1466 for f in infiles
1467 ]
1468 touched = [
1469 relpath(normpath(os.path.join(relative_base_dir, f)), root_dir)
1470 for f in touched
1471 ]
1472 # Expand the directories by listing each file inside. Up to now, trailing
1473 # os.path.sep must be kept. Do not expand 'touched'.
1474 infiles = expand_directories_and_symlinks(
1475 root_dir,
1476 infiles,
1477 lambda x: re.match(r'.*\.(git|svn|pyc)$', x))
1478
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001479 # Finally, update the new stuff in the foo.isolated file, the file that is
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001480 # used by run_isolated.py.
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001481 self.isolated.update(command, infiles, touched, read_only, relative_cwd)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001482 logging.debug(self)
1483
maruel@chromium.orgf2826a32012-10-16 18:26:17 +00001484 def process_inputs(self):
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001485 """Updates self.isolated.files with the files' mode and hash.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001486
1487 See process_input() for more information.
1488 """
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001489 for infile in sorted(self.isolated.files):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001490 filepath = os.path.join(self.root_dir, infile)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001491 self.isolated.files[infile] = process_input(
maruel@chromium.orgf2826a32012-10-16 18:26:17 +00001492 filepath, self.isolated.files[infile], self.isolated.read_only)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001493
1494 def save_files(self):
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001495 """Saves both self.isolated and self.saved_state."""
1496 logging.debug('Dumping to %s' % self.isolated_filepath)
1497 trace_inputs.write_json(
1498 self.isolated_filepath, self.isolated.flatten(), True)
1499 total_bytes = sum(i
1500 .get('size', 0) for i in self.isolated.files.itervalues())
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001501 if total_bytes:
1502 logging.debug('Total size: %d bytes' % total_bytes)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001503 saved_state_file = isolatedfile_to_state(self.isolated_filepath)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001504 logging.debug('Dumping to %s' % saved_state_file)
1505 trace_inputs.write_json(saved_state_file, self.saved_state.flatten(), True)
1506
1507 @property
1508 def root_dir(self):
1509 """isolate_file is always inside relative_cwd relative to root_dir."""
maruel@chromium.orgb253fb82012-10-16 21:44:48 +00001510 if not self.saved_state.isolate_file:
1511 raise ExecutionError('Please specify --isolate')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001512 isolate_dir = os.path.dirname(self.saved_state.isolate_file)
1513 # Special case '.'.
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001514 if self.isolated.relative_cwd == '.':
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001515 return isolate_dir
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001516 assert isolate_dir.endswith(self.isolated.relative_cwd), (
1517 isolate_dir, self.isolated.relative_cwd)
1518 return isolate_dir[:-(len(self.isolated.relative_cwd) + 1)]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001519
1520 @property
1521 def resultdir(self):
1522 """Directory containing the results, usually equivalent to the variable
1523 PRODUCT_DIR.
1524 """
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001525 return os.path.dirname(self.isolated_filepath)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001526
1527 def __str__(self):
1528 def indent(data, indent_length):
1529 """Indents text."""
1530 spacing = ' ' * indent_length
1531 return ''.join(spacing + l for l in str(data).splitlines(True))
1532
1533 out = '%s(\n' % self.__class__.__name__
1534 out += ' root_dir: %s\n' % self.root_dir
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001535 out += ' result: %s\n' % indent(self.isolated, 2)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001536 out += ' saved_state: %s)' % indent(self.saved_state, 2)
1537 return out
1538
1539
maruel@chromium.orgf2826a32012-10-16 18:26:17 +00001540def load_complete_state(options):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001541 """Loads a CompleteState.
1542
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001543 This includes data from .isolate, .isolated and .state files.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001544
1545 Arguments:
1546 options: Options instance generated with OptionParserIsolate.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001547 """
1548 if options.result:
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001549 # Load the previous state if it was present. Namely, "foo.isolated" and
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001550 # "foo.state".
1551 complete_state = CompleteState.load_files(options.result)
1552 else:
1553 # Constructs a dummy object that cannot be saved. Useful for temporary
1554 # commands like 'run'.
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001555 complete_state = CompleteState(None, IsolatedFile(), SavedState())
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001556 options.isolate = options.isolate or complete_state.saved_state.isolate_file
1557 if not options.isolate:
1558 raise ExecutionError('A .isolate file is required.')
1559 if (complete_state.saved_state.isolate_file and
1560 options.isolate != complete_state.saved_state.isolate_file):
1561 raise ExecutionError(
1562 '%s and %s do not match.' % (
1563 options.isolate, complete_state.saved_state.isolate_file))
1564
1565 # Then load the .isolate and expands directories.
1566 complete_state.load_isolate(options.isolate, options.variables)
1567
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001568 # Regenerate complete_state.isolated.files.
maruel@chromium.orgf2826a32012-10-16 18:26:17 +00001569 complete_state.process_inputs()
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001570 return complete_state
1571
1572
1573def read_trace_as_isolate_dict(complete_state):
1574 """Reads a trace and returns the .isolate dictionary."""
1575 api = trace_inputs.get_api()
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001576 logfile = complete_state.isolated_filepath + '.log'
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001577 if not os.path.isfile(logfile):
1578 raise ExecutionError(
1579 'No log file \'%s\' to read, did you forget to \'trace\'?' % logfile)
1580 try:
1581 results = trace_inputs.load_trace(
1582 logfile, complete_state.root_dir, api, default_blacklist)
1583 tracked, touched = split_touched(results.existent)
1584 value = generate_isolate(
1585 tracked,
1586 [],
1587 touched,
1588 complete_state.root_dir,
1589 complete_state.saved_state.variables,
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001590 complete_state.isolated.relative_cwd)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001591 return value
1592 except trace_inputs.TracingFailure, e:
1593 raise ExecutionError(
1594 'Reading traces failed for: %s\n%s' %
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001595 (' '.join(complete_state.isolated.command), str(e)))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001596
1597
1598def print_all(comment, data, stream):
1599 """Prints a complete .isolate file and its top-level file comment into a
1600 stream.
1601 """
1602 if comment:
1603 stream.write(comment)
1604 pretty_print(data, stream)
1605
1606
1607def merge(complete_state):
1608 """Reads a trace and merges it back into the source .isolate file."""
1609 value = read_trace_as_isolate_dict(complete_state)
1610
1611 # Now take that data and union it into the original .isolate file.
1612 with open(complete_state.saved_state.isolate_file, 'r') as f:
1613 prev_content = f.read()
1614 prev_config = load_isolate_as_config(
1615 eval_content(prev_content),
1616 extract_comment(prev_content),
1617 DEFAULT_OSES)
1618 new_config = load_isolate_as_config(value, '', DEFAULT_OSES)
1619 config = union(prev_config, new_config)
1620 # pylint: disable=E1103
1621 data = convert_map_to_isolate_dict(
1622 *reduce_inputs(*invert_map(config.flatten())))
1623 print 'Updating %s' % complete_state.saved_state.isolate_file
1624 with open(complete_state.saved_state.isolate_file, 'wb') as f:
1625 print_all(config.file_comment, data, f)
1626
1627
1628def CMDcheck(args):
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001629 """Checks that all the inputs are present and update .isolated."""
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001630 parser = OptionParserIsolate(command='check')
1631 options, _ = parser.parse_args(args)
maruel@chromium.orgf2826a32012-10-16 18:26:17 +00001632 complete_state = load_complete_state(options)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001633
1634 # Nothing is done specifically. Just store the result and state.
1635 complete_state.save_files()
1636 return 0
1637
1638
1639def CMDhashtable(args):
1640 """Creates a hash table content addressed object store.
1641
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001642 All the files listed in the .isolated file are put in the output directory
1643 with the file name being the sha-1 of the file's content.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001644 """
1645 parser = OptionParserIsolate(command='hashtable')
1646 options, _ = parser.parse_args(args)
1647
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001648 with run_isolated.Profiler('GenerateHashtable'):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001649 success = False
1650 try:
maruel@chromium.orgf2826a32012-10-16 18:26:17 +00001651 complete_state = load_complete_state(options)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001652 options.outdir = (
1653 options.outdir or os.path.join(complete_state.resultdir, 'hashtable'))
1654 # Make sure that complete_state isn't modified until save_files() is
1655 # called, because any changes made to it here will propagate to the files
1656 # created (which is probably not intended).
1657 complete_state.save_files()
1658
1659 logging.info('Creating content addressed object store with %d item',
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001660 len(complete_state.isolated.files))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001661
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001662 with open(complete_state.isolated_filepath, 'rb') as f:
maruel@chromium.org861a5e72012-10-09 14:49:42 +00001663 content = f.read()
1664 manifest_hash = hashlib.sha1(content).hexdigest()
csharp@chromium.orgd62bcb92012-10-16 17:45:33 +00001665 manifest_metadata = {
1666 'sha-1': manifest_hash,
1667 'size': len(content),
1668 'priority': '0'
1669 }
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001670
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001671 infiles = complete_state.isolated.files
1672 infiles[complete_state.isolated_filepath] = manifest_metadata
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001673
1674 if re.match(r'^https?://.+$', options.outdir):
1675 upload_sha1_tree(
1676 base_url=options.outdir,
1677 indir=complete_state.root_dir,
1678 infiles=infiles)
1679 else:
1680 recreate_tree(
1681 outdir=options.outdir,
1682 indir=complete_state.root_dir,
1683 infiles=infiles,
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001684 action=run_isolated.HARDLINK,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001685 as_sha1=True)
1686 success = True
1687 finally:
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001688 # If the command failed, delete the .isolated file if it exists. This is
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001689 # important so no stale swarm job is executed.
1690 if not success and os.path.isfile(options.result):
1691 os.remove(options.result)
1692
1693
1694def CMDnoop(args):
1695 """Touches --result but does nothing else.
1696
1697 This mode is to help transition since some builders do not have all the test
1698 data files checked out. Touch result_file and exit silently.
1699 """
1700 parser = OptionParserIsolate(command='noop')
1701 options, _ = parser.parse_args(args)
1702 # In particular, do not call load_complete_state().
1703 open(options.result, 'a').close()
1704 return 0
1705
1706
1707def CMDmerge(args):
1708 """Reads and merges the data from the trace back into the original .isolate.
1709
1710 Ignores --outdir.
1711 """
1712 parser = OptionParserIsolate(command='merge', require_result=False)
1713 options, _ = parser.parse_args(args)
maruel@chromium.orgf2826a32012-10-16 18:26:17 +00001714 complete_state = load_complete_state(options)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001715 merge(complete_state)
1716 return 0
1717
1718
1719def CMDread(args):
1720 """Reads the trace file generated with command 'trace'.
1721
1722 Ignores --outdir.
1723 """
1724 parser = OptionParserIsolate(command='read', require_result=False)
1725 options, _ = parser.parse_args(args)
maruel@chromium.orgf2826a32012-10-16 18:26:17 +00001726 complete_state = load_complete_state(options)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001727 value = read_trace_as_isolate_dict(complete_state)
1728 pretty_print(value, sys.stdout)
1729 return 0
1730
1731
1732def CMDremap(args):
1733 """Creates a directory with all the dependencies mapped into it.
1734
1735 Useful to test manually why a test is failing. The target executable is not
1736 run.
1737 """
1738 parser = OptionParserIsolate(command='remap', require_result=False)
1739 options, _ = parser.parse_args(args)
maruel@chromium.orgf2826a32012-10-16 18:26:17 +00001740 complete_state = load_complete_state(options)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001741
1742 if not options.outdir:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001743 options.outdir = run_isolated.make_temp_dir(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001744 'isolate', complete_state.root_dir)
1745 else:
1746 if not os.path.isdir(options.outdir):
1747 os.makedirs(options.outdir)
1748 print 'Remapping into %s' % options.outdir
1749 if len(os.listdir(options.outdir)):
1750 raise ExecutionError('Can\'t remap in a non-empty directory')
1751 recreate_tree(
1752 outdir=options.outdir,
1753 indir=complete_state.root_dir,
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001754 infiles=complete_state.isolated.files,
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001755 action=run_isolated.HARDLINK,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001756 as_sha1=False)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001757 if complete_state.isolated.read_only:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001758 run_isolated.make_writable(options.outdir, True)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001759
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001760 if complete_state.isolated_filepath:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001761 complete_state.save_files()
1762 return 0
1763
1764
1765def CMDrun(args):
1766 """Runs the test executable in an isolated (temporary) directory.
1767
1768 All the dependencies are mapped into the temporary directory and the
1769 directory is cleaned up after the target exits. Warning: if -outdir is
1770 specified, it is deleted upon exit.
1771
1772 Argument processing stops at the first non-recognized argument and these
1773 arguments are appended to the command line of the target to run. For example,
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001774 use: isolate.py -r foo.isolated -- --gtest_filter=Foo.Bar
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001775 """
1776 parser = OptionParserIsolate(command='run', require_result=False)
1777 parser.enable_interspersed_args()
1778 options, args = parser.parse_args(args)
maruel@chromium.orgf2826a32012-10-16 18:26:17 +00001779 complete_state = load_complete_state(options)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001780 cmd = complete_state.isolated.command + args
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001781 if not cmd:
1782 raise ExecutionError('No command to run')
1783 cmd = trace_inputs.fix_python_path(cmd)
1784 try:
1785 if not options.outdir:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001786 options.outdir = run_isolated.make_temp_dir(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001787 'isolate', complete_state.root_dir)
1788 else:
1789 if not os.path.isdir(options.outdir):
1790 os.makedirs(options.outdir)
1791 recreate_tree(
1792 outdir=options.outdir,
1793 indir=complete_state.root_dir,
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001794 infiles=complete_state.isolated.files,
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001795 action=run_isolated.HARDLINK,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001796 as_sha1=False)
1797 cwd = os.path.normpath(
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001798 os.path.join(options.outdir, complete_state.isolated.relative_cwd))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001799 if not os.path.isdir(cwd):
1800 # It can happen when no files are mapped from the directory containing the
1801 # .isolate file. But the directory must exist to be the current working
1802 # directory.
1803 os.makedirs(cwd)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001804 if complete_state.isolated.read_only:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001805 run_isolated.make_writable(options.outdir, True)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001806 logging.info('Running %s, cwd=%s' % (cmd, cwd))
1807 result = subprocess.call(cmd, cwd=cwd)
1808 finally:
1809 if options.outdir:
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001810 run_isolated.rmtree(options.outdir)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001811
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001812 if complete_state.isolated_filepath:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001813 complete_state.save_files()
1814 return result
1815
1816
1817def CMDtrace(args):
1818 """Traces the target using trace_inputs.py.
1819
1820 It runs the executable without remapping it, and traces all the files it and
1821 its child processes access. Then the 'read' command can be used to generate an
1822 updated .isolate file out of it.
1823
1824 Argument processing stops at the first non-recognized argument and these
1825 arguments are appended to the command line of the target to run. For example,
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001826 use: isolate.py -r foo.isolated -- --gtest_filter=Foo.Bar
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001827 """
1828 parser = OptionParserIsolate(command='trace')
1829 parser.enable_interspersed_args()
1830 parser.add_option(
1831 '-m', '--merge', action='store_true',
1832 help='After tracing, merge the results back in the .isolate file')
1833 options, args = parser.parse_args(args)
maruel@chromium.orgf2826a32012-10-16 18:26:17 +00001834 complete_state = load_complete_state(options)
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001835 cmd = complete_state.isolated.command + args
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001836 if not cmd:
1837 raise ExecutionError('No command to run')
1838 cmd = trace_inputs.fix_python_path(cmd)
1839 cwd = os.path.normpath(os.path.join(
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001840 complete_state.root_dir, complete_state.isolated.relative_cwd))
maruel@chromium.org808f6af2012-10-11 14:08:08 +00001841 cmd[0] = os.path.normpath(os.path.join(cwd, cmd[0]))
1842 if not os.path.isfile(cmd[0]):
1843 raise ExecutionError(
1844 'Tracing failed for: %s\nIt doesn\'t exit' % ' '.join(cmd))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001845 logging.info('Running %s, cwd=%s' % (cmd, cwd))
1846 api = trace_inputs.get_api()
maruel@chromium.org4b57f692012-10-05 20:33:09 +00001847 logfile = complete_state.isolated_filepath + '.log'
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001848 api.clean_trace(logfile)
1849 try:
1850 with api.get_tracer(logfile) as tracer:
1851 result, _ = tracer.trace(
1852 cmd,
1853 cwd,
1854 'default',
1855 True)
1856 except trace_inputs.TracingFailure, e:
1857 raise ExecutionError('Tracing failed for: %s\n%s' % (' '.join(cmd), str(e)))
1858
1859 complete_state.save_files()
1860
1861 if options.merge:
1862 merge(complete_state)
1863
1864 return result
1865
1866
maruel@chromium.orgb253fb82012-10-16 21:44:48 +00001867def add_variable_option(parser):
1868 """Adds --result and --variable to an OptionParser."""
1869 parser.add_option(
1870 '-r', '--result',
1871 metavar='FILE',
1872 help='.isolated file to store the json manifest')
1873 default_variables = [('OS', get_flavor())]
1874 if sys.platform in ('win32', 'cygwin'):
1875 default_variables.append(('EXECUTABLE_SUFFIX', '.exe'))
1876 else:
1877 default_variables.append(('EXECUTABLE_SUFFIX', ''))
1878 parser.add_option(
1879 '-V', '--variable',
1880 nargs=2,
1881 action='append',
1882 default=default_variables,
1883 dest='variables',
1884 metavar='FOO BAR',
1885 help='Variables to process in the .isolate file, default: %default. '
1886 'Variables are persistent accross calls, they are saved inside '
1887 '<results>.state')
1888
1889
1890def parse_variable_option(parser, options, require_result):
1891 """Processes --result and --variable."""
1892 if options.result:
1893 options.result = os.path.abspath(options.result.replace('/', os.path.sep))
1894 if require_result and not options.result:
1895 parser.error('--result is required.')
1896 if options.result and not options.result.endswith('.isolated'):
1897 parser.error('--result value must end with \'.isolated\'')
1898 options.variables = dict(options.variables)
1899
1900
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001901class OptionParserIsolate(trace_inputs.OptionParserWithNiceDescription):
maruel@chromium.orgb253fb82012-10-16 21:44:48 +00001902 """Adds automatic --isolate, --result, --out and --variable handling."""
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001903 def __init__(self, require_result=True, **kwargs):
maruel@chromium.org55276902012-10-05 20:56:19 +00001904 trace_inputs.OptionParserWithNiceDescription.__init__(
1905 self,
1906 verbose=int(os.environ.get('ISOLATE_DEBUG', 0)),
1907 **kwargs)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001908 group = optparse.OptionGroup(self, "Common options")
1909 group.add_option(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001910 '-i', '--isolate',
1911 metavar='FILE',
1912 help='.isolate file to load the dependency data from')
maruel@chromium.orgb253fb82012-10-16 21:44:48 +00001913 add_variable_option(group)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001914 group.add_option(
1915 '-o', '--outdir', metavar='DIR',
1916 help='Directory used to recreate the tree or store the hash table. '
1917 'If the environment variable ISOLATE_HASH_TABLE_DIR exists, it '
1918 'will be used. Otherwise, for run and remap, uses a /tmp '
1919 'subdirectory. For the other modes, defaults to the directory '
1920 'containing --result')
1921 self.add_option_group(group)
1922 self.require_result = require_result
1923
1924 def parse_args(self, *args, **kwargs):
1925 """Makes sure the paths make sense.
1926
1927 On Windows, / and \ are often mixed together in a path.
1928 """
1929 options, args = trace_inputs.OptionParserWithNiceDescription.parse_args(
1930 self, *args, **kwargs)
1931 if not self.allow_interspersed_args and args:
1932 self.error('Unsupported argument: %s' % args)
1933
maruel@chromium.orgb253fb82012-10-16 21:44:48 +00001934 parse_variable_option(self, options, self.require_result)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001935
1936 if options.isolate:
1937 options.isolate = trace_inputs.get_native_path_case(
1938 os.path.abspath(
1939 options.isolate.replace('/', os.path.sep)))
1940
1941 if options.outdir and not re.match(r'^https?://.+$', options.outdir):
1942 options.outdir = os.path.abspath(
1943 options.outdir.replace('/', os.path.sep))
1944
1945 return options, args
1946
1947
1948### Glue code to make all the commands works magically.
1949
1950
1951CMDhelp = trace_inputs.CMDhelp
1952
1953
1954def main(argv):
1955 try:
1956 return trace_inputs.main_impl(argv)
1957 except (
1958 ExecutionError,
maruel@chromium.orgb8375c22012-10-05 18:10:01 +00001959 run_isolated.MappingError,
1960 run_isolated.ConfigError) as e:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001961 sys.stderr.write('\nError: ')
1962 sys.stderr.write(str(e))
1963 sys.stderr.write('\n')
1964 return 1
1965
1966
1967if __name__ == '__main__':
1968 sys.exit(main(sys.argv[1:]))