checkcompatibility.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. #!/usr/bin/env python
  2. #
  3. # Licensed to the Apache Software Foundation (ASF) under one
  4. # or more contributor license agreements. See the NOTICE file
  5. # distributed with this work for additional information
  6. # regarding copyright ownership. The ASF licenses this file
  7. # to you under the Apache License, Version 2.0 (the
  8. # "License"); you may not use this file except in compliance
  9. # with the License. You may obtain a copy of the License at
  10. #
  11. # http://www.apache.org/licenses/LICENSE-2.0
  12. #
  13. # Unless required by applicable law or agreed to in writing,
  14. # software distributed under the License is distributed on an
  15. # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
  16. # KIND, either express or implied. See the License for the
  17. # specific language governing permissions and limitations
  18. # under the License.
  19. # Script which checks Java API compatibility between two revisions of the
  20. # Java client.
  21. #
  22. # Originally sourced from Apache Kudu, which was based on the
  23. # compatibility checker from the Apache HBase project, but ported to
  24. # Python for better readability.
  25. import logging
  26. import os
  27. import re
  28. import shutil
  29. import subprocess
  30. import sys
  31. import urllib2
  32. try:
  33. import argparse
  34. except ImportError:
  35. sys.stderr.write("Please install argparse, e.g. via `pip install argparse`.")
  36. sys.exit(2)
  37. # Various relative paths
  38. REPO_DIR = os.getcwd()
  39. def check_output(*popenargs, **kwargs):
  40. r"""Run command with arguments and return its output as a byte string.
  41. Backported from Python 2.7 as it's implemented as pure python on stdlib.
  42. >>> check_output(['/usr/bin/python', '--version'])
  43. Python 2.6.2
  44. """
  45. process = subprocess.Popen(stdout=subprocess.PIPE, *popenargs, **kwargs)
  46. output, _ = process.communicate()
  47. retcode = process.poll()
  48. if retcode:
  49. cmd = kwargs.get("args")
  50. if cmd is None:
  51. cmd = popenargs[0]
  52. error = subprocess.CalledProcessError(retcode, cmd)
  53. error.output = output
  54. raise error
  55. return output
  56. def get_repo_dir():
  57. """ Return the path to the top of the repo. """
  58. dirname, _ = os.path.split(os.path.abspath(__file__))
  59. return os.path.join(dirname, "../..")
  60. def get_scratch_dir():
  61. """ Return the path to the scratch dir that we build within. """
  62. scratch_dir = os.path.join(get_repo_dir(), "target", "compat-check")
  63. if not os.path.exists(scratch_dir):
  64. os.makedirs(scratch_dir)
  65. return scratch_dir
  66. def get_java_acc_dir():
  67. """ Return the path where we check out the Java API Compliance Checker. """
  68. return os.path.join(get_repo_dir(), "target", "java-acc")
  69. def clean_scratch_dir(scratch_dir):
  70. """ Clean up and re-create the scratch directory. """
  71. if os.path.exists(scratch_dir):
  72. logging.info("Removing scratch dir %s...", scratch_dir)
  73. shutil.rmtree(scratch_dir)
  74. logging.info("Creating empty scratch dir %s...", scratch_dir)
  75. os.makedirs(scratch_dir)
  76. def checkout_java_tree(rev, path):
  77. """ Check out the Java source tree for the given revision into
  78. the given path. """
  79. logging.info("Checking out %s in %s", rev, path)
  80. os.makedirs(path)
  81. # Extract java source
  82. subprocess.check_call(["bash", '-o', 'pipefail', "-c",
  83. ("git archive --format=tar %s | " +
  84. "tar -C \"%s\" -xf -") % (rev, path)],
  85. cwd=get_repo_dir())
  86. def get_git_hash(revname):
  87. """ Convert 'revname' to its SHA-1 hash. """
  88. return check_output(["git", "rev-parse", revname],
  89. cwd=get_repo_dir()).strip()
  90. def get_repo_name():
  91. """Get the name of the repo based on the git remote."""
  92. remotes = check_output(["git", "remote", "-v"],
  93. cwd=get_repo_dir()).strip().split("\n")
  94. # Example output:
  95. # origin https://github.com/apache/hadoop.git (fetch)
  96. # origin https://github.com/apache/hadoop.git (push)
  97. remote_url = remotes[0].split("\t")[1].split(" ")[0]
  98. remote = remote_url.split("/")[-1]
  99. if remote.endswith(".git"):
  100. remote = remote[:-4]
  101. return remote
  102. def build_tree(java_path):
  103. """ Run the Java build within 'path'. """
  104. logging.info("Building in %s...", java_path)
  105. subprocess.check_call(["mvn", "-DskipTests", "-Dmaven.javadoc.skip=true",
  106. "package"],
  107. cwd=java_path)
  108. def checkout_java_acc(force):
  109. """
  110. Check out the Java API Compliance Checker. If 'force' is true, will
  111. re-download even if the directory exists.
  112. """
  113. acc_dir = get_java_acc_dir()
  114. if os.path.exists(acc_dir):
  115. logging.info("Java ACC is already downloaded.")
  116. if not force:
  117. return
  118. logging.info("Forcing re-download.")
  119. shutil.rmtree(acc_dir)
  120. logging.info("Downloading Java ACC...")
  121. url = "https://github.com/lvc/japi-compliance-checker/archive/1.8.tar.gz"
  122. scratch_dir = get_scratch_dir()
  123. path = os.path.join(scratch_dir, os.path.basename(url))
  124. jacc = urllib2.urlopen(url)
  125. with open(path, 'wb') as w:
  126. w.write(jacc.read())
  127. subprocess.check_call(["tar", "xzf", path],
  128. cwd=scratch_dir)
  129. shutil.move(os.path.join(scratch_dir, "japi-compliance-checker-1.8"),
  130. os.path.join(acc_dir))
  131. def find_jars(path):
  132. """ Return a list of jars within 'path' to be checked for compatibility. """
  133. all_jars = set(check_output(["find", path, "-name", "*.jar"]).splitlines())
  134. return [j for j in all_jars if (
  135. "-tests" not in j and
  136. "-sources" not in j and
  137. "-with-dependencies" not in j)]
  138. def write_xml_file(path, version, jars):
  139. """Write the XML manifest file for JACC."""
  140. with open(path, "wt") as f:
  141. f.write("<version>" + version + "</version>\n")
  142. f.write("<archives>")
  143. for j in jars:
  144. f.write(j + "\n")
  145. f.write("</archives>")
  146. def run_java_acc(src_name, src_jars, dst_name, dst_jars, annotations):
  147. """ Run the compliance checker to compare 'src' and 'dst'. """
  148. logging.info("Will check compatibility between original jars:\n\t%s\n" +
  149. "and new jars:\n\t%s",
  150. "\n\t".join(src_jars),
  151. "\n\t".join(dst_jars))
  152. java_acc_path = os.path.join(get_java_acc_dir(), "japi-compliance-checker.pl")
  153. src_xml_path = os.path.join(get_scratch_dir(), "src.xml")
  154. dst_xml_path = os.path.join(get_scratch_dir(), "dst.xml")
  155. write_xml_file(src_xml_path, src_name, src_jars)
  156. write_xml_file(dst_xml_path, dst_name, dst_jars)
  157. out_path = os.path.join(get_scratch_dir(), "report.html")
  158. args = ["perl", java_acc_path,
  159. "-l", get_repo_name(),
  160. "-d1", src_xml_path,
  161. "-d2", dst_xml_path,
  162. "-report-path", out_path]
  163. if annotations is not None:
  164. annotations_path = os.path.join(get_scratch_dir(), "annotations.txt")
  165. with file(annotations_path, "w") as f:
  166. for ann in annotations:
  167. print >>f, ann
  168. args += ["-annotations-list", annotations_path]
  169. subprocess.check_call(args)
  170. def filter_jars(jars, include_filters, exclude_filters):
  171. """Filter the list of JARs based on include and exclude filters."""
  172. filtered = []
  173. # Apply include filters
  174. for j in jars:
  175. found = False
  176. basename = os.path.basename(j)
  177. for f in include_filters:
  178. if f.match(basename):
  179. found = True
  180. break
  181. if found:
  182. filtered += [j]
  183. else:
  184. logging.debug("Ignoring JAR %s", j)
  185. # Apply exclude filters
  186. exclude_filtered = []
  187. for j in filtered:
  188. basename = os.path.basename(j)
  189. found = False
  190. for f in exclude_filters:
  191. if f.match(basename):
  192. found = True
  193. break
  194. if found:
  195. logging.debug("Ignoring JAR %s", j)
  196. else:
  197. exclude_filtered += [j]
  198. return exclude_filtered
  199. def main():
  200. """Main function."""
  201. logging.basicConfig(level=logging.INFO)
  202. parser = argparse.ArgumentParser(
  203. description="Run Java API Compliance Checker.")
  204. parser.add_argument("-f", "--force-download",
  205. action="store_true",
  206. help="Download dependencies (i.e. Java JAVA_ACC) " +
  207. "even if they are already present")
  208. parser.add_argument("-i", "--include-file",
  209. action="append",
  210. dest="include_files",
  211. help="Regex filter for JAR files to be included. " +
  212. "Applied before the exclude filters. " +
  213. "Can be specified multiple times.")
  214. parser.add_argument("-e", "--exclude-file",
  215. action="append",
  216. dest="exclude_files",
  217. help="Regex filter for JAR files to be excluded. " +
  218. "Applied after the include filters. " +
  219. "Can be specified multiple times.")
  220. parser.add_argument("-a", "--annotation",
  221. action="append",
  222. dest="annotations",
  223. help="Fully-qualified Java annotation. " +
  224. "Java ACC will only check compatibility of " +
  225. "annotated classes. Can be specified multiple times.")
  226. parser.add_argument("--skip-clean",
  227. action="store_true",
  228. help="Skip cleaning the scratch directory.")
  229. parser.add_argument("--skip-build",
  230. action="store_true",
  231. help="Skip building the projects.")
  232. parser.add_argument("src_rev", nargs=1, help="Source revision.")
  233. parser.add_argument("dst_rev", nargs="?", default="HEAD",
  234. help="Destination revision. " +
  235. "If not specified, will use HEAD.")
  236. if len(sys.argv) == 1:
  237. parser.print_help()
  238. sys.exit(1)
  239. args = parser.parse_args()
  240. src_rev, dst_rev = args.src_rev[0], args.dst_rev
  241. logging.info("Source revision: %s", src_rev)
  242. logging.info("Destination revision: %s", dst_rev)
  243. # Construct the JAR regex patterns for filtering.
  244. include_filters = []
  245. if args.include_files is not None:
  246. for f in args.include_files:
  247. logging.info("Applying JAR filename include filter: %s", f)
  248. include_filters += [re.compile(f)]
  249. else:
  250. include_filters = [re.compile(".*")]
  251. exclude_filters = []
  252. if args.exclude_files is not None:
  253. for f in args.exclude_files:
  254. logging.info("Applying JAR filename exclude filter: %s", f)
  255. exclude_filters += [re.compile(f)]
  256. # Construct the annotation list
  257. annotations = args.annotations
  258. if annotations is not None:
  259. logging.info("Filtering classes using %d annotation(s):", len(annotations))
  260. for a in annotations:
  261. logging.info("\t%s", a)
  262. # Download deps.
  263. checkout_java_acc(args.force_download)
  264. # Set up the build.
  265. scratch_dir = get_scratch_dir()
  266. src_dir = os.path.join(scratch_dir, "src")
  267. dst_dir = os.path.join(scratch_dir, "dst")
  268. if args.skip_clean:
  269. logging.info("Skipping cleaning the scratch directory")
  270. else:
  271. clean_scratch_dir(scratch_dir)
  272. # Check out the src and dst source trees.
  273. checkout_java_tree(get_git_hash(src_rev), src_dir)
  274. checkout_java_tree(get_git_hash(dst_rev), dst_dir)
  275. # Run the build in each.
  276. if args.skip_build:
  277. logging.info("Skipping the build")
  278. else:
  279. build_tree(src_dir)
  280. build_tree(dst_dir)
  281. # Find the JARs.
  282. src_jars = find_jars(src_dir)
  283. dst_jars = find_jars(dst_dir)
  284. # Filter the JARs.
  285. src_jars = filter_jars(src_jars, include_filters, exclude_filters)
  286. dst_jars = filter_jars(dst_jars, include_filters, exclude_filters)
  287. if len(src_jars) == 0 or len(dst_jars) == 0:
  288. logging.error("No JARs found! Are your filters too strong?")
  289. sys.exit(1)
  290. run_java_acc(src_rev, src_jars,
  291. dst_rev, dst_jars, annotations)
  292. if __name__ == "__main__":
  293. main()