blob: 85802e443f8cb06a8588d378342e7b2c3a25628e [file] [log] [blame]
#!/usr/bin/env python
# Copyright 2018 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""A script used to manage Google Maven dependencies for Chromium.
This script creates a temporary build directory, where it will, for each
of the dependencies specified in `build.gradle`, take care of the following:
- Download the library
- Generate a README.chromium file
- Download the LICENSE
- Generate a GN target in BUILD.gn
- Generate .info files for AAR libraries
- Generate CIPD yaml files describing the packages
- Generate a 'deps' entry in DEPS.
It will then compare the build directory with your current workspace, and
print the differences (i.e. new/updated/deleted packages names).
The --update-all option can be used to update the current workspace with any
relevant changes, and will print commands to create the relevant CIPD packages
as well.
The --reset-workspace option can be used to reset your workspace to its HEAD
state if you're not satisfied with the results of --update-all. Note that
this preserves local modifications to your build.gradle file.
"""
import argparse
import collections
import contextlib
import fnmatch
import logging
import tempfile
import os
import re
import shutil
import subprocess
import zipfile
# Assume this script is stored under tools/android/roll/android_deps/
_CHROMIUM_SRC = os.path.abspath(
os.path.join(__file__, '..', '..', '..', '..', '..'))
# Location of the android_deps directory from a root checkout.
_ANDROID_DEPS_SUBDIR = 'third_party/android_deps'
# Path to BUILD.gn file under android_deps/
_ANDROID_DEPS_BUILD_GN = _ANDROID_DEPS_SUBDIR + '/BUILD.gn'
# Path to custom licenses under android_deps/
_ANDROID_DEPS_LICENSE_SUBDIR = _ANDROID_DEPS_SUBDIR + '/licenses'
# Path to additional_readme_paths.json
_ANDROID_DEPS_ADDITIONAL_README_PATHS = (
_ANDROID_DEPS_SUBDIR + '/additional_readme_paths.json')
# Location of the android_deps libs directory from a root checkout.
_ANDROID_DEPS_LIBS_SUBDIR = _ANDROID_DEPS_SUBDIR + '/libs'
# Location of the buildSrc directory used implement our gradle task.
_GRADLE_BUILDSRC_PATH = 'tools/android/roll/android_deps/buildSrc'
# The list of git-controlled files that are checked or updated by this tool.
_UPDATED_GIT_FILES = [
'DEPS',
_ANDROID_DEPS_BUILD_GN,
_ANDROID_DEPS_ADDITIONAL_README_PATHS,
]
# If this file exists in an aar file then it is appended to LICENSE
_THIRD_PARTY_LICENSE_FILENAME = 'third_party_licenses.txt'
@contextlib.contextmanager
def BuildDir(dirname=None):
"""Helper function used to manage a build directory.
Args:
dirname: Optional build directory path. If not provided, a temporary
directory will be created and cleaned up on exit.
Returns:
A python context manager modelling a directory path. The manager
removes the directory if necessary on exit.
"""
delete = False
if not dirname:
dirname = tempfile.mkdtemp()
delete = True
try:
yield dirname
finally:
if delete:
shutil.rmtree(dirname)
def RaiseCommandException(args, returncode, output, error):
"""Raise an exception whose message describing a command failure.
Args:
args: shell command-line (as passed to subprocess.call())
returncode: status code.
error: standard error output.
Raises:
a new Exception.
"""
message = 'Command failed with status %d: %s\n' % (returncode, args)
if output:
message += 'Output:-------------------------------------------\n%s\n' \
'--------------------------------------------------\n' % output
if error:
message += 'Error message: -----------------------------------\n%s\n' \
'--------------------------------------------------\n' % error
raise Exception(message)
def RunCommand(args):
"""Run a new shell command.
This function runs without printing anything.
Args:
args: A string or a list of strings for the shell command.
Raises:
On failure, raise an Exception that contains the command's arguments,
return status, and standard output + error merged in a single message.
"""
logging.debug('Run %s', args)
p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
pout, _ = p.communicate()
if p.returncode != 0:
RaiseCommandException(args, p.returncode, None, pout)
def RunCommandAndGetOutput(args):
"""Run a new shell command. Return its output. Exception on failure.
This function runs without printing anything.
Args:
args: A string or a list of strings for the shell command.
Returns:
The command's output.
Raises:
On failure, raise an Exception that contains the command's arguments,
return status, and standard output, and standard error as separate
messages.
"""
logging.debug('Run %s', args)
p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
pout, perr = p.communicate()
if p.returncode != 0:
RaiseCommandException(args, p.returncode, pout, perr)
return pout
def MakeDirectory(dir_path):
"""Make directory |dir_path| recursively if necessary."""
if not os.path.isdir(dir_path):
logging.debug('mkdir [%s]', dir_path)
os.makedirs(dir_path)
def DeleteDirectory(dir_path):
"""Recursively delete a directory if it exists."""
if os.path.exists(dir_path):
logging.debug('rmdir [%s]', dir_path)
shutil.rmtree(dir_path)
def CopyFileOrDirectory(src_path, dst_path):
"""Copy file or directory |src_path| into |dst_path| exactly."""
logging.debug('copy [%s -> %s]', src_path, dst_path)
MakeDirectory(os.path.dirname(dst_path))
if os.path.isdir(src_path):
# Copy directory recursively.
DeleteDirectory(dst_path)
shutil.copytree(src_path, dst_path)
else:
shutil.copy(src_path, dst_path)
def ReadFile(file_path):
"""Read a file, return its content."""
with open(file_path) as f:
return f.read()
def ReadFileAsLines(file_path):
"""Read a file as a series of lines."""
with open(file_path) as f:
return f.readlines()
def WriteFile(file_path, file_data):
"""Write a file."""
MakeDirectory(os.path.dirname(file_path))
with open(file_path, 'w') as f:
f.write(file_data)
def ReadGitHeadFile(git_root, src_path):
"""Read the HEAD version of a git-controlled file.
Args:
git_root: Git root directory.
src_path: Relative path of source file under |git_root|.
Returns:
file data.
"""
git_args = ['git', '-C', git_root, 'show', 'HEAD:%s' % src_path]
return RunCommandAndGetOutput(git_args)
def FindInDirectory(directory, filename_filter):
"""Find all files in a directory that matches a given filename filter."""
files = []
for root, _dirnames, filenames in os.walk(directory):
matched_files = fnmatch.filter(filenames, filename_filter)
files.extend((os.path.join(root, f) for f in matched_files))
return files
# Named tuple describing a CIPD package.
# - path: Path to cipd.yaml file.
# - name: cipd package name.
# - tag: cipd tag.
CipdPackageInfo = collections.namedtuple(
'CipdPackageInfo', ['path', 'name', 'tag'])
# Regular expressions used to extract useful info from cipd.yaml files
# generated by Gradle. See BuildConfigGenerator.groovy:makeCipdYaml()
_RE_CIPD_CREATE = re.compile('cipd create --pkg-def cipd.yaml -tag (\S*)')
_RE_CIPD_PACKAGE = re.compile('package: (\S*)')
def GetCipdPackageInfo(cipd_yaml_path):
"""Returns the CIPD package name corresponding to a given cipd.yaml file.
Args:
cipd_yaml_path: Path of input cipd.yaml file.
Returns:
A (package_name, package_tag) tuple.
Raises:
Exception if the file could not be read.
"""
package_name = None
package_tag = None
for line in ReadFileAsLines(cipd_yaml_path):
m = _RE_CIPD_PACKAGE.match(line)
if m:
package_name = m.group(1)
m = _RE_CIPD_CREATE.search(line)
if m:
package_tag = m.group(1)
if not package_name or not package_tag:
raise Exception('Invalid cipd.yaml format: ' + cipd_yaml_path)
return (package_name, package_tag)
def ParseDeps(root_dir, libs_dir):
"""Parse an android_deps/libs and retrieve package information.
Args:
root_dir: Path to a root Chromium or build directory.
Returns:
A directory mapping package names to tuples of
(cipd_yaml_file, package_name, package_tag), where |cipd_yaml_file|
is the path to the cipd.yaml file, related to |libs_dir|,
and |package_name| and |package_tag| are the extracted from it.
"""
result = {}
libs_dir = os.path.abspath(os.path.join(root_dir, libs_dir))
for cipd_file in FindInDirectory(libs_dir, 'cipd.yaml'):
pkg_name, pkg_tag = GetCipdPackageInfo(cipd_file)
cipd_path = os.path.dirname(cipd_file)
cipd_path = cipd_path[len(root_dir) + 1:]
result[pkg_name] = CipdPackageInfo(cipd_path, pkg_name, pkg_tag)
return result
def PrintPackageList(packages, list_name):
"""Print a list of packages to standard output.
Args:
packages: list of package names.
list_name: a simple word describing the package list (e.g. 'new')
"""
print ' %d %s packages:' % (len(packages), list_name)
print '\n'.join([' - %s' % p for p in packages])
def GenerateCipdUploadCommand(cipd_pkg_info):
"""Generate a shell command to create a cipd package.
Args:
cipd_pkg_info: A CipdPackageInfo instance.
Returns:
A string holding a shell command to upload the package through cipd.
"""
pkg_path, pkg_name, pkg_tag = cipd_pkg_info
return ('(cd "{0}"; '
# Need to skip create step if an instance already exists with the
# same package name and version tag (thus the use of ||).
'cipd describe "{1}" -version "{2}" || '
'cipd create --pkg-def cipd.yaml -tag "{2}")').format(
pkg_path, pkg_name, pkg_tag)
def main():
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--build-dir',
help='Path to build directory (default is temporary directory).')
parser.add_argument('--chromium-dir',
help='Path to Chromium source tree (auto-detect by default).')
parser.add_argument('--gradle-wrapper',
help='Path to custom gradle wrapper (auto-detect by default).')
parser.add_argument(
'--build-gradle',
help='Path to build.gradle relative to src/.',
default='tools/android/roll/android_deps/build.gradle')
parser.add_argument(
'--git-dir', help='Path to git subdir from chromium-dir.', default='.')
parser.add_argument(
'--ignore-licenses',
help='Ignores licenses for these deps.',
action='store_true')
parser.add_argument('--update-all', action='store_true',
help='Update current checkout in case of build.gradle changes.'
'This will also print a list of commands to upload new and updated '
'packages through cipd, if needed.')
parser.add_argument('--reset-workspace', action='store_true',
help='Reset your Chromium workspace to its HEAD state, but preserves '
'build.gradle changes. Use this to undo previous --update-all changes.')
parser.add_argument(
'--debug', action='store_true', help='Enable debug logging')
args = parser.parse_args()
# Determine Chromium source tree.
chromium_src = args.chromium_dir
if not chromium_src:
# Assume this script is stored under tools/android/roll/android_deps/
chromium_src = _CHROMIUM_SRC
chromium_src = os.path.abspath(chromium_src)
abs_git_dir = os.path.normpath(os.path.join(chromium_src, args.git_dir))
if not os.path.isdir(chromium_src):
raise Exception('Not a directory: ' + chromium_src)
if not os.path.isdir(abs_git_dir):
raise Exception('Not a directory: ' + abs_git_dir)
# The list of files and dirs that are copied to the build directory by this
# script. Should not include _UPDATED_GIT_FILES.
copied_paths = {
args.build_gradle:
args.build_gradle,
_GRADLE_BUILDSRC_PATH:
os.path.join(os.path.dirname(args.build_gradle), "buildSrc"),
}
if not args.ignore_licenses:
copied_paths[_ANDROID_DEPS_LICENSE_SUBDIR] = _ANDROID_DEPS_LICENSE_SUBDIR
logging.basicConfig(format='%(message)s')
logger = logging.getLogger()
if args.debug:
logger.setLevel('DEBUG')
# Handle --reset-workspace here.
if args.reset_workspace:
print '# Removing .cipd directory.'
cipd_dir = os.path.join(chromium_src, '..', '.cipd')
if os.path.isdir(cipd_dir):
RunCommand(['rm', '-rf', cipd_dir])
print '# Saving build.gradle content'
build_gradle_path = os.path.join(chromium_src, args.build_gradle)
build_gradle = ReadFile(build_gradle_path)
print '# Resetting and re-syncing workspace. (may take a while).'
RunCommand(['gclient', 'sync', '--reset', '--nohooks', '-r', 'src@HEAD'])
print '# Restoring build.gradle.'
WriteFile(build_gradle_path, build_gradle)
return
missing_files = []
for src_path in copied_paths.keys():
if not os.path.exists(os.path.join(chromium_src, src_path)):
missing_files.append(src_path)
for src_path in _UPDATED_GIT_FILES:
if not os.path.exists(os.path.join(abs_git_dir, src_path)):
missing_files.append(src_path)
if missing_files:
raise Exception('Missing files from %s: %s' % (chromium_src, missing_files))
# Path to the gradlew script used to run build.gradle.
gradle_wrapper_path = args.gradle_wrapper or os.path.join(
chromium_src, 'third_party', 'gradle_wrapper', 'gradlew')
# Path to the aar.py script used to generate .info files.
aar_py = os.path.join(chromium_src, 'build', 'android', 'gyp', 'aar.py')
if not os.path.exists(aar_py):
raise Exception('Missing required python script: ' + aar_py)
with BuildDir(args.build_dir) as build_dir:
print '# Setup build directory.'
logging.debug('Using build directory: ' + build_dir)
for git_file in _UPDATED_GIT_FILES:
git_data = ReadGitHeadFile(abs_git_dir, git_file)
WriteFile(os.path.join(build_dir, args.git_dir, git_file), git_data)
for path, dest in copied_paths.iteritems():
CopyFileOrDirectory(
os.path.join(chromium_src, path), os.path.join(build_dir, dest))
print '# Use Gradle to download packages and edit/create relevant files.'
# This gradle command generates the new DEPS and BUILD.gn files, it can also
# handle special cases. Edit BuildConfigGenerator.groovy#addSpecialTreatment
# for such cases.
gradle_cmd = [
gradle_wrapper_path,
'-b',
os.path.join(build_dir, args.build_gradle),
'setupRepository',
'--stacktrace',
]
if args.debug:
gradle_cmd.append('--debug')
RunCommand(gradle_cmd)
libs_dir = os.path.join(build_dir, args.git_dir, _ANDROID_DEPS_LIBS_SUBDIR)
print '# Reformat %s.' % _ANDROID_DEPS_BUILD_GN
gn_args = [
'gn', 'format',
os.path.join(build_dir, args.git_dir, _ANDROID_DEPS_BUILD_GN)
]
RunCommand(gn_args)
print '# Generate Android .aar info and third-party license files.'
aar_files = FindInDirectory(libs_dir, '*.aar')
for aar_file in aar_files:
aar_dirname = os.path.dirname(aar_file)
aar_info_name = os.path.basename(aar_dirname) + '.info'
aar_info_path = os.path.join(aar_dirname, aar_info_name)
if not os.path.exists(aar_info_path):
logging.info('- %s' % aar_info_name)
RunCommand([aar_py, 'list', aar_file, '--output', aar_info_path])
if not args.ignore_licenses:
with zipfile.ZipFile(aar_file) as z:
if _THIRD_PARTY_LICENSE_FILENAME in z.namelist():
license_path = os.path.join(aar_dirname, 'LICENSE')
# Make sure to append as we don't want to lose the existing license.
with open(license_path, 'a') as f:
f.write(z.read(_THIRD_PARTY_LICENSE_FILENAME))
print '# Compare CIPD packages.'
existing_packages = ParseDeps(abs_git_dir, _ANDROID_DEPS_LIBS_SUBDIR)
build_packages = ParseDeps(
build_dir, os.path.join(args.git_dir, _ANDROID_DEPS_LIBS_SUBDIR))
deleted_packages = []
updated_packages = []
for pkg in sorted(existing_packages):
if pkg not in build_packages:
logging.info('- %s' % pkg)
deleted_packages.append(pkg)
else:
existing_info = existing_packages[pkg]
build_info = build_packages[pkg]
if existing_info.tag != build_info.tag:
logging.info('U %s (%s -> %s)', pkg, existing_info.tag,
build_info.tag)
updated_packages.append(pkg)
else:
logging.info('= %s', pkg) # Unchanged.
new_packages = sorted(set(build_packages) - set(existing_packages))
for pkg in new_packages:
logging.info('+ %s', pkg)
# Generate CIPD package upload commands.
cipd_packages_to_upload = sorted(updated_packages + new_packages)
if cipd_packages_to_upload:
# TODO(wnwen): Check CIPD to make sure that no other package with the
# same tag exists, print error otherwise.
cipd_commands = [GenerateCipdUploadCommand(build_packages[pkg])
for pkg in cipd_packages_to_upload]
# Print them to the log for debugging.
logging.info('CIPD update commands\n%s\n',
'\n'.join(cipd_commands))
if not args.update_all:
if not (deleted_packages or new_packages or updated_packages):
print 'No changes detected. All good.'
else:
print 'Changes detected:'
if new_packages:
PrintPackageList(new_packages, 'new')
if updated_packages:
PrintPackageList(updated_packages, 'updated')
if deleted_packages:
PrintPackageList(deleted_packages, 'deleted')
print ''
print 'Run with --update-all to update your checkout!'
return
# Copy updated DEPS and BUILD.gn to build directory.
update_cmds = []
for updated_file in _UPDATED_GIT_FILES:
CopyFileOrDirectory(
os.path.join(build_dir, args.git_dir, updated_file),
os.path.join(abs_git_dir, updated_file))
# Delete obsolete or updated package directories.
for pkg in deleted_packages + updated_packages:
pkg_path = os.path.join(abs_git_dir, existing_packages[pkg].path)
DeleteDirectory(pkg_path)
# Copy new and updated packages from build directory.
for pkg in new_packages + updated_packages:
pkg_path = build_packages[pkg].path
dst_pkg_path = os.path.join(chromium_src, pkg_path)
src_pkg_path = os.path.join(build_dir, pkg_path)
CopyFileOrDirectory(src_pkg_path, dst_pkg_path)
if cipd_packages_to_upload:
print 'Run the following to upload new and updated CIPD packages:'
print 'Note: Duplicate instances with the same tag will break the build.'
print '------------------------ cut here -----------------------------'
print '\n'.join(cipd_commands)
print '------------------------ cut here -----------------------------'
if __name__ == "__main__":
main()