mirror of
https://github.com/DarkFlippers/unleashed-firmware.git
synced 2026-07-18 11:47:43 +00:00
223 lines
8.4 KiB
Python
223 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Summarize public firmware API changes between two api_symbols.csv revisions.
|
|
|
|
The public API surface available to external apps (.fap) is defined by
|
|
``targets/<hw>/api_symbols.csv``. Only entries with status ``+`` are exported and
|
|
callable by apps; ``-`` entries are known-but-disabled (e.g. STM32 HAL symbols).
|
|
|
|
A removed or signature-changed ``+`` Function/Variable, or a MAJOR version bump,
|
|
is *breaking* for prebuilt apps: they fail at load time with an API-version
|
|
mismatch (lib/flipper_application/application_manifest.c) or MissingImports.
|
|
Additive-only changes bump the MINOR version and keep existing apps compatible.
|
|
|
|
Emits a Markdown report (``--out``) and prints a JSON status line to stdout:
|
|
{"changed": <bool>, "breaking": <bool>}
|
|
|
|
Base/head sources:
|
|
* CI mode: --base-ref <git-ref> (head = working tree; all targets scanned)
|
|
* Test mode: --base-file / --head-file (single --target)
|
|
"""
|
|
|
|
import argparse
|
|
import csv
|
|
import io
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
def parse_api(text):
|
|
"""Parse api_symbols.csv text.
|
|
|
|
Returns (version, funcs, variables, headers) where funcs/variables/headers
|
|
only contain status ``+`` (exported) entries:
|
|
funcs: name -> (return_type, params)
|
|
variables: name -> type
|
|
headers: set(name)
|
|
"""
|
|
version = None
|
|
funcs, variables, headers = {}, {}, set()
|
|
for row in csv.reader(io.StringIO(text)):
|
|
if not row or row[0] == "entry":
|
|
continue
|
|
entry = row[0]
|
|
status = row[1] if len(row) > 1 else ""
|
|
name = row[2] if len(row) > 2 else ""
|
|
rtype = row[3] if len(row) > 3 else ""
|
|
params = row[4] if len(row) > 4 else ""
|
|
if entry == "Version":
|
|
version = name
|
|
continue
|
|
if status != "+":
|
|
continue # disabled / not exported to apps
|
|
if entry == "Function":
|
|
funcs[name] = (rtype, params)
|
|
elif entry == "Variable":
|
|
variables[name] = rtype
|
|
elif entry == "Header":
|
|
headers.add(name)
|
|
return version, funcs, variables, headers
|
|
|
|
|
|
def _ver_tuple(v):
|
|
try:
|
|
parts = v.split(".")
|
|
return int(parts[0]), (int(parts[1]) if len(parts) > 1 else 0)
|
|
except (ValueError, AttributeError, IndexError):
|
|
return None
|
|
|
|
|
|
def classify_version(old, new):
|
|
o, n = _ver_tuple(old), _ver_tuple(new)
|
|
if not o or not n or o == n:
|
|
return "none"
|
|
if n[0] > o[0]:
|
|
return "major"
|
|
if n[0] == o[0] and n[1] > o[1]:
|
|
return "minor"
|
|
return "other"
|
|
|
|
|
|
def _table(title, names, fmt):
|
|
if not names:
|
|
return []
|
|
out = [f"<details><summary>{title} ({len(names)})</summary>", "",
|
|
"| Symbol | Signature |", "|---|---|"]
|
|
out += [fmt(n) for n in names]
|
|
out += ["", "</details>", ""]
|
|
return out
|
|
|
|
|
|
def diff_target(target, base_text, head_text):
|
|
"""Return (markdown_section, changed, breaking) for one target."""
|
|
b_ver, b_func, b_var, b_hdr = parse_api(base_text)
|
|
h_ver, h_func, h_var, h_hdr = parse_api(head_text)
|
|
|
|
func_added = sorted(set(h_func) - set(b_func))
|
|
func_removed = sorted(set(b_func) - set(h_func))
|
|
func_changed = sorted(n for n in set(b_func) & set(h_func) if b_func[n] != h_func[n])
|
|
var_added = sorted(set(h_var) - set(b_var))
|
|
var_removed = sorted(set(b_var) - set(h_var))
|
|
var_changed = sorted(n for n in set(b_var) & set(h_var) if b_var[n] != h_var[n])
|
|
hdr_added = sorted(h_hdr - b_hdr)
|
|
hdr_removed = sorted(b_hdr - h_hdr)
|
|
vbump = classify_version(b_ver, h_ver)
|
|
|
|
changed = any([b_ver != h_ver, func_added, func_removed, func_changed,
|
|
var_added, var_removed, var_changed, hdr_added, hdr_removed])
|
|
breaking = bool(func_removed or func_changed or var_removed or var_changed
|
|
or vbump == "major")
|
|
if not changed:
|
|
return "", False, False
|
|
|
|
L = [f"### Target `{target}`"]
|
|
if b_ver != h_ver:
|
|
tag = {"major": " — ⚠️ **MAJOR (breaking)**",
|
|
"minor": " — additive",
|
|
"other": "", "none": ""}.get(vbump, "")
|
|
L.append(f"**SDK API version:** `{b_ver}` → `{h_ver}`{tag}")
|
|
else:
|
|
L.append(f"**SDK API version:** `{h_ver}` (unchanged)")
|
|
L.append("")
|
|
|
|
if func_removed or var_removed or func_changed or var_changed:
|
|
L.append("#### \U0001f534 Removed / changed (breaking)")
|
|
L += _table("Functions removed", func_removed,
|
|
lambda n: f"| `{n}` | `{b_func[n][0]} {n}({b_func[n][1]})` |")
|
|
L += _table("Functions changed", func_changed,
|
|
lambda n: f"| `{n}` | `{b_func[n][0]} ({b_func[n][1]})` → "
|
|
f"`{h_func[n][0]} ({h_func[n][1]})` |")
|
|
L += _table("Variables removed", var_removed,
|
|
lambda n: f"| `{n}` | `{b_var[n]}` |")
|
|
L += _table("Variables changed", var_changed,
|
|
lambda n: f"| `{n}` | `{b_var[n]}` → `{h_var[n]}` |")
|
|
|
|
if func_added or var_added:
|
|
L.append("#### \U0001f7e2 Added (non-breaking)")
|
|
L += _table("Functions added", func_added,
|
|
lambda n: f"| `{n}` | `{h_func[n][0]} {n}({h_func[n][1]})` |")
|
|
L += _table("Variables added", var_added,
|
|
lambda n: f"| `{n}` | `{h_var[n]}` |")
|
|
|
|
if hdr_added or hdr_removed:
|
|
bits = []
|
|
if hdr_added:
|
|
bits.append(f"{len(hdr_added)} added")
|
|
if hdr_removed:
|
|
bits.append(f"{len(hdr_removed)} removed")
|
|
L.append(f"#### \U0001f4c4 Headers: {', '.join(bits)}")
|
|
L += [f"- \U0001f7e2 `{n}`" for n in hdr_added]
|
|
L += [f"- \U0001f534 `{n}`" for n in hdr_removed]
|
|
L.append("")
|
|
|
|
if (func_removed or func_changed or var_removed or var_changed) and vbump != "major":
|
|
L.append("> ⚠️ **Note:** symbols were removed/changed but the major "
|
|
"version was not bumped. fbt normally forces a MAJOR bump on removal "
|
|
"— double-check `api_symbols.csv`.")
|
|
L.append("")
|
|
|
|
return "\n".join(L), changed, breaking
|
|
|
|
|
|
def git_show(ref, path):
|
|
"""Return file content at a git ref, or '' if it does not exist there."""
|
|
if not ref:
|
|
return ""
|
|
try:
|
|
return subprocess.check_output(
|
|
["git", "show", f"{ref}:{path}"], stderr=subprocess.DEVNULL
|
|
).decode("utf-8", "replace")
|
|
except subprocess.CalledProcessError:
|
|
return ""
|
|
|
|
|
|
def _read(path):
|
|
p = Path(path)
|
|
return p.read_text(encoding="utf-8", errors="replace") if p.exists() else ""
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--base-ref", help="git ref for the PR base commit")
|
|
ap.add_argument("--out", default="api_report.md")
|
|
ap.add_argument("--base-file", help="test mode: base csv path")
|
|
ap.add_argument("--head-file", help="test mode: head csv path")
|
|
ap.add_argument("--target", default="f7", help="target label for test mode")
|
|
args = ap.parse_args()
|
|
|
|
sections, any_changed, any_breaking = [], False, False
|
|
|
|
if args.base_file or args.head_file:
|
|
# Test mode: a single explicit base/head pair.
|
|
pairs = [(args.target, _read(args.base_file), _read(args.head_file))]
|
|
else:
|
|
# CI mode: every target, base from git, head from the working tree.
|
|
pairs = [(p.parent.name, git_show(args.base_ref, p.as_posix()), _read(p))
|
|
for p in sorted(Path("targets").glob("*/api_symbols.csv"))]
|
|
|
|
for target, base_text, head_text in pairs:
|
|
section, changed, breaking = diff_target(target, base_text, head_text)
|
|
if section:
|
|
sections.append(section)
|
|
any_changed |= changed
|
|
any_breaking |= breaking
|
|
|
|
if any_changed:
|
|
head = "## \U0001f4cb Public API changes\n"
|
|
if any_breaking:
|
|
head += ("\n> \U0001f534 **This PR changes the public app API in a breaking way.** "
|
|
"Prebuilt community apps (all-the-plugins) built against the old API "
|
|
"will fail to load until rebuilt.\n")
|
|
else:
|
|
head += "\n> \U0001f7e2 Additive API changes only — existing apps stay compatible.\n"
|
|
body = head + "\n" + "\n".join(sections)
|
|
else:
|
|
body = "## \U0001f4cb Public API changes\n\n✅ No public API changes in this PR.\n"
|
|
|
|
Path(args.out).write_text(body, encoding="utf-8")
|
|
print(json.dumps({"changed": any_changed, "breaking": any_breaking}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|