1# Copyright 2013 The Chromium Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5"""Contains common helpers for GN action()s.""" 6 7import collections 8import contextlib 9import filecmp 10import fnmatch 11import json 12import os 13import pipes 14import re 15import shutil 16import stat 17import subprocess 18import sys 19import tempfile 20import zipfile 21 22# Any new non-system import must be added to: 23# //build/config/android/internal_rules.gni 24 25# Some clients do not add //build/android/gyp to PYTHONPATH. 26import build.android.gyp.util.md5_check as md5_check # pylint: disable=relative-import 27import build.gn_helpers as gn_helpers 28 29# Definition copied from pylib/constants/__init__.py to avoid adding 30# a dependency on pylib. 31DIR_SOURCE_ROOT = os.environ.get('CHECKOUT_SOURCE_ROOT', 32 os.path.abspath(os.path.join(os.path.dirname(__file__), 33 os.pardir, os.pardir, os.pardir, os.pardir))) 34 35HERMETIC_TIMESTAMP = (2001, 1, 1, 0, 0, 0) 36_HERMETIC_FILE_ATTR = (0o644 << 16) 37 38 39@contextlib.contextmanager 40def TempDir(): 41 dirname = tempfile.mkdtemp() 42 try: 43 yield dirname 44 finally: 45 shutil.rmtree(dirname) 46 47 48def MakeDirectory(dir_path): 49 try: 50 os.makedirs(dir_path) 51 except OSError: 52 pass 53 54 55def DeleteDirectory(dir_path): 56 if os.path.exists(dir_path): 57 shutil.rmtree(dir_path) 58 59 60def Touch(path, fail_if_missing=False): 61 if fail_if_missing and not os.path.exists(path): 62 raise Exception(path + ' doesn\'t exist.') 63 64 MakeDirectory(os.path.dirname(path)) 65 with open(path, 'a'): 66 os.utime(path, None) 67 68 69def FindInDirectory(directory, filename_filter): 70 files = [] 71 for root, _dirnames, filenames in os.walk(directory): 72 matched_files = fnmatch.filter(filenames, filename_filter) 73 files.extend((os.path.join(root, f) for f in matched_files)) 74 return files 75 76 77def ReadBuildVars(path): 78 """Parses a build_vars.txt into a dict.""" 79 with open(path) as f: 80 return dict(l.rstrip().split('=', 1) for l in f) 81 82 83def ParseGnList(gn_string): 84 """Converts a command-line parameter into a list. 85 86 If the input starts with a '[' it is assumed to be a GN-formatted list and 87 it will be parsed accordingly. When empty an empty list will be returned. 88 Otherwise, the parameter will be treated as a single raw string (not 89 GN-formatted in that it's not assumed to have literal quotes that must be 90 removed) and a list will be returned containing that string. 91 92 The common use for this behavior is in the Android build where things can 93 take lists of @FileArg references that are expanded via ExpandFileArgs. 94 """ 95 if gn_string.startswith('['): 96 parser = gn_helpers.GNValueParser(gn_string) 97 return parser.ParseList() 98 if len(gn_string): 99 return [ gn_string ] 100 return [] 101 102 103def CheckOptions(options, parser, required=None): 104 if not required: 105 return 106 for option_name in required: 107 if getattr(options, option_name) is None: 108 parser.error('--%s is required' % option_name.replace('_', '-')) 109 110 111def WriteJson(obj, path, only_if_changed=False): 112 old_dump = None 113 if os.path.exists(path): 114 with open(path, 'r') as oldfile: 115 old_dump = oldfile.read() 116 117 new_dump = json.dumps(obj, sort_keys=True, indent=2, separators=(',', ': ')) 118 119 if not only_if_changed or old_dump != new_dump: 120 with open(path, 'w') as outfile: 121 outfile.write(new_dump) 122 123 124@contextlib.contextmanager 125def AtomicOutput(path, only_if_changed=True): 126 """Helper to prevent half-written outputs. 127 128 Args: 129 path: Path to the final output file, which will be written atomically. 130 only_if_changed: If True (the default), do not touch the filesystem 131 if the content has not changed. 132 Returns: 133 A python context manager that yelds a NamedTemporaryFile instance 134 that must be used by clients to write the data to. On exit, the 135 manager will try to replace the final output file with the 136 temporary one if necessary. The temporary file is always destroyed 137 on exit. 138 Example: 139 with build_utils.AtomicOutput(output_path) as tmp_file: 140 subprocess.check_call(['prog', '--output', tmp_file.name]) 141 """ 142 # Create in same directory to ensure same filesystem when moving. 143 with tempfile.NamedTemporaryFile(suffix=os.path.basename(path), 144 dir=os.path.dirname(path), 145 delete=False) as f: 146 try: 147 yield f 148 149 # file should be closed before comparison/move. 150 f.close() 151 if not (only_if_changed and os.path.exists(path) and 152 filecmp.cmp(f.name, path)): 153 shutil.move(f.name, path) 154 finally: 155 if os.path.exists(f.name): 156 os.unlink(f.name) 157 158 159class CalledProcessError(Exception): 160 """This exception is raised when the process run by CheckOutput 161 exits with a non-zero exit code.""" 162 163 def __init__(self, cwd, args, output): 164 super(CalledProcessError, self).__init__() 165 self.cwd = cwd 166 self.args = args 167 self.output = output 168 169 def __str__(self): 170 # A user should be able to simply copy and paste the command that failed 171 # into their shell. 172 copyable_command = '( cd {}; {} )'.format(os.path.abspath(self.cwd), 173 ' '.join(map(pipes.quote, self.args))) 174 return 'Command failed: {}\n{}'.format(copyable_command, self.output) 175 176 177# This can be used in most cases like subprocess.check_output(). The output, 178# particularly when the command fails, better highlights the command's failure. 179# If the command fails, raises a build_utils.CalledProcessError. 180def CheckOutput(args, cwd=None, env=None, 181 print_stdout=False, print_stderr=True, 182 stdout_filter=None, 183 stderr_filter=None, 184 fail_func=lambda returncode, stderr: returncode != 0): 185 if not cwd: 186 cwd = os.getcwd() 187 188 child = subprocess.Popen(args, 189 stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, env=env) 190 stdout, stderr = child.communicate() 191 192 if stdout_filter is not None: 193 stdout = stdout_filter(stdout) 194 195 if stderr_filter is not None: 196 stderr = stderr_filter(stderr) 197 198 if fail_func(child.returncode, stderr): 199 raise CalledProcessError(cwd, args, stdout + stderr) 200 201 if print_stdout: 202 sys.stdout.write(stdout) 203 if print_stderr: 204 sys.stderr.write(stderr) 205 206 return stdout 207 208 209def GetModifiedTime(path): 210 # For a symlink, the modified time should be the greater of the link's 211 # modified time and the modified time of the target. 212 return max(os.lstat(path).st_mtime, os.stat(path).st_mtime) 213 214 215def IsTimeStale(output, inputs): 216 if not os.path.exists(output): 217 return True 218 219 output_time = GetModifiedTime(output) 220 for i in inputs: 221 if GetModifiedTime(i) > output_time: 222 return True 223 return False 224 225 226def _CheckZipPath(name): 227 if os.path.normpath(name) != name: 228 raise Exception('Non-canonical zip path: %s' % name) 229 if os.path.isabs(name): 230 raise Exception('Absolute zip path: %s' % name) 231 232 233def _IsSymlink(zip_file, name): 234 zi = zip_file.getinfo(name) 235 236 # The two high-order bytes of ZipInfo.external_attr represent 237 # UNIX permissions and file type bits. 238 return stat.S_ISLNK(zi.external_attr >> 16) 239 240 241def ExtractAll(zip_path, path=None, no_clobber=True, pattern=None, 242 predicate=None): 243 if path is None: 244 path = os.getcwd() 245 elif not os.path.exists(path): 246 MakeDirectory(path) 247 248 if not zipfile.is_zipfile(zip_path): 249 raise Exception('Invalid zip file: %s' % zip_path) 250 251 extracted = [] 252 with zipfile.ZipFile(zip_path) as z: 253 for name in z.namelist(): 254 if name.endswith('/'): 255 MakeDirectory(os.path.join(path, name)) 256 continue 257 if pattern is not None: 258 if not fnmatch.fnmatch(name, pattern): 259 continue 260 if predicate and not predicate(name): 261 continue 262 _CheckZipPath(name) 263 if no_clobber: 264 output_path = os.path.join(path, name) 265 if os.path.exists(output_path): 266 raise Exception( 267 'Path already exists from zip: %s %s %s' 268 % (zip_path, name, output_path)) 269 if _IsSymlink(z, name): 270 dest = os.path.join(path, name) 271 MakeDirectory(os.path.dirname(dest)) 272 os.symlink(z.read(name), dest) 273 extracted.append(dest) 274 else: 275 z.extract(name, path) 276 extracted.append(os.path.join(path, name)) 277 278 return extracted 279 280 281def AddToZipHermetic(zip_file, zip_path, src_path=None, data=None, 282 compress=None): 283 """Adds a file to the given ZipFile with a hard-coded modified time. 284 285 Args: 286 zip_file: ZipFile instance to add the file to. 287 zip_path: Destination path within the zip file. 288 src_path: Path of the source file. Mutually exclusive with |data|. 289 data: File data as a string. 290 compress: Whether to enable compression. Default is taken from ZipFile 291 constructor. 292 """ 293 assert (src_path is None) != (data is None), ( 294 '|src_path| and |data| are mutually exclusive.') 295 _CheckZipPath(zip_path) 296 zipinfo = zipfile.ZipInfo(filename=zip_path, date_time=HERMETIC_TIMESTAMP) 297 zipinfo.external_attr = _HERMETIC_FILE_ATTR 298 299 if src_path and os.path.islink(src_path): 300 zipinfo.filename = zip_path 301 zipinfo.external_attr |= stat.S_IFLNK << 16 # mark as a symlink 302 zip_file.writestr(zipinfo, os.readlink(src_path)) 303 return 304 305 if src_path: 306 with open(src_path) as f: 307 data = f.read() 308 309 # zipfile will deflate even when it makes the file bigger. To avoid 310 # growing files, disable compression at an arbitrary cut off point. 311 if len(data) < 16: 312 compress = False 313 314 # None converts to ZIP_STORED, when passed explicitly rather than the 315 # default passed to the ZipFile constructor. 316 compress_type = zip_file.compression 317 if compress is not None: 318 compress_type = zipfile.ZIP_DEFLATED if compress else zipfile.ZIP_STORED 319 zip_file.writestr(zipinfo, data, compress_type) 320 321 322def DoZip(inputs, output, base_dir=None, compress_fn=None): 323 """Creates a zip file from a list of files. 324 325 Args: 326 inputs: A list of paths to zip, or a list of (zip_path, fs_path) tuples. 327 output: Destination .zip file. 328 base_dir: Prefix to strip from inputs. 329 compress_fn: Applied to each input to determine whether or not to compress. 330 By default, items will be |zipfile.ZIP_STORED|. 331 """ 332 input_tuples = [] 333 for tup in inputs: 334 if isinstance(tup, str): 335 tup = (os.path.relpath(tup, base_dir), tup) 336 input_tuples.append(tup) 337 338 # Sort by zip path to ensure stable zip ordering. 339 input_tuples.sort(key=lambda tup: tup[0]) 340 with zipfile.ZipFile(output, 'w') as outfile: 341 for zip_path, fs_path in input_tuples: 342 compress = compress_fn(zip_path) if compress_fn else None 343 AddToZipHermetic(outfile, zip_path, src_path=fs_path, compress=compress) 344 345 346def ZipDir(output, base_dir, compress_fn=None): 347 """Creates a zip file from a directory.""" 348 inputs = [] 349 for root, _, files in os.walk(base_dir): 350 for f in files: 351 inputs.append(os.path.join(root, f)) 352 353 with AtomicOutput(output) as f: 354 DoZip(inputs, f, base_dir, compress_fn=compress_fn) 355 356 357def MatchesGlob(path, filters): 358 """Returns whether the given path matches any of the given glob patterns.""" 359 return filters and any(fnmatch.fnmatch(path, f) for f in filters) 360 361 362def MergeZips(output, input_zips, path_transform=None): 363 """Combines all files from |input_zips| into |output|. 364 365 Args: 366 output: Path or ZipFile instance to add files to. 367 input_zips: Iterable of paths to zip files to merge. 368 path_transform: Called for each entry path. Returns a new path, or None to 369 skip the file. 370 """ 371 path_transform = path_transform or (lambda p: p) 372 added_names = set() 373 374 output_is_already_open = not isinstance(output, str) 375 if output_is_already_open: 376 assert isinstance(output, zipfile.ZipFile) 377 out_zip = output 378 else: 379 out_zip = zipfile.ZipFile(output, 'w') 380 381 try: 382 for in_file in input_zips: 383 with zipfile.ZipFile(in_file, 'r') as in_zip: 384 # ijar creates zips with null CRCs. 385 in_zip._expected_crc = None 386 for info in in_zip.infolist(): 387 # Ignore directories. 388 if info.filename[-1] == '/': 389 continue 390 dst_name = path_transform(info.filename) 391 if not dst_name: 392 continue 393 already_added = dst_name in added_names 394 if not already_added: 395 AddToZipHermetic(out_zip, dst_name, data=in_zip.read(info), 396 compress=info.compress_type != zipfile.ZIP_STORED) 397 added_names.add(dst_name) 398 finally: 399 if not output_is_already_open: 400 out_zip.close() 401 402 403def GetSortedTransitiveDependencies(top, deps_func): 404 """Gets the list of all transitive dependencies in sorted order. 405 406 There should be no cycles in the dependency graph (crashes if cycles exist). 407 408 Args: 409 top: A list of the top level nodes 410 deps_func: A function that takes a node and returns a list of its direct 411 dependencies. 412 Returns: 413 A list of all transitive dependencies of nodes in top, in order (a node will 414 appear in the list at a higher index than all of its dependencies). 415 """ 416 # Find all deps depth-first, maintaining original order in the case of ties. 417 deps_map = collections.OrderedDict() 418 def discover(nodes): 419 for node in nodes: 420 if node in deps_map: 421 continue 422 deps = deps_func(node) 423 discover(deps) 424 deps_map[node] = deps 425 426 discover(top) 427 return deps_map.keys() 428 429 430def _ComputePythonDependencies(): 431 """Gets the paths of imported non-system python modules. 432 433 A path is assumed to be a "system" import if it is outside of chromium's 434 src/. The paths will be relative to the current directory. 435 """ 436 _ForceLazyModulesToLoad() 437 module_paths = (m.__file__ for m in sys.modules.values() 438 if m is not None and hasattr(m, '__file__')) 439 abs_module_paths = map(os.path.abspath, module_paths) 440 441 assert os.path.isabs(DIR_SOURCE_ROOT) 442 non_system_module_paths = [ 443 p for p in abs_module_paths if p.startswith(DIR_SOURCE_ROOT)] 444 def ConvertPycToPy(s): 445 if s.endswith('.pyc'): 446 return s[:-1] 447 return s 448 449 non_system_module_paths = map(ConvertPycToPy, non_system_module_paths) 450 non_system_module_paths = map(os.path.relpath, non_system_module_paths) 451 return sorted(set(non_system_module_paths)) 452 453 454def _ForceLazyModulesToLoad(): 455 """Forces any lazily imported modules to fully load themselves. 456 457 Inspecting the modules' __file__ attribute causes lazily imported modules 458 (e.g. from email) to get fully imported and update sys.modules. Iterate 459 over the values until sys.modules stabilizes so that no modules are missed. 460 """ 461 while True: 462 num_modules_before = len(sys.modules.keys()) 463 for m in sys.modules.values(): 464 if m is not None and hasattr(m, '__file__'): 465 _ = m.__file__ 466 num_modules_after = len(sys.modules.keys()) 467 if num_modules_before == num_modules_after: 468 break 469 470 471def AddDepfileOption(parser): 472 # TODO(agrieve): Get rid of this once we've moved to argparse. 473 if hasattr(parser, 'add_option'): 474 func = parser.add_option 475 else: 476 func = parser.add_argument 477 func('--depfile', 478 help='Path to depfile (refer to `gn help depfile`)') 479 480 481def WriteDepfile(depfile_path, first_gn_output, inputs=None, add_pydeps=True): 482 assert depfile_path != first_gn_output # http://crbug.com/646165 483 inputs = inputs or [] 484 if add_pydeps: 485 inputs = _ComputePythonDependencies() + inputs 486 MakeDirectory(os.path.dirname(depfile_path)) 487 # Ninja does not support multiple outputs in depfiles. 488 with open(depfile_path, 'w') as depfile: 489 depfile.write(first_gn_output.replace(' ', '\\ ')) 490 depfile.write(': ') 491 depfile.write(' '.join(i.replace(' ', '\\ ') for i in inputs)) 492 depfile.write('\n') 493 494 495def ExpandFileArgs(args): 496 """Replaces file-arg placeholders in args. 497 498 These placeholders have the form: 499 @FileArg(filename:key1:key2:...:keyn) 500 501 The value of such a placeholder is calculated by reading 'filename' as json. 502 And then extracting the value at [key1][key2]...[keyn]. 503 504 Note: This intentionally does not return the list of files that appear in such 505 placeholders. An action that uses file-args *must* know the paths of those 506 files prior to the parsing of the arguments (typically by explicitly listing 507 them in the action's inputs in build files). 508 """ 509 new_args = list(args) 510 file_jsons = dict() 511 r = re.compile('@FileArg\((.*?)\)') 512 for i, arg in enumerate(args): 513 match = r.search(arg) 514 if not match: 515 continue 516 517 if match.end() != len(arg): 518 raise Exception('Unexpected characters after FileArg: ' + arg) 519 520 lookup_path = match.group(1).split(':') 521 file_path = lookup_path[0] 522 if not file_path in file_jsons: 523 with open(file_path) as f: 524 file_jsons[file_path] = json.load(f) 525 526 expansion = file_jsons[file_path] 527 for k in lookup_path[1:]: 528 expansion = expansion[k] 529 530 # This should match ParseGNList. The output is either a GN-formatted list 531 # or a literal (with no quotes). 532 if isinstance(expansion, list): 533 new_args[i] = arg[:match.start()] + gn_helpers.ToGNString(expansion) 534 else: 535 new_args[i] = arg[:match.start()] + str(expansion) 536 537 return new_args 538 539 540def ReadSourcesList(sources_list_file_name): 541 """Reads a GN-written file containing list of file names and returns a list. 542 543 Note that this function should not be used to parse response files. 544 """ 545 with open(sources_list_file_name) as f: 546 return [file_name.strip() for file_name in f] 547 548 549def CallAndWriteDepfileIfStale(function, options, record_path=None, 550 input_paths=None, input_strings=None, 551 output_paths=None, force=False, 552 pass_changes=False, depfile_deps=None, 553 add_pydeps=True): 554 """Wraps md5_check.CallAndRecordIfStale() and writes a depfile if applicable. 555 556 Depfiles are automatically added to output_paths when present in the |options| 557 argument. They are then created after |function| is called. 558 559 By default, only python dependencies are added to the depfile. If there are 560 other input paths that are not captured by GN deps, then they should be listed 561 in depfile_deps. It's important to write paths to the depfile that are already 562 captured by GN deps since GN args can cause GN deps to change, and such 563 changes are not immediately reflected in depfiles (http://crbug.com/589311). 564 """ 565 if not output_paths: 566 raise Exception('At least one output_path must be specified.') 567 input_paths = list(input_paths or []) 568 input_strings = list(input_strings or []) 569 output_paths = list(output_paths or []) 570 571 python_deps = None 572 if hasattr(options, 'depfile') and options.depfile: 573 python_deps = _ComputePythonDependencies() 574 input_paths += python_deps 575 output_paths += [options.depfile] 576 577 def on_stale_md5(changes): 578 args = (changes,) if pass_changes else () 579 function(*args) 580 if python_deps is not None: 581 all_depfile_deps = list(python_deps) if add_pydeps else [] 582 if depfile_deps: 583 all_depfile_deps.extend(depfile_deps) 584 WriteDepfile(options.depfile, output_paths[0], all_depfile_deps, 585 add_pydeps=False) 586 587 md5_check.CallAndRecordIfStale( 588 on_stale_md5, 589 record_path=record_path, 590 input_paths=input_paths, 591 input_strings=input_strings, 592 output_paths=output_paths, 593 force=force, 594 pass_changes=True) 595