Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(60)

Side by Side Diff: tools/isolate/isolate.py

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

Powered by Google App Engine
This is Rietveld 408576698