xref: /aosp_15_r20/external/libchrome/build/android/gyp/util/build_utils.py (revision 635a864187cb8b6c713ff48b7e790a6b21769273)
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