mirror of
https://github.com/nlohmann/json.git
synced 2026-07-12 13:35:13 +00:00
0663907b68
extract_api.py's own extraction wasn't deterministic across machines, which CI's drift check caught immediately: JSON_HAS_RANGES auto-detects via the standard library's __cpp_lib_ranges feature-test macro, which isn't reliably gated to C++20 mode by every stdlib -- undefined under -std=c++17 with macOS's libc++, but defined under the identical flag with the Ubuntu stdlib CI uses, so parse()/accept()/from_*() extracted different signatures purely depending on which machine ran the extraction. Pinned to -DJSON_HAS_RANGES=0: the deterministic and safe choice, since pinning to 1 was tried first and found to fail to parse on a stdlib without full <ranges> support even when the macro claims otherwise. Also found and fixed a second, independent source of the same class of drift: get_identity_name() used cursor.spelling verbatim for CONVERSION_FUNCTION cursors, which libclang renders as its own internally-canonicalized form of the return type rather than what's literally written. Confirmed for json_pointer::operator string_t() spelling differently on two machines pinned to the identical libclang==18.1.1 wheel, with the JSON_HAS_RANGES fix above ruled out as the cause. Now derived from the cursor's own raw source text instead, immune to libclang's dependent-type resolution differences and incidentally more readable than the libclang-internal forms it replaces. Bumped SURFACE_FORMAT_VERSION to 3 and regenerated all 27 history snapshots and the committed api_surface.json; both fixes are documented in tools/api_checker/history/README.md's format-history log. Also fixes diff_api.py's format_version guard, which only compared the two loaded surfaces against each other and never against SURFACE_FORMAT_VERSION (what this build actually understands) -- two surfaces on the same, newer-than-expected format_version would have silently passed the guard. Remaining fixes are the concretely actionable findings from Codacy's review of the new tools/api_checker/ files: unused imports/variables, a stray f-string with no placeholders. Left the docstring-formatting nitpicks (pydocstyle D2xx/D4xx) and generic subprocess-usage notices alone -- the former has no established convention elsewhere in this codebase's Python tooling to conform to, and the latter are inherent to a dev tool that shells out to git/clang with developer-controlled arguments, not user input. Signed-off-by: Niels Lohmann <mail@nlohmann.me> Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Niels Lohmann <mail@nlohmann.me>
296 lines
14 KiB
Python
Executable File
296 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Diff the public API surface between two refs to flag breaking vs. feature changes.
|
|
|
|
A "ref" for --old/--new is resolved in this order:
|
|
1. A stored, committed historical record at tools/api_checker/history/<ref>.json, if one exists
|
|
and --no-history wasn't passed (fast path -- no libclang/git-archive needed).
|
|
2. Live extraction: check the ref out via `git archive` into a temp dir and run extract_api.py
|
|
against it (needed for HEAD, branches, or any tag not yet backfilled into history/).
|
|
--old-file/--new-file bypass both and load an arbitrary surface JSON file directly.
|
|
|
|
Uses extract_api.py's --surface-output (identity-only: scope, kind, name, identity_name, tier,
|
|
signature, pretty_signature -- no location, no doc_url) for both sides, so the diff reflects only
|
|
genuine API changes, never unrelated code motion or documentation-site restructuring. Identity is
|
|
(scope, identity_name, kind, signature) -- see extract_api.py's identity_key()/get_signature_text()
|
|
docstrings for the full history of why this is what it is: a naive {scope,name,kind,params} key
|
|
silently collided on overloads differing only by constness/SFINAE; switching to libclang's USR
|
|
fixed that but encoded the *enclosing class template's own arity* into every member's identity, so
|
|
a single backward-compatible template-parameter addition (confirmed via real release tags
|
|
v3.11.2->v3.11.3) made ~228 of 330 entries look "changed" for a release with no real breaking
|
|
changes. The current raw-source-text-signature approach was arrived at, and each of several further
|
|
refinements verified, by testing against real historical releases -- not by inspecting code alone.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from collections import defaultdict
|
|
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
HISTORY_DIR = os.path.join(SCRIPT_DIR, 'history')
|
|
|
|
# The format_version this build of diff_api.py understands. Kept in sync with
|
|
# extract_api.py's SURFACE_FORMAT_VERSION by convention (both bump together whenever the
|
|
# identity-computing algorithm changes) -- imported directly so there's exactly one source of
|
|
# truth, not two constants that can drift apart.
|
|
sys.path.insert(0, SCRIPT_DIR)
|
|
from extract_api import SURFACE_FORMAT_VERSION # noqa: E402
|
|
|
|
|
|
def resolve_commit_sha(ref: str) -> str | None:
|
|
"""Resolve a ref to its full commit sha, or None if that fails."""
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'rev-parse', ref],
|
|
capture_output=True, text=True, check=True, timeout=10
|
|
)
|
|
return result.stdout.strip()
|
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
|
return None
|
|
|
|
|
|
def extract_surface_for_ref(ref: str, header: str = 'include/nlohmann/json.hpp',
|
|
include: str = 'include') -> dict:
|
|
"""Check out the full include/ tree at `ref` into a temp dir and extract its API surface.
|
|
|
|
A single-file checkout of json.hpp is not enough: json.hpp #includes dozens of other
|
|
headers under nlohmann/detail/ that must exist at the same ref for parsing to succeed.
|
|
|
|
Returns the full loaded surface JSON (format_version, meta, public_api list), with `ref` and
|
|
the resolved commit sha recorded in meta -- callers that only care about the diff can ignore
|
|
these; snapshot_release.py uses them as a historical record's provenance.
|
|
"""
|
|
with tempfile.TemporaryDirectory(prefix='api_checker_') as tmpdir:
|
|
archive = subprocess.run(
|
|
['git', 'archive', ref, '--', 'include'],
|
|
capture_output=True, check=True
|
|
)
|
|
subprocess.run(['tar', '-x', '-C', tmpdir], input=archive.stdout, check=True)
|
|
|
|
ref_include = os.path.join(tmpdir, include)
|
|
ref_header = os.path.join(tmpdir, header)
|
|
if not os.path.exists(ref_header):
|
|
print(f"Error: {header} does not exist at ref {ref}")
|
|
sys.exit(1)
|
|
|
|
surface_output = os.path.join(tmpdir, 'surface.json')
|
|
result = subprocess.run(
|
|
[sys.executable, os.path.join(SCRIPT_DIR, 'extract_api.py'),
|
|
'--header', ref_header,
|
|
'--include', ref_include,
|
|
'--output', os.path.join(tmpdir, 'snapshot.json'),
|
|
'--surface-output', surface_output],
|
|
capture_output=True, text=True
|
|
)
|
|
if result.returncode != 0:
|
|
print(f"Error extracting API at ref {ref}:")
|
|
print(result.stdout)
|
|
print(result.stderr)
|
|
sys.exit(1)
|
|
|
|
with open(surface_output) as f:
|
|
surface = json.load(f)
|
|
|
|
# extract_api.py resolved 'extracted_from' relative to *its own* invocation cwd/repo-root
|
|
# detection, which inside this temp checkout is meaningless (a path like
|
|
# "../../../../var/folders/.../tmp.XXXX/include/nlohmann/json.hpp") -- overwrite with the
|
|
# stable, repo-relative header path actually passed in, so a stored history file's metadata
|
|
# doesn't embed a throwaway temp directory name.
|
|
surface.setdefault('meta', {})['extracted_from'] = header
|
|
surface['meta']['ref'] = ref
|
|
commit_sha = resolve_commit_sha(ref)
|
|
if commit_sha:
|
|
surface['meta']['commit'] = commit_sha
|
|
return surface
|
|
|
|
|
|
def load_surface_file(path: str) -> dict:
|
|
"""Load a surface JSON file from disk (a stored history/ record or an explicit --old-file/--new-file)."""
|
|
with open(path) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def resolve_surface(ref: str | None, explicit_file: str | None, header: str, include: str,
|
|
no_history: bool) -> tuple:
|
|
"""Resolve one side of a diff. Returns (surface_dict, description_string_for_display)."""
|
|
if explicit_file:
|
|
return load_surface_file(explicit_file), f"file:{explicit_file}"
|
|
|
|
history_path = os.path.join(HISTORY_DIR, f"{ref}.json")
|
|
if not no_history and os.path.exists(history_path):
|
|
return load_surface_file(history_path), f"history:{ref}"
|
|
|
|
return extract_surface_for_ref(ref, header, include), f"live:{ref}"
|
|
|
|
|
|
def build_identity_dict(public_api: list) -> dict:
|
|
"""Group a surface's public_api list by identity for diffing.
|
|
|
|
Identity is (scope, identity_name, kind, signature) -- the same four components
|
|
extract_api.py's identity_key() joins into one opaque string internally, exposed here as
|
|
explicit fields on each record (see extract_api.py's write_surface()) so this reconstruction
|
|
is a plain, visible tuple lookup rather than decoding anything.
|
|
"""
|
|
return {
|
|
(entry['scope'], entry['identity_name'], entry['kind'], entry['signature']): entry
|
|
for entry in public_api
|
|
}
|
|
|
|
|
|
def diff_surfaces(old_api: dict, new_api: dict) -> dict:
|
|
"""Compare two API surfaces by identity key, grouping same-(scope,name) changes."""
|
|
old_keys = set(old_api.keys())
|
|
new_keys = set(new_api.keys())
|
|
|
|
added_keys = new_keys - old_keys
|
|
removed_keys = old_keys - new_keys
|
|
|
|
# Group removed/added entries by (scope, name) to detect "changed overload" pairs,
|
|
# rather than reporting a same-named removal+addition as two unrelated changes.
|
|
removed_by_name = defaultdict(list)
|
|
for key in removed_keys:
|
|
entry = old_api[key]
|
|
removed_by_name[(entry['scope'], entry['name'])].append(key)
|
|
|
|
added_by_name = defaultdict(list)
|
|
for key in added_keys:
|
|
entry = new_api[key]
|
|
added_by_name[(entry['scope'], entry['name'])].append(key)
|
|
|
|
changed = []
|
|
pure_added = []
|
|
pure_removed = []
|
|
|
|
for name, removed_list in removed_by_name.items():
|
|
if name in added_by_name:
|
|
added_list = added_by_name.pop(name)
|
|
for key in removed_list:
|
|
changed.append({'scope': name[0], 'name': name[1],
|
|
'old': old_api[key]['pretty_signature'],
|
|
'new': new_api[added_list[0]]['pretty_signature']})
|
|
else:
|
|
for key in removed_list:
|
|
pure_removed.append(old_api[key])
|
|
|
|
for name, added_list in added_by_name.items():
|
|
for key in added_list:
|
|
pure_added.append(new_api[key])
|
|
|
|
return {
|
|
'added': sorted(pure_added, key=lambda e: e['pretty_signature']),
|
|
'removed': sorted(pure_removed, key=lambda e: e['pretty_signature']),
|
|
'changed': sorted(changed, key=lambda e: (e['scope'], e['name'])),
|
|
}
|
|
|
|
|
|
def print_diff(diff: dict, old_desc: str, new_desc: str) -> bool:
|
|
"""Print a human-readable diff report. Returns True if breaking changes were found."""
|
|
print(120 * "-")
|
|
print(f"API Diff: {old_desc} -> {new_desc}")
|
|
print(120 * "-")
|
|
|
|
if diff['added']:
|
|
print(f"\nAdded ({len(diff['added'])} entries) -- feature:")
|
|
for entry in diff['added']:
|
|
print(f" + {entry['pretty_signature']}")
|
|
|
|
if diff['removed']:
|
|
print(f"\nRemoved ({len(diff['removed'])} entries) -- BREAKING:")
|
|
for entry in diff['removed']:
|
|
print(f" - {entry['pretty_signature']}")
|
|
|
|
if diff['changed']:
|
|
print(f"\nChanged overloads ({len(diff['changed'])} entries) -- BREAKING by default:")
|
|
for entry in diff['changed']:
|
|
print(f" ~ {entry['old']} -> {entry['new']}")
|
|
|
|
if not any([diff['added'], diff['removed'], diff['changed']]):
|
|
print("\nNo API changes detected")
|
|
|
|
print(120 * "-")
|
|
|
|
breaking = bool(diff['removed'] or diff['changed'])
|
|
if breaking:
|
|
print("\nWARNING: Potential BREAKING CHANGES detected:")
|
|
print(f" - {len(diff['removed'])} removed entries")
|
|
print(f" - {len(diff['changed'])} changed overloads")
|
|
if diff['added']:
|
|
print(f"\nNew features: {len(diff['added'])} added entries")
|
|
|
|
return breaking
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description='Diff public API surface between two refs')
|
|
parser.add_argument('--old', help='Old git ref (tag/commit) -- checks tools/api_checker/history/ first, '
|
|
'then falls back to live extraction')
|
|
parser.add_argument('--new', help='New git ref (default: HEAD unless --new-file is given)')
|
|
parser.add_argument('--old-file', help='Path to a stored surface JSON file for the "old" side '
|
|
'(bypasses git and tools/api_checker/history/ entirely)')
|
|
parser.add_argument('--new-file', help='Path to a stored surface JSON file for the "new" side '
|
|
'(bypasses git and tools/api_checker/history/ entirely)')
|
|
parser.add_argument('--no-history', action='store_true',
|
|
help='Force live extraction even if a matching tools/api_checker/history/<ref>.json '
|
|
'exists -- useful to check a stored record is still faithful to a fresh run')
|
|
parser.add_argument('--allow-format-mismatch', action='store_true',
|
|
help='Proceed even if the two surfaces have different format_version (results may '
|
|
'be unsound -- the identity algorithm may differ between versions)')
|
|
parser.add_argument('--header', default='include/nlohmann/json.hpp',
|
|
help='Header file to analyze (relative to repo root, live-extraction only)')
|
|
parser.add_argument('--include', default='include',
|
|
help='Include directory (relative to repo root, live-extraction only)')
|
|
parser.add_argument('--fail-on-breaking', action='store_true',
|
|
help='Exit with status 1 if breaking changes are detected')
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.old and args.old_file:
|
|
parser.error('--old and --old-file are mutually exclusive')
|
|
if not args.old and not args.old_file:
|
|
parser.error('one of --old or --old-file is required')
|
|
if args.new and args.new_file:
|
|
parser.error('--new and --new-file are mutually exclusive')
|
|
|
|
new_ref = args.new or ('HEAD' if not args.new_file else None)
|
|
|
|
print(f"Resolving old surface ({args.old_file or args.old})...")
|
|
old_surface, old_desc = resolve_surface(args.old, args.old_file, args.header, args.include, args.no_history)
|
|
|
|
print(f"Resolving new surface ({args.new_file or new_ref})...")
|
|
new_surface, new_desc = resolve_surface(new_ref, args.new_file, args.header, args.include, args.no_history)
|
|
|
|
old_fmt = old_surface.get('format_version')
|
|
new_fmt = new_surface.get('format_version')
|
|
# Check both surfaces against SURFACE_FORMAT_VERSION (what *this build* of diff_api.py
|
|
# understands), not just against each other: two surfaces on the same format_version could
|
|
# still both be on a version this build predates and doesn't correctly interpret.
|
|
mismatched = old_fmt != SURFACE_FORMAT_VERSION or new_fmt != SURFACE_FORMAT_VERSION
|
|
if mismatched and not args.allow_format_mismatch:
|
|
print(f"Error: format_version mismatch. This build of diff_api.py understands "
|
|
f"format_version {SURFACE_FORMAT_VERSION!r}, but {old_desc} is {old_fmt!r} and "
|
|
f"{new_desc} is {new_fmt!r}.")
|
|
print("The identity/signature algorithm may differ between these two surfaces, making a diff unsound.")
|
|
print("Re-run with --allow-format-mismatch to proceed anyway.")
|
|
sys.exit(1)
|
|
elif mismatched:
|
|
print(f"WARNING: proceeding with mismatched format_version (this build understands "
|
|
f"{SURFACE_FORMAT_VERSION!r}; {old_desc}={old_fmt!r}, {new_desc}={new_fmt!r}) as requested.")
|
|
|
|
old_api = build_identity_dict(old_surface['public_api'])
|
|
new_api = build_identity_dict(new_surface['public_api'])
|
|
|
|
print(f"\nComparing {len(old_api)} old entries with {len(new_api)} new entries...")
|
|
diff = diff_surfaces(old_api, new_api)
|
|
|
|
breaking = print_diff(diff, old_desc, new_desc)
|
|
|
|
if args.fail_on_breaking and breaking:
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|