chromium/components/cronet/tools/update_api.py

#!/usr/bin/env python3
# Copyright 2016 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""update_api.py - Update committed Cronet API."""

import argparse
import filecmp
import hashlib
import os
import re
import shutil
import sys
import tempfile


REPOSITORY_ROOT = os.path.abspath(
    os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, os.pardir))

sys.path.insert(0, os.path.join(REPOSITORY_ROOT, 'build/android/gyp'))
from util import build_utils  # pylint: disable=wrong-import-position

# Filename of dump of current API.
API_FILENAME = os.path.abspath(os.path.join(
    os.path.dirname(__file__), '..', 'android', 'api.txt'))
# Filename of file containing API version number.
API_VERSION_FILENAME = os.path.abspath(
    os.path.join(os.path.dirname(__file__), '..', 'android', 'api_version.txt'))

# Regular expression that catches the beginning of lines that declare classes.
# The first group returned by a match is the class name.
CLASS_RE = re.compile(r'.*(class|interface) ([^ ]*) .*\{')

# Regular expression that matches a string containing an unnamed class name,
# for example 'Foo$1'.
UNNAMED_CLASS_RE = re.compile(r'.*\$[0-9]')

# javap still prints internal (package private, nested...) classes even though
# -protected is passed so they need to be filtered out.
INTERNAL_CLASS_RE = re.compile(
    r'^(?!public ((final|abstract) )?(class|interface)).*')

JAR_PATH = os.path.join(build_utils.JAVA_HOME, 'bin', 'jar')
JAVAP_PATH = os.path.join(build_utils.JAVA_HOME, 'bin', 'javap')


def _split_by_class(javap_output):
  """Splits the combined javap output to separate classes.

   * Removes unneeded comments like "Compiled from ...".
   * Sorts the declarations inside the class.

  Returns an array where each element represents a class.
  """
  current_class_lines = []
  all_classes = []
  for line in javap_output:
    # Lines starting with Compiled from are just comments and not part of the
    # api.
    if line.startswith('Compiled from'):
      continue
    current_class_lines.append(line)
    if line == '}':
      # sort only the lines between the {}.
      current_class_lines = ([current_class_lines[0]] +
                             sorted(current_class_lines[1:-1]) +
                             [current_class_lines[-1]])
      all_classes.append(current_class_lines)
      current_class_lines = []
  return all_classes


def _generate_api(api_jar, output_filename):
  """Dumps the API in |api_jar| into |outpuf_filename|."""
  # Extract API class files from api_jar.
  with tempfile.TemporaryDirectory() as temp_dir:
    api_jar_path = os.path.abspath(api_jar)
    jar_cmd = [os.path.relpath(JAR_PATH, temp_dir), 'xf', api_jar_path]
    build_utils.CheckOutput(jar_cmd, cwd=temp_dir)

    shutil.rmtree(os.path.join(temp_dir, 'META-INF'), ignore_errors=True)

    # Collect paths of all API class files
    api_class_files = []
    for root, _, filenames in os.walk(temp_dir):
      api_class_files += [os.path.join(root, f) for f in filenames]
    api_class_files.sort()

    output_lines = ['DO NOT EDIT THIS FILE, USE update_api.py TO UPDATE IT\n']
    javap_cmd = [JAVAP_PATH, '-protected'] + api_class_files
    javap_output = build_utils.CheckOutput(javap_cmd)

  all_classes = _split_by_class(javap_output.splitlines())
  for class_lines in all_classes:
    first_line = class_lines[0]
    # Skip classes we do not care about.
    if UNNAMED_CLASS_RE.match(first_line) or INTERNAL_CLASS_RE.match(
        first_line):
      continue
    output_lines.extend(class_lines)

  output_string = '\n'.join(output_lines) + '\n'
  md5_hash = hashlib.md5()
  md5_hash.update(output_string.encode('utf-8'))
  output_string += 'Stamp: %s\n' % md5_hash.hexdigest()

  with open(output_filename, 'w') as output_file:
    output_file.write(output_string)


def check_up_to_date(api_jar):
  """Returns True if API_FILENAME matches the API exposed by |api_jar|."""
  with tempfile.NamedTemporaryFile() as temp:
    _generate_api(api_jar, temp.name)
    return filecmp.cmp(API_FILENAME, temp.name)


def _check_api_update(old_api, new_api):
  """Enforce that lines are only added when updating API."""
  new_hash = hashlib.md5()
  old_hash = hashlib.md5()
  seen_stamp = False
  with open(old_api, 'r') as old_api_file, open(new_api, 'r') as new_api_file:
    for old_line in old_api_file:
      while True:
        new_line = new_api_file.readline()
        if seen_stamp:
          print('ERROR: Stamp is not the last line.')
          return False
        if new_line.startswith('Stamp: ') and old_line.startswith('Stamp: '):
          if old_line != 'Stamp: %s\n' % old_hash.hexdigest():
            print('ERROR: Prior api.txt not stamped by update_api.py')
            return False
          if new_line != 'Stamp: %s\n' % new_hash.hexdigest():
            print('ERROR: New api.txt not stamped by update_api.py')
            return False
          seen_stamp = True
          break
        new_hash.update(new_line.encode('utf8'))
        if new_line == old_line:
          break
        if not new_line:
          if old_line.startswith('Stamp: '):
            print('ERROR: New api.txt not stamped by update_api.py')
          else:
            print('ERROR: This API was modified or removed:')
            print('           ' + old_line)
            print('       Cronet API methods and classes cannot be modified.')
          return False
      old_hash.update(old_line.encode('utf8'))
  if not seen_stamp:
    print('ERROR: api.txt not stamped by update_api.py.')
    return False
  return True


def main(args):
  parser = argparse.ArgumentParser(description='Update Cronet api.txt.')
  parser.add_argument('--api_jar',
                      help='Path to API jar (i.e. cronet_api.jar)',
                      required=True,
                      metavar='path/to/cronet_api.jar')
  parser.add_argument('--ignore_check_errors',
                      help='If true, ignore errors from verification checks',
                      required=False,
                      default=False,
                      action='store_true')
  opts = parser.parse_args(args)

  if check_up_to_date(opts.api_jar):
    return True

  with tempfile.NamedTemporaryFile() as temp:
    _generate_api(opts.api_jar, temp.name)
    if _check_api_update(API_FILENAME, temp.name):
      # Update API version number to new version number
      with open(API_VERSION_FILENAME, 'r+') as f:
        version = int(f.read())
        f.seek(0)
        f.write(str(version + 1))
      # Update API file to new API
      shutil.copyfile(temp.name, API_FILENAME)
      return True
  return False


if __name__ == '__main__':
  sys.exit(0 if main(sys.argv[1:]) else -1)