#!/usr/bin/env python3 """Compare the public API of hibernate-core 5.6.15.Final (org.hibernate) with 7.4.14.Final (org.hibernate.orm). javax.persistence is read as jakarta.persistence, so the namespace move does not drown the real changes. Writes output/01-versions-and-baseline.txt, output/02-removed-api.txt and output/02-removed-api-summary.txt. Needs a JDK (javap) 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 = ("org/hibernate", "5.6.15.Final") NEW = ("org/hibernate/orm", "7.4.14.Final") def fetch(url, dest): if dest.exists(): return for _ in range(10): 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(5) raise RuntimeError("rate limited: " + url) def jar(side): group, v = side dest = JARS / f"hibernate-core-{v}.jar" fetch(f"https://repo1.maven.org/maven2/{group}/hibernate-core/{v}/hibernate-core-{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 "META-INF" not in n and not n.endswith("module-info.class")) return int.from_bytes(z.read(name)[6:8], "big") def jakarta(text): return text.replace("javax.persistence", "jakarta.persistence").replace("javax.transaction", "jakarta.transaction") def norm(sig): sig = jakarta(sig) sig = re.sub(r"\s+throws\s+.*$", "", sig) prev = None while prev != sig: prev = sig sig = re.sub(r"<[^<>]*>", "", sig) return " ".join(sig.split()) MEMBER = re.compile(r"^ ((?:public|protected)[^;]*);$") def dump(path): names = [c for c in classes(path) if ".internal." not in c and ".spi." not in c and not c.startswith("org.hibernate.sql.ast") and not c.startswith("org.hibernate.sql.results") and not c.startswith("org.hibernate.query.sqm.tree") and not c.startswith("org.hibernate.grammars")] result = {} 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) for block in re.split(r"(?m)^Classfile ", p.stdout)[1:]: lines = block.splitlines() cls = jakarta(re.search(r"!/(.*)\.class", lines[0]).group(1).replace("/", ".")) public, seen_flags, in_pool, cur = False, False, False, None members = {} cls_dep = bool(re.search(r"^Deprecated: true", block, re.M)) 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" and cur is not None: members[cur] = True if line.startswith("}"): cur = None result[cls] = (public, cls_dep, members) return result def main(): JARS.mkdir(parents=True, exist_ok=True) OUT.mkdir(parents=True, exist_ok=True) old, new = jar(OLD), jar(NEW) mo, mn = major(old), major(new) versions = [ f"{'':<12}{'5.x':<46}{'7.x'}", f"{'coordinates':<12}{'org.hibernate:hibernate-core:5.6.15.Final':<46}org.hibernate.orm:hibernate-core:7.4.14.Final", f"{'class files':<12}{'Java ' + str(mo - 44):<46}Java {mn - 44}", f"{'persistence':<12}{'javax.persistence (JPA 2.2)':<46}jakarta.persistence (Jakarta Persistence 3.2)", ] do, dn = dump(old), dump(new) co = {jakarta(c) for c in classes(old)} cn = {jakarta(c) for c in classes(new)} simple_new = collections.defaultdict(list) for c in cn: simple_new[c.split(".")[-1]].append(c) removed_cls, removed_mem = [], [] for c in sorted(co - cn): if c in do and do[c][0]: removed_cls.append((c, do[c][1], simple_new.get(c.split(".")[-1], []))) for c in sorted(do): if c in dn and do[c][0]: new_norm = {norm(x) for x in dn[c][2]} for sig, dep in do[c][2].items(): if norm(sig) not in new_norm: removed_mem.append((c, jakarta(sig), dep)) lines = [f"== hibernate-core: public classes missing: {len(removed_cls)}, members removed from classes that remain: {len(removed_mem)}, " f"new classes: {len(cn - co)}"] for c, dep, same_name in removed_cls: if same_name: note = "same simple name exists in " + ", ".join(sorted(p.rsplit('.', 1)[0] for p in same_name)[:2]) else: note = "no class with this name" lines.append(f" class {c} [{'deprecated in 5.6' if dep else 'NOT deprecated in 5.6'}; {note}]") for c, sig, dep in removed_mem: lines.append(f" member {c}: {sig} [{'deprecated in 5.6' if dep else 'NOT deprecated in 5.6'}]") (OUT / "01-versions-and-baseline.txt").write_text("\n".join(versions) + "\n") (OUT / "02-removed-api.txt").write_text("\n".join(lines) + "\n") # summary: counts per package, and how many of the missing classes have a namesake elsewhere per_pkg = collections.Counter(c.rsplit(".", 1)[0] for c, _, _ in removed_cls) dep_n = sum(1 for _, d, _ in removed_cls if d) ren_n = sum(1 for _, _, s in removed_cls if s) summary = [lines[0], f" of the missing classes: {dep_n} were deprecated in 5.6.15, {ren_n} have a class of the same simple name somewhere in 7.4.14", " packages with the most missing classes:"] for pkg, n in per_pkg.most_common(12): summary.append(f" {n:>4} {pkg}") (OUT / "02-removed-api-summary.txt").write_text("\n".join(summary) + "\n") print("\n".join(versions)) print("\n".join(summary)) if __name__ == "__main__": main()