Files
hibernate-demo/migration-5-to-7/scripts/api_diff.py
T

171 lines
6.8 KiB
Python

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