| OLD | NEW |
| (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:])) | |
| OLD | NEW |