xref: /aosp_15_r20/external/cronet/build/android/gyp/util/build_utils.py (revision 6777b5387eb2ff775bb5750e3f5d96f37fb7352b)
1# Copyright 2013 The Chromium Authors
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 atexit
8import collections
9import contextlib
10import filecmp
11import fnmatch
12import json
13import logging
14import os
15import re
16import shlex
17import shutil
18import stat
19import subprocess
20import sys
21import tempfile
22import textwrap
23import zipfile
24
25sys.path.append(os.path.join(os.path.dirname(__file__),
26                             os.pardir, os.pardir, os.pardir))
27import gn_helpers
28
29# Use relative paths to improved hermetic property of build scripts.
30DIR_SOURCE_ROOT = os.path.relpath(
31    os.environ.get(
32        'CHECKOUT_SOURCE_ROOT',
33        os.path.join(
34            os.path.dirname(__file__), os.pardir, os.pardir, os.pardir,
35            os.pardir)))
36JAVA_HOME = os.path.join(DIR_SOURCE_ROOT, 'third_party', 'jdk', 'current')
37JAVA_PATH = os.path.join(JAVA_HOME, 'bin', 'java')
38JAVA_PATH_FOR_INPUTS = f'{JAVA_PATH}.chromium'
39JAVAC_PATH = os.path.join(JAVA_HOME, 'bin', 'javac')
40JAVAP_PATH = os.path.join(JAVA_HOME, 'bin', 'javap')
41KOTLIN_HOME = os.path.join(DIR_SOURCE_ROOT, 'third_party', 'kotlinc', 'current')
42KOTLINC_PATH = os.path.join(KOTLIN_HOME, 'bin', 'kotlinc')
43
44
45def JavaCmd(xmx='1G'):
46  ret = [JAVA_PATH]
47  # Limit heap to avoid Java not GC'ing when it should, and causing
48  # bots to OOM when many java commands are runnig at the same time
49  # https://crbug.com/1098333
50  ret += ['-Xmx' + xmx]
51  # JDK17 bug.
52  # See: https://chromium-review.googlesource.com/c/chromium/src/+/4705883/3
53  # https://github.com/iBotPeaches/Apktool/issues/3174
54  ret += ['-Djdk.util.zip.disableZip64ExtraFieldValidation=true']
55  return ret
56
57
58@contextlib.contextmanager
59def TempDir(**kwargs):
60  dirname = tempfile.mkdtemp(**kwargs)
61  try:
62    yield dirname
63  finally:
64    shutil.rmtree(dirname)
65
66
67def MakeDirectory(dir_path):
68  try:
69    os.makedirs(dir_path)
70  except OSError:
71    pass
72
73
74def DeleteDirectory(dir_path):
75  if os.path.exists(dir_path):
76    shutil.rmtree(dir_path)
77
78
79def Touch(path, fail_if_missing=False):
80  if fail_if_missing and not os.path.exists(path):
81    raise Exception(path + ' doesn\'t exist.')
82
83  MakeDirectory(os.path.dirname(path))
84  with open(path, 'a'):
85    os.utime(path, None)
86
87
88def FindInDirectory(directory, filename_filter='*'):
89  files = []
90  for root, _dirnames, filenames in os.walk(directory):
91    matched_files = fnmatch.filter(filenames, filename_filter)
92    files.extend((os.path.join(root, f) for f in matched_files))
93  return files
94
95
96def CheckOptions(options, parser, required=None):
97  if not required:
98    return
99  for option_name in required:
100    if getattr(options, option_name) is None:
101      parser.error('--%s is required' % option_name.replace('_', '-'))
102
103
104def WriteJson(obj, path, only_if_changed=False):
105  old_dump = None
106  if os.path.exists(path):
107    with open(path, 'r') as oldfile:
108      old_dump = oldfile.read()
109
110  new_dump = json.dumps(obj, sort_keys=True, indent=2, separators=(',', ': '))
111
112  if not only_if_changed or old_dump != new_dump:
113    with open(path, 'w') as outfile:
114      outfile.write(new_dump)
115
116
117@contextlib.contextmanager
118def _AtomicOutput(path, only_if_changed=True, mode='w+b'):
119  # Create in same directory to ensure same filesystem when moving.
120  dirname = os.path.dirname(path)
121  if not os.path.exists(dirname):
122    MakeDirectory(dirname)
123  with tempfile.NamedTemporaryFile(
124      mode, suffix=os.path.basename(path), dir=dirname, delete=False) as f:
125    try:
126      yield f
127
128      # file should be closed before comparison/move.
129      f.close()
130      if not (only_if_changed and os.path.exists(path) and
131              filecmp.cmp(f.name, path)):
132        shutil.move(f.name, path)
133    finally:
134      if os.path.exists(f.name):
135        os.unlink(f.name)
136
137
138class CalledProcessError(Exception):
139  """This exception is raised when the process run by CheckOutput
140  exits with a non-zero exit code."""
141
142  def __init__(self, cwd, args, output):
143    super().__init__()
144    self.cwd = cwd
145    self.args = args
146    self.output = output
147
148  def __str__(self):
149    # A user should be able to simply copy and paste the command that failed
150    # into their shell (unless it is more than 200 chars).
151    # User can set PRINT_FULL_COMMAND=1 to always print the full command.
152    print_full = os.environ.get('PRINT_FULL_COMMAND', '0') != '0'
153    full_cmd = shlex.join(self.args)
154    short_cmd = textwrap.shorten(full_cmd, width=200)
155    printed_cmd = full_cmd if print_full else short_cmd
156    copyable_command = '( cd {}; {} )'.format(os.path.abspath(self.cwd),
157                                              printed_cmd)
158    return 'Command failed: {}\n{}'.format(copyable_command, self.output)
159
160
161def FilterLines(output, filter_string):
162  """Output filter from build_utils.CheckOutput.
163
164  Args:
165    output: Executable output as from build_utils.CheckOutput.
166    filter_string: An RE string that will filter (remove) matching
167        lines from |output|.
168
169  Returns:
170    The filtered output, as a single string.
171  """
172  re_filter = re.compile(filter_string)
173  return '\n'.join(
174      line for line in output.split('\n') if not re_filter.search(line))
175
176
177def FilterReflectiveAccessJavaWarnings(output):
178  """Filters out warnings about illegal reflective access operation.
179
180  These warnings were introduced in Java 9, and generally mean that dependencies
181  need to be updated.
182  """
183  #  WARNING: An illegal reflective access operation has occurred
184  #  WARNING: Illegal reflective access by ...
185  #  WARNING: Please consider reporting this to the maintainers of ...
186  #  WARNING: Use --illegal-access=warn to enable warnings of further ...
187  #  WARNING: All illegal access operations will be denied in a future release
188  return FilterLines(
189      output, r'WARNING: ('
190      'An illegal reflective|'
191      'Illegal reflective access|'
192      'Please consider reporting this to|'
193      'Use --illegal-access=warn|'
194      'All illegal access operations)')
195
196
197# This filter applies globally to all CheckOutput calls. We use this to prevent
198# messages from failing the build, without actually removing them.
199def _FailureFilter(output):
200  # This is a message that comes from the JDK which can't be disabled, which as
201  # far as we can tell, doesn't cause any real issues. It only happens
202  # occasionally on the bots. See crbug.com/1441023 for details.
203  jdk_filter = (r'.*warning.*Cannot use file \S+ because'
204                r' it is locked by another process')
205  output = FilterLines(output, jdk_filter)
206  return output
207
208
209# This can be used in most cases like subprocess.check_output(). The output,
210# particularly when the command fails, better highlights the command's failure.
211# If the command fails, raises a build_utils.CalledProcessError.
212def CheckOutput(args,
213                cwd=None,
214                env=None,
215                print_stdout=False,
216                print_stderr=True,
217                stdout_filter=None,
218                stderr_filter=None,
219                fail_on_output=True,
220                fail_func=lambda returncode, stderr: returncode != 0):
221  if not cwd:
222    cwd = os.getcwd()
223
224  logging.info('CheckOutput: %s', ' '.join(args))
225  child = subprocess.Popen(args,
226      stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, env=env)
227
228  stdout, stderr = child.communicate()
229
230  # For Python3 only:
231  if isinstance(stdout, bytes) and sys.version_info >= (3, ):
232    stdout = stdout.decode('utf-8')
233    stderr = stderr.decode('utf-8')
234
235  if stdout_filter is not None:
236    stdout = stdout_filter(stdout)
237
238  if stderr_filter is not None:
239    stderr = stderr_filter(stderr)
240
241  if fail_func and fail_func(child.returncode, stderr):
242    raise CalledProcessError(cwd, args, stdout + stderr)
243
244  if print_stdout:
245    sys.stdout.write(stdout)
246  if print_stderr:
247    sys.stderr.write(stderr)
248
249  has_stdout = print_stdout and stdout
250  has_stderr = print_stderr and stderr
251  if has_stdout or has_stderr:
252    if has_stdout and has_stderr:
253      stream_name = 'stdout and stderr'
254    elif has_stdout:
255      stream_name = 'stdout'
256    else:
257      stream_name = 'stderr'
258
259    if fail_on_output and _FailureFilter(stdout + stderr):
260      MSG = """
261Command failed because it wrote to {}.
262You can often set treat_warnings_as_errors=false to not treat output as \
263failure (useful when developing locally).
264"""
265      raise CalledProcessError(cwd, args, MSG.format(stream_name))
266
267    short_cmd = textwrap.shorten(shlex.join(args), width=200)
268    sys.stderr.write(
269        f'\nThe above {stream_name} output was from: {short_cmd}\n')
270
271  return stdout
272
273
274def GetModifiedTime(path):
275  # For a symlink, the modified time should be the greater of the link's
276  # modified time and the modified time of the target.
277  return max(os.lstat(path).st_mtime, os.stat(path).st_mtime)
278
279
280def IsTimeStale(output, inputs):
281  if not os.path.exists(output):
282    return True
283
284  output_time = GetModifiedTime(output)
285  for i in inputs:
286    if GetModifiedTime(i) > output_time:
287      return True
288  return False
289
290
291def _CheckZipPath(name):
292  if os.path.normpath(name) != name:
293    raise Exception('Non-canonical zip path: %s' % name)
294  if os.path.isabs(name):
295    raise Exception('Absolute zip path: %s' % name)
296
297
298def _IsSymlink(zip_file, name):
299  zi = zip_file.getinfo(name)
300
301  # The two high-order bytes of ZipInfo.external_attr represent
302  # UNIX permissions and file type bits.
303  return stat.S_ISLNK(zi.external_attr >> 16)
304
305
306def ExtractAll(zip_path, path=None, no_clobber=True, pattern=None,
307               predicate=None):
308  if path is None:
309    path = os.getcwd()
310  elif not os.path.exists(path):
311    MakeDirectory(path)
312
313  if not zipfile.is_zipfile(zip_path):
314    raise Exception('Invalid zip file: %s' % zip_path)
315
316  extracted = []
317  with zipfile.ZipFile(zip_path) as z:
318    for name in z.namelist():
319      if name.endswith('/'):
320        MakeDirectory(os.path.join(path, name))
321        continue
322      if pattern is not None:
323        if not fnmatch.fnmatch(name, pattern):
324          continue
325      if predicate and not predicate(name):
326        continue
327      _CheckZipPath(name)
328      if no_clobber:
329        output_path = os.path.join(path, name)
330        if os.path.exists(output_path):
331          raise Exception(
332              'Path already exists from zip: %s %s %s'
333              % (zip_path, name, output_path))
334      if _IsSymlink(z, name):
335        dest = os.path.join(path, name)
336        MakeDirectory(os.path.dirname(dest))
337        os.symlink(z.read(name), dest)
338        extracted.append(dest)
339      else:
340        z.extract(name, path)
341        extracted.append(os.path.join(path, name))
342
343  return extracted
344
345
346def MatchesGlob(path, filters):
347  """Returns whether the given path matches any of the given glob patterns."""
348  return filters and any(fnmatch.fnmatch(path, f) for f in filters)
349
350
351def MergeZips(output, input_zips, path_transform=None, compress=None):
352  """Combines all files from |input_zips| into |output|.
353
354  Args:
355    output: Path, fileobj, or ZipFile instance to add files to.
356    input_zips: Iterable of paths to zip files to merge.
357    path_transform: Called for each entry path. Returns a new path, or None to
358        skip the file.
359    compress: Overrides compression setting from origin zip entries.
360  """
361  path_transform = path_transform or (lambda p: p)
362
363  out_zip = output
364  if not isinstance(output, zipfile.ZipFile):
365    out_zip = zipfile.ZipFile(output, 'w')
366
367  # Include paths in the existing zip here to avoid adding duplicate files.
368  added_names = set(out_zip.namelist())
369
370  try:
371    for in_file in input_zips:
372      with zipfile.ZipFile(in_file, 'r') as in_zip:
373        for info in in_zip.infolist():
374          # Ignore directories.
375          if info.filename[-1] == '/':
376            continue
377          dst_name = path_transform(info.filename)
378          if not dst_name:
379            continue
380          already_added = dst_name in added_names
381          if not already_added:
382            if compress is not None:
383              compress_entry = compress
384            else:
385              compress_entry = info.compress_type != zipfile.ZIP_STORED
386            AddToZipHermetic(
387                out_zip,
388                dst_name,
389                data=in_zip.read(info),
390                compress=compress_entry)
391            added_names.add(dst_name)
392  finally:
393    if output is not out_zip:
394      out_zip.close()
395
396
397def GetSortedTransitiveDependencies(top, deps_func):
398  """Gets the list of all transitive dependencies in sorted order.
399
400  There should be no cycles in the dependency graph (crashes if cycles exist).
401
402  Args:
403    top: A list of the top level nodes
404    deps_func: A function that takes a node and returns a list of its direct
405        dependencies.
406  Returns:
407    A list of all transitive dependencies of nodes in top, in order (a node will
408    appear in the list at a higher index than all of its dependencies).
409  """
410  # Find all deps depth-first, maintaining original order in the case of ties.
411  deps_map = collections.OrderedDict()
412  def discover(nodes):
413    for node in nodes:
414      if node in deps_map:
415        continue
416      deps = deps_func(node)
417      discover(deps)
418      deps_map[node] = deps
419
420  discover(top)
421  return list(deps_map)
422
423
424def InitLogging(enabling_env):
425  logging.basicConfig(
426      level=logging.DEBUG if os.environ.get(enabling_env) else logging.WARNING,
427      format='%(levelname).1s %(process)d %(relativeCreated)6d %(message)s')
428  script_name = os.path.basename(sys.argv[0])
429  logging.info('Started (%s)', script_name)
430
431  my_pid = os.getpid()
432
433  def log_exit():
434    # Do not log for fork'ed processes.
435    if os.getpid() == my_pid:
436      logging.info("Job's done (%s)", script_name)
437
438  atexit.register(log_exit)
439
440
441def ExpandFileArgs(args):
442  """Replaces file-arg placeholders in args.
443
444  These placeholders have the form:
445    @FileArg(filename:key1:key2:...:keyn)
446
447  The value of such a placeholder is calculated by reading 'filename' as json.
448  And then extracting the value at [key1][key2]...[keyn]. If a key has a '[]'
449  suffix the (intermediate) value will be interpreted as a single item list and
450  the single item will be returned or used for further traversal.
451
452  Note: This intentionally does not return the list of files that appear in such
453  placeholders. An action that uses file-args *must* know the paths of those
454  files prior to the parsing of the arguments (typically by explicitly listing
455  them in the action's inputs in build files).
456  """
457  new_args = list(args)
458  file_jsons = dict()
459  r = re.compile(r'@FileArg\((.*?)\)')
460  for i, arg in enumerate(args):
461    match = r.search(arg)
462    if not match:
463      continue
464
465    def get_key(key):
466      if key.endswith('[]'):
467        return key[:-2], True
468      return key, False
469
470    lookup_path = match.group(1).split(':')
471    file_path, _ = get_key(lookup_path[0])
472    if not file_path in file_jsons:
473      with open(file_path) as f:
474        file_jsons[file_path] = json.load(f)
475
476    expansion = file_jsons
477    for k in lookup_path:
478      k, flatten = get_key(k)
479      expansion = expansion[k]
480      if flatten:
481        if not isinstance(expansion, list) or not len(expansion) == 1:
482          raise Exception('Expected single item list but got %s' % expansion)
483        expansion = expansion[0]
484
485    # This should match parse_gn_list. The output is either a GN-formatted list
486    # or a literal (with no quotes).
487    if isinstance(expansion, list):
488      new_args[i] = (arg[:match.start()] + gn_helpers.ToGNString(expansion) +
489                     arg[match.end():])
490    else:
491      new_args[i] = arg[:match.start()] + str(expansion) + arg[match.end():]
492
493  return new_args
494
495
496def ReadSourcesList(sources_list_file_name):
497  """Reads a GN-written file containing list of file names and returns a list.
498
499  Note that this function should not be used to parse response files.
500  """
501  with open(sources_list_file_name) as f:
502    return [file_name.strip() for file_name in f]
503