#!/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 open(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()