#!/usr/bin/env python3 """Compare the public API of JUnit 5.14.4 / Platform 1.14.4 against JUnit 6.1.3 straight from the jars. Writes output/01-versions-and-baseline.txt and output/02-removed-api.txt. Needs a JDK (javap) on PATH or via JAVA_HOME, and access to Maven Central. """ import collections import os import pathlib import re import subprocess import time import urllib.error import urllib.request import zipfile ROOT = pathlib.Path(__file__).resolve().parent.parent JARS = ROOT / "target" / "jars" OUT = ROOT / "output" JAVAP = os.path.join(os.environ["JAVA_HOME"], "bin", "javap") if os.environ.get("JAVA_HOME") else "javap" OLD, NEW, PLATFORM_OLD = "5.14.4", "6.1.3", "1.14.4" ARTIFACTS = [ ("jupiter", "junit-jupiter-api"), ("jupiter", "junit-jupiter-engine"), ("jupiter", "junit-jupiter-params"), ("platform", "junit-platform-commons"), ("platform", "junit-platform-engine"), ("platform", "junit-platform-launcher"), ("platform", "junit-platform-suite-api"), ("platform", "junit-platform-suite-engine"), ("platform", "junit-platform-testkit"), ("vintage", "junit-vintage-engine"), ] def version_for(group, which): if which == "new": return NEW return PLATFORM_OLD if group == "platform" else OLD def fetch(url, dest): if dest.exists(): return for _ in range(8): try: with urllib.request.urlopen(url) as r: dest.write_bytes(r.read()) return except urllib.error.HTTPError as e: if e.code != 429: raise time.sleep(4) raise RuntimeError("rate limited: " + url) def jar(group, artifact, which): v = version_for(group, which) dest = JARS / f"{artifact}-{v}.jar" fetch(f"https://repo1.maven.org/maven2/org/junit/{group}/{artifact}/{v}/{artifact}-{v}.jar", dest) return dest def classes(path): with zipfile.ZipFile(path) as z: return sorted(n[:-6].replace("/", ".") for n in z.namelist() if n.endswith(".class") and not n.endswith("module-info.class") and "META-INF" not in n) def major(path): with zipfile.ZipFile(path) as z: name = next(n for n in z.namelist() if n.endswith(".class") and not n.endswith("module-info.class") and "META-INF" not in n) b = z.read(name) return int.from_bytes(b[6:8], "big") MEMBER = re.compile(r"^ ((?:public|protected)[^;]*);$") def dump(path): """One javap call per jar. Returns {class: (is_public, class_deprecated, {member signature: deprecated?})}.""" names = [c for c in classes(path) if ".internal." not in c] result = {} # javap takes many class names at once; split the list to keep the command line short for i in range(0, len(names), 150): chunk = names[i:i + 150] p = subprocess.run([JAVAP, "-v", "-protected", "-cp", str(path)] + chunk, capture_output=True, text=True) cur_cls = None for block in re.split(r"(?m)^Classfile ", p.stdout)[1:]: lines = block.splitlines() header = lines[0] cls = re.search(r"!/(.*)\.class", header).group(1).replace("/", ".") public = False cls_dep = False members, cur, in_pool, seen_flags = {}, None, False, False for line in lines[1:]: if not seen_flags and line.startswith(" flags:"): public = "ACC_PUBLIC" in line seen_flags = True if line.startswith("Constant pool:"): in_pool = True continue if in_pool: if line.startswith("{"): in_pool = False continue m = MEMBER.match(line) if m: cur = " ".join(m.group(1).split()) members[cur] = False continue if line.strip() == "Deprecated: true": if cur is None: cls_dep = True else: members[cur] = True if line.startswith("}"): cur = None if re.search(r"^Deprecated: true", block, re.M): cls_dep = True result[cls] = (public, cls_dep, members) return result def main(): JARS.mkdir(parents=True, exist_ok=True) OUT.mkdir(parents=True, exist_ok=True) versions = [f"{'artifact':<34} {'5.x jar':<28} {'6.x jar':<28}"] removed_classes = collections.defaultdict(list) removed_members = collections.defaultdict(list) added = collections.Counter() for group, art in ARTIFACTS: old, new = jar(group, art, "old"), jar(group, art, "new") mo, mn = major(old), major(new) versions.append(f"{art:<34} {version_for(group, 'old')} (Java {mo - 44}){'':<9} {NEW} (Java {mn - 44})") do, dn = dump(old), dump(new) co, cn = set(classes(old)), set(classes(new)) for c in sorted(co - cn): if c in do and do[c][0]: removed_classes[art].append((c, do[c][1])) added[art] = len(cn - co) for c in sorted(do): if c in dn and do[c][0]: for sig, dep in do[c][2].items(): if sig not in dn[c][2]: removed_members[art].append((c, sig, dep)) (OUT / "01-versions-and-baseline.txt").write_text("\n".join(versions) + "\n") lines = [] for _, art in ARTIFACTS: lines.append(f"== {art}: public classes removed: {len(removed_classes[art])}, " f"members removed from classes that remain: {len(removed_members[art])}, " f"new classes: {added[art]}") for c, dep in removed_classes[art]: lines.append(f" class {c} [{'deprecated in 5.x' if dep else 'NOT deprecated in 5.x'}]") for c, sig, dep in removed_members[art]: lines.append(f" member {c}: {sig} [{'deprecated in 5.x' if dep else 'NOT deprecated in 5.x'}]") (OUT / "02-removed-api.txt").write_text("\n".join(lines) + "\n") print("\n".join(versions)) print("\n".join(l for l in lines if l.startswith("=="))) if __name__ == "__main__": main()