Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-08-16 08:16:23

0001 #!/usr/bin/env python3
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 # The JSON consumed here is produced by an unprivileged job a PR fully
0043 # controls (see api-surface-report.yml's header comment): a PR could skip
0044 # public_api_surface.py's own extraction entirely and hand-craft this JSON,
0045 # so a crafted C++20 Unicode identifier could carry bidi-override /
0046 # zero-width characters into the Markdown this script renders. This is the
0047 # one script guaranteed to run from a trusted (base-branch) checkout, so
0048 # strip them here too rather than relying on the producer.
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     # non-function, name-level entities
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     # function/method callable signatures
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     # public data members (fields): removal / retype is breaking, add is not
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())