• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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