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 """Measure the ACTS public API surface from Doxygen XML.
0003 
0004 The "public API surface" is the set of *documented*, non-``detail`` /
0005 non-``Experimental`` entities in the ``Acts`` namespace, matching the policy in
0006 docs/pages/versioning.md. Doxygen parses syntactically, so this needs no
0007 dependency headers or compile flags -- just the ``doxygen`` binary.
0008 
0009 Typical use (runs Doxygen itself, then reports):
0010 
0011     CI/public_api/public_api_surface.py --run --json surface.json --markdown surface.md
0012 
0013 Or parse an existing XML tree:
0014 
0015     CI/public_api/public_api_surface.py --xml <dir>/xml --markdown -
0016 
0017 Writes a Markdown summary to ``$GITHUB_STEP_SUMMARY`` when that env var is set.
0018 """
0019 
0020 from __future__ import annotations
0021 
0022 import argparse
0023 import glob
0024 import os
0025 import subprocess
0026 import sys
0027 import tempfile
0028 import unicodedata
0029 import xml.etree.ElementTree as ET
0030 from collections import Counter, defaultdict
0031 from pathlib import Path
0032 
0033 # Absolute so it resolves regardless of the caller's working directory.
0034 DOXYFILE = str(Path(__file__).resolve().parent / "Doxyfile")
0035 
0036 # Plugins deliberately left out of the public-API surface metric. (The
0037 # top-level Detray/ and Traccc/ build-integration folders live outside Plugins/
0038 # and are never scanned; the Detray *plugin* under Plugins/ is in scope.)
0039 EXCLUDED_PLUGINS: set[str] = set()
0040 
0041 
0042 def standard_roots(under: Path) -> list[Path]:
0043     """Header roots whose public API we track, resolved under `under`.
0044 
0045     Core, Fatras and Alignment, plus every plugin under Plugins/. The Examples
0046     tree and the top-level Detray/Traccc integration folders are not included.
0047     """
0048     roots: list[Path] = []
0049     for rel in ("Core/include", "Fatras/include", "Alignment/include"):
0050         p = under / rel
0051         if p.is_dir():
0052             roots.append(p)
0053     plugins = under / "Plugins"
0054     if plugins.is_dir():
0055         for pdir in sorted(plugins.iterdir()):
0056             if (
0057                 pdir.is_dir()
0058                 and pdir.name not in EXCLUDED_PLUGINS
0059                 and (pdir / "include").is_dir()
0060             ):
0061                 roots.append(pdir / "include")
0062     return roots
0063 
0064 
0065 # Doxygen memberdef 'kind' -> report bucket for namespace-scope members
0066 NS_MEMBER_BUCKET = {
0067     "function": "free_functions",
0068     "typedef": "aliases",
0069     "variable": "variables",
0070     "enum": "enums",
0071     "concept": "concepts",
0072 }
0073 
0074 
0075 def is_internal(name: str) -> bool:
0076     return bool(name) and ("detail" in name or "Experimental" in name)
0077 
0078 
0079 def module_of(location: str | None) -> str:
0080     """Component (and Core sub-module) from a header location path.
0081 
0082     e.g. '.../Core/include/Acts/Surfaces/X.hpp' -> 'Core/Surfaces',
0083          '.../Plugins/Json/include/ActsPlugins/Json/Y.hpp' -> 'Plugin:Json',
0084          '.../Fatras/include/ActsFatras/Z.hpp' -> 'Fatras'.
0085     """
0086     if not location:
0087         return "?"
0088     parts = Path(location).as_posix().split("/")
0089     if "Plugins" in parts:
0090         i = parts.index("Plugins")
0091         if i + 1 < len(parts):
0092             return "Plugin:" + parts[i + 1]
0093     if "Fatras" in parts:
0094         return "Fatras"
0095     if "Alignment" in parts:
0096         return "Alignment"
0097     if "Acts" in parts:  # Core: '.../Core/include/Acts/<module>/file.hpp'
0098         i = parts.index("Acts")
0099         return f"Core/{parts[i + 1]}" if i + 1 < len(parts) - 1 else "Core"
0100     return "?"
0101 
0102 
0103 def run_doxygen(repo: Path, out: Path, input_dirs: list[Path]) -> None:
0104     out.mkdir(parents=True, exist_ok=True)
0105     # Doxygen INPUT accepts a space-separated list of directories.
0106     doxy_input = " ".join(f'"{d}"' for d in input_dirs)
0107     env = dict(os.environ, DOXY_OUT=str(out), DOXY_INPUT=doxy_input)
0108     # Capture output: Doxygen emits many non-fatal cross-reference warnings
0109     # (our config feeds only headers, so @ref links into the narrative docs do
0110     # not resolve). They do not affect symbol extraction, so keep them out of
0111     # the log unless Doxygen actually fails.
0112     proc = subprocess.run(
0113         ["doxygen", DOXYFILE], cwd=repo, env=env, capture_output=True, text=True
0114     )
0115     if proc.returncode != 0:
0116         sys.stderr.write(proc.stdout)
0117         sys.stderr.write(proc.stderr)
0118         raise subprocess.CalledProcessError(proc.returncode, "doxygen")
0119     n = proc.stderr.count("warning:") + proc.stderr.count("error:")
0120     if n:
0121         print(f"(doxygen: {n} non-fatal diagnostics suppressed)", file=sys.stderr)
0122 
0123 
0124 # C++20 permits Unicode identifiers, so a crafted symbol/type name could
0125 # otherwise smuggle bidi-override or zero-width characters into the report
0126 # (Trojan-Source-style visual spoofing) even though every value is rendered
0127 # into a Markdown code span. Strip format (Cf) and control (Cc) code points
0128 # from any text pulled out of Doxygen's XML before it is used as a key or
0129 # rendered.
0130 def _sanitize(text: str) -> str:
0131     return "".join(c for c in text if unicodedata.category(c) not in ("Cf", "Cc"))
0132 
0133 
0134 def _name(el, tag: str) -> str:
0135     return _sanitize(el.findtext(tag) or "")
0136 
0137 
0138 def _norm_type(el) -> str:
0139     """Flatten a Doxygen <type>/<param><type> element to normalized text."""
0140     if el is None:
0141         return ""
0142     return _sanitize(" ".join("".join(el.itertext()).split()))
0143 
0144 
0145 def callable_forms(md, qualname: str) -> dict[str, str]:
0146     """Expand a function memberdef into its source-callable signatures.
0147 
0148     A parameter with a default value makes shorter calls valid too, so
0149     ``f(A, B = d)`` yields both ``f(A)`` and ``f(A, B)``. Comparing these
0150     expanded forms across two revisions makes *adding a defaulted argument*
0151     non-breaking while *adding a non-defaulted argument* (or removing/retyping
0152     one) shows up as a removed form. Maps form-key -> return type.
0153     """
0154     params = md.findall("param")
0155     types = [_norm_type(p.find("type")) for p in params]
0156     has_default = [p.find("defval") is not None for p in params]
0157     required = len(types)
0158     for i, d in enumerate(has_default):
0159         if d:
0160             required = i
0161             break
0162     ret = _norm_type(md.find("type"))
0163     const = " const" if md.get("const") == "yes" else ""
0164     forms = {}
0165     for m in range(required, len(types) + 1):
0166         forms[f"{qualname}({', '.join(types[:m])}){const}"] = ret
0167     return forms
0168 
0169 
0170 def parse_xml(xml_dir: Path) -> dict:
0171     counts: Counter[str] = Counter()
0172     per_module: dict[str, Counter] = defaultdict(Counter)
0173     type_names: set[str] = set()
0174     ns_member_names: set[str] = set()
0175     symbols: set[str] = set()  # stable per-symbol keys for diffing (non-function)
0176     callables: dict[str, str] = {}  # source-callable signature -> return type
0177     fields: dict[str, str] = {}  # public data member Class::name -> type
0178     methods = 0
0179 
0180     for f in glob.glob(str(xml_dir / "*.xml")):
0181         base = os.path.basename(f)
0182         if base in ("index.xml", "Doxyfile.xml"):
0183             continue
0184         try:
0185             root = ET.parse(f).getroot()
0186         except ET.ParseError:
0187             continue
0188         for cd in root.findall("compounddef"):
0189             kind = cd.get("kind")
0190             name = _name(cd, "compoundname")
0191             if not name.startswith("Acts") or is_internal(name):
0192                 continue
0193             loc = cd.find("location")
0194             locfile = loc.get("file") if loc is not None else None
0195 
0196             if kind in ("class", "struct", "union"):
0197                 bare = name.split("<")[0]
0198                 if bare not in type_names:
0199                     type_names.add(bare)
0200                     counts["types"] += 1
0201                     per_module[module_of(locfile)]["types"] += 1
0202                     symbols.add(f"type {bare}")
0203                 # public methods and data members on this documented type
0204                 for md in cd.iter("memberdef"):
0205                     if md.get("prot") != "public":
0206                         continue
0207                     if md.get("kind") == "function":
0208                         methods += 1
0209                         callables.update(
0210                             callable_forms(md, f"{bare}::{_name(md, 'name')}")
0211                         )
0212                     elif md.get("kind") == "variable":
0213                         mname = _name(md, "name")
0214                         fields[f"{bare}::{mname}"] = _norm_type(md.find("type"))
0215 
0216             elif kind == "concept":
0217                 # Doxygen >= 1.9.7 emits C++20 concepts as their own compound.
0218                 if name not in ns_member_names:
0219                     ns_member_names.add(name)
0220                     counts["concepts"] += 1
0221                     per_module[module_of(locfile)]["concepts"] += 1
0222                     symbols.add(f"concept {name}")
0223 
0224             elif kind == "namespace":
0225                 for md in cd.findall("sectiondef/memberdef"):
0226                     mk = md.get("kind")
0227                     bucket = NS_MEMBER_BUCKET.get(mk)
0228                     if not bucket:
0229                         continue
0230                     mname = _name(md, "name")
0231                     full = f"{name}::{mname}"
0232                     if full in ns_member_names:
0233                         continue
0234                     ns_member_names.add(full)
0235                     counts[bucket] += 1
0236                     symbols.add(f"{bucket} {full}")
0237                     if mk == "function":
0238                         callables.update(callable_forms(md, full))
0239                     mloc = md.find("location")
0240                     per_module[
0241                         module_of(mloc.get("file") if mloc is not None else None)
0242                     ][bucket] += 1
0243 
0244     total = sum(counts.values())
0245     return {
0246         "counts": dict(counts),
0247         "total": total,
0248         "public_methods": methods,
0249         "public_fields": len(fields),
0250         "per_module": {m: dict(c) for m, c in per_module.items()},
0251         "symbols": sorted(symbols),
0252         "callables": callables,
0253         "fields": fields,
0254     }
0255 
0256 
0257 BUCKET_ORDER = ["types", "free_functions", "aliases", "variables", "enums", "concepts"]
0258 
0259 
0260 def render_markdown(data: dict, doxy_version: str | None) -> str:
0261     c = data["counts"]
0262     lines = ["## ACTS public API surface", ""]
0263     lines.append(
0264         f"**{data['total']}** documented public names in `Acts*::` "
0265         f"(excluding `detail` / `Experimental`), plus "
0266         f"**{data['public_methods']}** public methods and "
0267         f"**{data.get('public_fields', 0)}** public data members on "
0268         f"documented types."
0269     )
0270     scope = data.get("scope")
0271     if scope:
0272         lines.append(
0273             f"\n_Scope: {scope}"
0274             + (f" via Doxygen {doxy_version}._" if doxy_version else "._")
0275         )
0276     elif doxy_version:
0277         lines.append(f"\n_Doxygen {doxy_version}._")
0278     lines += ["", "| category | count |", "|---|---:|"]
0279     for k in BUCKET_ORDER:
0280         if k in c:
0281             lines.append(f"| {k.replace('_', ' ')} | {c[k]} |")
0282     lines.append(f"| **total** | **{data['total']}** |")
0283 
0284     lines += [
0285         "",
0286         "<details><summary>By module</summary>",
0287         "",
0288         "| module | total | " + " | ".join(BUCKET_ORDER) + " |",
0289         "|---|---:|" + "|".join(["---:"] * len(BUCKET_ORDER)) + "|",
0290     ]
0291     mods = sorted(data["per_module"].items(), key=lambda kv: -sum(kv[1].values()))
0292     for m, d in mods:
0293         row = [m, str(sum(d.values()))] + [str(d.get(k, 0)) for k in BUCKET_ORDER]
0294         lines.append("| " + " | ".join(row) + " |")
0295     lines += ["", "</details>"]
0296     return "\n".join(lines) + "\n"
0297 
0298 
0299 def main() -> int:
0300     ap = argparse.ArgumentParser(
0301         description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
0302     )
0303     ap.add_argument("--repo", default=".", help="repository root (has the Doxyfile)")
0304     ap.add_argument("--run", action="store_true", help="run doxygen (else use --xml)")
0305     ap.add_argument(
0306         "--input", nargs="+", metavar="DIR", help="explicit header root(s) to measure"
0307     )
0308     ap.add_argument(
0309         "--roots-under",
0310         metavar="DIR",
0311         help="measure the standard component set (Core, Fatras, Alignment, "
0312         "and all plugins) resolved under DIR; use for another checkout",
0313     )
0314     ap.add_argument("--xml", help="existing Doxygen XML dir to parse")
0315     ap.add_argument("--json", help="write report JSON here")
0316     ap.add_argument("--markdown", help="write Markdown here ('-' for stdout)")
0317     ap.add_argument(
0318         "--summary",
0319         action="store_true",
0320         help="also append the Markdown to $GITHUB_STEP_SUMMARY",
0321     )
0322     args = ap.parse_args()
0323 
0324     repo = Path(args.repo).resolve()
0325 
0326     doxy_version = None
0327     try:
0328         doxy_version = subprocess.run(
0329             ["doxygen", "--version"], capture_output=True, text=True
0330         ).stdout.split()[0]
0331     except (FileNotFoundError, IndexError):
0332         pass
0333 
0334     scope = None
0335     tmp = None
0336     if args.run:
0337         if args.input:
0338             input_dirs = [Path(p).resolve() for p in args.input]
0339         else:
0340             input_dirs = standard_roots(
0341                 Path(args.roots_under).resolve() if args.roots_under else repo
0342             )
0343         if not input_dirs:
0344             print("error: no header roots found to measure", file=sys.stderr)
0345             return 2
0346 
0347         def component(d: Path) -> str:
0348             parts = d.parts
0349             if "Plugins" in parts:
0350                 return "Plugin:" + parts[parts.index("Plugins") + 1]
0351             for comp in ("Core", "Fatras", "Alignment"):
0352                 if comp in parts:
0353                     return comp
0354             return d.name
0355 
0356         scope = ", ".join(dict.fromkeys(component(d) for d in input_dirs))
0357         tmp = Path(tempfile.mkdtemp(prefix="acts-api-surface-"))
0358         run_doxygen(repo, tmp, input_dirs)
0359         xml_dir = tmp / "xml"
0360     elif args.xml:
0361         xml_dir = Path(args.xml)
0362     else:
0363         print("error: pass --run or --xml", file=sys.stderr)
0364         return 2
0365 
0366     if not xml_dir.is_dir():
0367         print(f"error: no XML at {xml_dir}", file=sys.stderr)
0368         return 2
0369 
0370     data = parse_xml(xml_dir)
0371     data["doxygen_version"] = doxy_version
0372     data["scope"] = scope
0373 
0374     md = render_markdown(data, doxy_version)
0375 
0376     if args.json:
0377         import json
0378 
0379         Path(args.json).write_text(json.dumps(data, indent=2) + "\n")
0380     if args.markdown == "-" or args.markdown is None:
0381         print(md)
0382     elif args.markdown:
0383         Path(args.markdown).write_text(md)
0384 
0385     summary = os.environ.get("GITHUB_STEP_SUMMARY")
0386     if args.summary and summary:
0387         with open(summary, "a") as fh:
0388             fh.write(md)
0389 
0390     return 0
0391 
0392 
0393 if __name__ == "__main__":
0394     sys.exit(main())