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