#!/usr/bin/env python3
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# Script which checks Java API compatibility between two revisions of the
# Java client.
#
# Originally sourced from Apache Kudu, which was based on the
# compatibility checker from the Apache HBase project, but ported to
# Python for better readability.
import logging
import os
import re
import shutil
import subprocess
import sys
import urllib.request
import argparse
# Various relative paths
REPO_DIR = os.getcwd()
def check_output(*popenargs, **kwargs):
""" Run command with arguments and return its output as a string. """
return subprocess.check_output(*popenargs, **kwargs, encoding='utf-8')
def get_repo_dir():
""" Return the path to the top of the repo. """
dirname, _ = os.path.split(os.path.abspath(__file__))
return os.path.join(dirname, "../..")
def get_scratch_dir():
""" Return the path to the scratch dir that we build within. """
scratch_dir = os.path.join(get_repo_dir(), "target", "compat-check")
if not os.path.exists(scratch_dir):
os.makedirs(scratch_dir)
return scratch_dir
def get_java_acc_dir():
""" Return the path where we check out the Java API Compliance Checker. """
return os.path.join(get_repo_dir(), "target", "java-acc")
def clean_scratch_dir(scratch_dir):
""" Clean up and re-create the scratch directory. """
if os.path.exists(scratch_dir):
logging.info("Removing scratch dir %s...", scratch_dir)
shutil.rmtree(scratch_dir)
logging.info("Creating empty scratch dir %s...", scratch_dir)
os.makedirs(scratch_dir)
def checkout_java_tree(rev, path):
""" Check out the Java source tree for the given revision into
the given path. """
logging.info("Checking out %s in %s", rev, path)
os.makedirs(path)
# Extract java source
subprocess.check_call(["bash", '-o', 'pipefail', "-c",
("git archive --format=tar %s | " +
"tar -C \"%s\" -xf -") % (rev, path)],
cwd=get_repo_dir())
def get_git_hash(revname):
""" Convert 'revname' to its SHA-1 hash. """
return check_output(["git", "rev-parse", revname],
cwd=get_repo_dir()).strip()
def get_repo_name():
"""Get the name of the repo based on the git remote."""
remotes = check_output(["git", "remote", "-v"],
cwd=get_repo_dir()).strip().split("\n")
# Example output:
# origin https://github.com/apache/hadoop.git (fetch)
# origin https://github.com/apache/hadoop.git (push)
remote_url = remotes[0].split("\t")[1].split(" ")[0]
remote = remote_url.split("/")[-1]
if remote.endswith(".git"):
remote = remote[:-4]
return remote
def build_tree(java_path):
""" Run the Java build within 'path'. """
logging.info("Building in %s...", java_path)
subprocess.check_call(["mvn", "-DskipTests", "-Dmaven.javadoc.skip=true",
"package"],
cwd=java_path)
def checkout_java_acc(force):
"""
Check out the Java API Compliance Checker. If 'force' is true, will
re-download even if the directory exists.
"""
acc_dir = get_java_acc_dir()
if os.path.exists(acc_dir):
logging.info("Java ACC is already downloaded.")
if not force:
return
logging.info("Forcing re-download.")
shutil.rmtree(acc_dir)
logging.info("Downloading Java ACC...")
url = "https://github.com/lvc/japi-compliance-checker/archive/1.8.tar.gz"
scratch_dir = get_scratch_dir()
path = os.path.join(scratch_dir, os.path.basename(url))
jacc = urllib.request.urlopen(url)
with open(path, 'wb') as w:
w.write(jacc.read())
subprocess.check_call(["tar", "xzf", path],
cwd=scratch_dir)
shutil.move(os.path.join(scratch_dir, "japi-compliance-checker-1.8"),
os.path.join(acc_dir))
def find_jars(path):
""" Return a list of jars within 'path' to be checked for compatibility. """
all_jars = set(check_output(["find", path, "-name", "*.jar"]).splitlines())
return [j for j in all_jars if (
"-tests" not in j and
"-sources" not in j and
"-with-dependencies" not in j)]
def write_xml_file(path, version, jars):
"""Write the XML manifest file for JACC."""
with open(path, "wt") as f:
f.write("" + version + "\n")
f.write("")
for j in jars:
f.write(j + "\n")
f.write("")
def run_java_acc(src_name, src_jars, dst_name, dst_jars, annotations):
""" Run the compliance checker to compare 'src' and 'dst'. """
logging.info("Will check compatibility between original jars:\n\t%s\n" +
"and new jars:\n\t%s",
"\n\t".join(src_jars),
"\n\t".join(dst_jars))
java_acc_path = os.path.join(get_java_acc_dir(), "japi-compliance-checker.pl")
src_xml_path = os.path.join(get_scratch_dir(), "src.xml")
dst_xml_path = os.path.join(get_scratch_dir(), "dst.xml")
write_xml_file(src_xml_path, src_name, src_jars)
write_xml_file(dst_xml_path, dst_name, dst_jars)
out_path = os.path.join(get_scratch_dir(), "report.html")
args = ["perl", java_acc_path,
"-l", get_repo_name(),
"-d1", src_xml_path,
"-d2", dst_xml_path,
"-report-path", out_path]
if annotations is not None:
annotations_path = os.path.join(get_scratch_dir(), "annotations.txt")
with file(annotations_path, "w") as f:
for ann in annotations:
print(ann, file=f)
args += ["-annotations-list", annotations_path]
subprocess.check_call(args)
def filter_jars(jars, include_filters, exclude_filters):
"""Filter the list of JARs based on include and exclude filters."""
filtered = []
# Apply include filters
for j in jars:
found = False
basename = os.path.basename(j)
for f in include_filters:
if f.match(basename):
found = True
break
if found:
filtered += [j]
else:
logging.debug("Ignoring JAR %s", j)
# Apply exclude filters
exclude_filtered = []
for j in filtered:
basename = os.path.basename(j)
found = False
for f in exclude_filters:
if f.match(basename):
found = True
break
if found:
logging.debug("Ignoring JAR %s", j)
else:
exclude_filtered += [j]
return exclude_filtered
def main():
"""Main function."""
logging.basicConfig(level=logging.INFO)
parser = argparse.ArgumentParser(
description="Run Java API Compliance Checker.")
parser.add_argument("-f", "--force-download",
action="store_true",
help="Download dependencies (i.e. Java JAVA_ACC) " +
"even if they are already present")
parser.add_argument("-i", "--include-file",
action="append",
dest="include_files",
help="Regex filter for JAR files to be included. " +
"Applied before the exclude filters. " +
"Can be specified multiple times.")
parser.add_argument("-e", "--exclude-file",
action="append",
dest="exclude_files",
help="Regex filter for JAR files to be excluded. " +
"Applied after the include filters. " +
"Can be specified multiple times.")
parser.add_argument("-a", "--annotation",
action="append",
dest="annotations",
help="Fully-qualified Java annotation. " +
"Java ACC will only check compatibility of " +
"annotated classes. Can be specified multiple times.")
parser.add_argument("--skip-clean",
action="store_true",
help="Skip cleaning the scratch directory.")
parser.add_argument("--skip-build",
action="store_true",
help="Skip building the projects.")
parser.add_argument("src_rev", nargs=1, type=str, help="Source revision.")
parser.add_argument("dst_rev", nargs="?", type=str, default="HEAD",
help="Destination revision. " +
"If not specified, will use HEAD.")
if len(sys.argv) == 1:
parser.print_help()
sys.exit(1)
args = parser.parse_args()
src_rev, dst_rev = args.src_rev[0], args.dst_rev
logging.info("Source revision: %s", src_rev)
logging.info("Destination revision: %s", dst_rev)
# Construct the JAR regex patterns for filtering.
include_filters = []
if args.include_files is not None:
for f in args.include_files:
logging.info("Applying JAR filename include filter: %s", f)
include_filters += [re.compile(f)]
else:
include_filters = [re.compile(".*")]
exclude_filters = []
if args.exclude_files is not None:
for f in args.exclude_files:
logging.info("Applying JAR filename exclude filter: %s", f)
exclude_filters += [re.compile(f)]
# Construct the annotation list
annotations = args.annotations
if annotations is not None:
logging.info("Filtering classes using %d annotation(s):", len(annotations))
for a in annotations:
logging.info("\t%s", a)
# Download deps.
checkout_java_acc(args.force_download)
# Set up the build.
scratch_dir = get_scratch_dir()
src_dir = os.path.join(scratch_dir, "src")
dst_dir = os.path.join(scratch_dir, "dst")
if args.skip_clean:
logging.info("Skipping cleaning the scratch directory")
else:
clean_scratch_dir(scratch_dir)
# Check out the src and dst source trees.
checkout_java_tree(get_git_hash(src_rev), src_dir)
checkout_java_tree(get_git_hash(dst_rev), dst_dir)
# Run the build in each.
if args.skip_build:
logging.info("Skipping the build")
else:
build_tree(src_dir)
build_tree(dst_dir)
# Find the JARs.
src_jars = find_jars(src_dir)
dst_jars = find_jars(dst_dir)
# Filter the JARs.
src_jars = filter_jars(src_jars, include_filters, exclude_filters)
dst_jars = filter_jars(dst_jars, include_filters, exclude_filters)
if len(src_jars) == 0 or len(dst_jars) == 0:
logging.error("No JARs found! Are your filters too strong?")
sys.exit(1)
run_java_acc(src_rev, src_jars,
dst_rev, dst_jars, annotations)
if __name__ == "__main__":
main()