File indexing completed on 2026-08-16 08:16:23
0001
0002 """Diff two public-API-surface snapshots and classify the change.
0003
0004 Consumes two JSON files from CI/public_api/public_api_surface.py. Each carries:
0005 * ``symbols`` -- name-level keys for non-function entities
0006 (types, concepts, aliases, variables, enums)
0007 * ``callables`` -- source-callable function/method signatures -> return type,
0008 with defaulted arguments already expanded into their
0009 shorter callable forms.
0010
0011 Classification (source-level API only; ABI is out of scope):
0012 * ADDED -- names/signatures present in head but not base
0013 (new type, new overload, added *defaulted* argument, ...)
0014 * BREAKING -- present in base but not head, or a changed return type:
0015 removals, renames (old name gone), removing an argument,
0016 adding a *non-defaulted* argument, or retyping a parameter
0017 (all drop the old callable form), and return-type changes.
0018
0019 Emits Markdown (for the PR/job summary) and a machine-readable JSON that a
0020 labeling job can act on. Optionally fails the job (``--fail-on``).
0021
0022 CI/public_api/public_api_diff.py --base base.json --head head.json \
0023 --json classification.json --markdown - --fail-on none
0024 """
0025
0026 from __future__ import annotations
0027
0028 import argparse
0029 import json
0030 import os
0031 import sys
0032 import unicodedata
0033 from pathlib import Path
0034
0035 NONFUNC_PREFIXES = ("type ", "concept ", "aliases ", "variables ", "enums ")
0036
0037
0038 def load(path: str) -> dict:
0039 return json.loads(Path(path).read_text())
0040
0041
0042
0043
0044
0045
0046
0047
0048
0049 def _sanitize(text: str) -> str:
0050 return "".join(c for c in text if unicodedata.category(c) not in ("Cf", "Cc"))
0051
0052
0053 def _sanitize_snapshot(d: dict) -> dict:
0054 return {
0055 "symbols": [_sanitize(s) for s in d.get("symbols", [])],
0056 "callables": {
0057 _sanitize(k): _sanitize(v) for k, v in d.get("callables", {}).items()
0058 },
0059 "fields": {_sanitize(k): _sanitize(v) for k, v in d.get("fields", {}).items()},
0060 }
0061
0062
0063 def classify(base: dict, head: dict) -> dict:
0064 base = _sanitize_snapshot(base)
0065 head = _sanitize_snapshot(head)
0066
0067 b_names = {s for s in base.get("symbols", []) if s.startswith(NONFUNC_PREFIXES)}
0068 h_names = {s for s in head.get("symbols", []) if s.startswith(NONFUNC_PREFIXES)}
0069 added_names = sorted(h_names - b_names)
0070 removed_names = sorted(b_names - h_names)
0071
0072
0073 b_call = base.get("callables", {})
0074 h_call = head.get("callables", {})
0075 b_keys, h_keys = set(b_call), set(h_call)
0076 added_forms = sorted(h_keys - b_keys)
0077 removed_forms = sorted(b_keys - h_keys)
0078 ret_changed = sorted(
0079 f"{k}: {b_call[k]} -> {h_call[k]}"
0080 for k in (b_keys & h_keys)
0081 if b_call[k] != h_call[k]
0082 )
0083
0084
0085 b_fld = base.get("fields", {})
0086 h_fld = head.get("fields", {})
0087 added_fields = sorted(set(h_fld) - set(b_fld))
0088 removed_fields = sorted(set(b_fld) - set(h_fld))
0089 field_retyped = sorted(
0090 f"{k}: {b_fld[k]} -> {h_fld[k]}"
0091 for k in (set(b_fld) & set(h_fld))
0092 if b_fld[k] != h_fld[k]
0093 )
0094
0095 added = added_names + added_forms + added_fields
0096 breaking = (
0097 removed_names + removed_forms + ret_changed + removed_fields + field_retyped
0098 )
0099 return {
0100 "added": added,
0101 "breaking": breaking,
0102 "added_names": added_names,
0103 "added_signatures": added_forms,
0104 "added_fields": added_fields,
0105 "removed_names": removed_names,
0106 "removed_signatures": removed_forms,
0107 "return_type_changes": ret_changed,
0108 "removed_fields": removed_fields,
0109 "field_type_changes": field_retyped,
0110 "added_count": len(added),
0111 "breaking_count": len(breaking),
0112 "has_additions": bool(added),
0113 "has_breaking": bool(breaking),
0114 }
0115
0116
0117 def _details(title: str, items: list[str], cap: int = 60) -> list[str]:
0118 out = [f"<details><summary>{title} ({len(items)})</summary>", ""]
0119 out += [f"- `{s}`" for s in items[:cap]]
0120 if len(items) > cap:
0121 out.append(f"- … and {len(items) - cap} more")
0122 return out + ["", "</details>"]
0123
0124
0125 def render_markdown(c: dict) -> str:
0126 lines = ["## Public API surface diff", ""]
0127 if not c["has_additions"] and not c["has_breaking"]:
0128 return "\n".join(lines + ["No change to the public API surface. ✅", ""]) + "\n"
0129
0130 lines.append(
0131 f"**+{c['added_count']} added**, " f"**{c['breaking_count']} breaking**."
0132 )
0133 lines.append("")
0134 if c["has_breaking"]:
0135 lines.append("### ⚠️ Breaking API changes (source-level)")
0136 if c["removed_names"]:
0137 lines += _details(
0138 "Removed types / aliases / enums / variables", c["removed_names"]
0139 )
0140 if c["removed_signatures"]:
0141 lines += _details(
0142 "Removed or changed call signatures", c["removed_signatures"]
0143 )
0144 if c["return_type_changes"]:
0145 lines += _details("Return-type changes", c["return_type_changes"])
0146 if c.get("removed_fields"):
0147 lines += _details("Removed public data members", c["removed_fields"])
0148 if c.get("field_type_changes"):
0149 lines += _details("Retyped public data members", c["field_type_changes"])
0150 lines.append("")
0151 if c["has_additions"]:
0152 lines.append("### ➕ Added public API")
0153 if c["added_names"]:
0154 lines += _details(
0155 "New types / aliases / enums / variables / concepts", c["added_names"]
0156 )
0157 if c["added_signatures"]:
0158 lines += _details(
0159 "New call signatures (incl. defaulted-arg overloads)",
0160 c["added_signatures"],
0161 )
0162 if c.get("added_fields"):
0163 lines += _details("New public data members", c["added_fields"])
0164 return "\n".join(lines) + "\n"
0165
0166
0167 def main() -> int:
0168 ap = argparse.ArgumentParser(
0169 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
0170 )
0171 ap.add_argument("--base", required=True)
0172 ap.add_argument("--head", required=True)
0173 ap.add_argument("--json", help="write classification JSON here")
0174 ap.add_argument("--markdown", help="write Markdown here ('-' for stdout)")
0175 ap.add_argument(
0176 "--summary",
0177 action="store_true",
0178 help="also append the Markdown to $GITHUB_STEP_SUMMARY",
0179 )
0180 ap.add_argument(
0181 "--fail-on",
0182 choices=["none", "additions", "breaking", "any"],
0183 default="none",
0184 help="exit non-zero when this category is present",
0185 )
0186 ap.add_argument(
0187 "--allow-additions",
0188 default="false",
0189 help="'true' suppresses failure on additions (e.g. maintainer label present)",
0190 )
0191 args = ap.parse_args()
0192
0193 c = classify(load(args.base), load(args.head))
0194 md = render_markdown(c)
0195
0196 if args.markdown == "-" or args.markdown is None:
0197 print(md)
0198 elif args.markdown:
0199 Path(args.markdown).write_text(md)
0200 if args.json:
0201 Path(args.json).write_text(json.dumps(c, indent=2) + "\n")
0202
0203 summary = os.environ.get("GITHUB_STEP_SUMMARY")
0204 if args.summary and summary:
0205 with open(summary, "a") as fh:
0206 fh.write(md)
0207
0208 allow_add = str(args.allow_additions).strip().lower() in ("true", "1", "yes")
0209 fail = False
0210 if args.fail_on in ("breaking", "any") and c["has_breaking"]:
0211 print(
0212 f"::error::This PR makes {c['breaking_count']} breaking public API "
0213 f"change(s).",
0214 file=sys.stderr,
0215 )
0216 fail = True
0217 if args.fail_on in ("additions", "any") and c["has_additions"] and not allow_add:
0218 print(
0219 f"::error::This PR adds {c['added_count']} public API symbol(s); "
0220 f"a maintainer must accept the enlarged surface.",
0221 file=sys.stderr,
0222 )
0223 fail = True
0224 return 1 if fail else 0
0225
0226
0227 if __name__ == "__main__":
0228 sys.exit(main())