chore: add firmware size history script (#1235)
## Summary **What is the goal of this PR?** - Adds `scripts/firmware_size_history.py`, a developer tool that builds firmware at selected git commits and reports flash usage with deltas between them. - Supports two input modes: `--range START END` to walk every commit in a range, or `--commits REF [REF ...]` to compare specific refs (which can span branches). - Defaults to a human-readable aligned table; pass `--csv` for machine-readable output to stdout or `--csv FILE` to write to a file. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**YES, fully written by AI**_
This commit is contained in:
291
scripts/firmware_size_history.py
Executable file
291
scripts/firmware_size_history.py
Executable file
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build firmware at selected commits and report flash usage.
|
||||
|
||||
Two modes (mutually exclusive, one required):
|
||||
|
||||
--range START END Walk every commit from START (exclusive baseline) to END
|
||||
(inclusive), oldest-first. START is also built so the
|
||||
first delta can be computed. Both refs must lie on the
|
||||
same ancestry path (i.e. one branch).
|
||||
|
||||
--commits REF [...] Build each REF in the order given. Refs may be SHAs,
|
||||
branch names, tags, or relative refs like HEAD~3. They
|
||||
can come from different branches.
|
||||
|
||||
Common options:
|
||||
--env ENV PlatformIO build environment (default: "default")
|
||||
--csv [FILE] Output as CSV. Without FILE, writes to stdout.
|
||||
|
||||
Output is a human-readable table by default. Use --csv for machine-readable
|
||||
output.
|
||||
|
||||
Examples:
|
||||
python3 scripts/firmware_size_history.py --range HEAD~5 HEAD
|
||||
python3 scripts/firmware_size_history.py --range abc1234 def5678 --env gh_release --csv sizes.csv
|
||||
python3 scripts/firmware_size_history.py --commits main feature/new-parser
|
||||
python3 scripts/firmware_size_history.py --commits abc1234 def5678 ghi9012 --csv
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
FLASH_RE = re.compile(
|
||||
r"Flash:.*?(\d+)\s+bytes\s+from\s+(\d+)\s+bytes"
|
||||
)
|
||||
BOX_CHAR = "\u2500"
|
||||
|
||||
|
||||
def run(cmd, capture=True, check=True):
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=capture, text=True, check=check
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def resolve_ref(ref):
|
||||
"""Resolve a git ref to (full_sha, title), or sys.exit with a message."""
|
||||
r = run(["git", "rev-parse", "--verify", ref], check=False)
|
||||
if r.returncode != 0:
|
||||
print(f"[error] Could not resolve ref '{ref}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
sha = r.stdout.strip()
|
||||
title = run(["git", "log", "-1", "--format=%s", sha]).stdout.strip()
|
||||
return sha, title
|
||||
|
||||
|
||||
def git_current_ref():
|
||||
"""Return the current branch name, or the detached commit hash."""
|
||||
r = run(["git", "symbolic-ref", "--short", "HEAD"], check=False)
|
||||
if r.returncode == 0:
|
||||
return r.stdout.strip()
|
||||
return run(["git", "rev-parse", "HEAD"]).stdout.strip()
|
||||
|
||||
|
||||
def git_commit_list(start, end):
|
||||
"""Return list of (hash, title) from start (exclusive) to end (inclusive), oldest first."""
|
||||
r = run([
|
||||
"git", "log", "--reverse", "--format=%H %s",
|
||||
f"{start}..{end}",
|
||||
])
|
||||
commits = []
|
||||
for line in r.stdout.strip().splitlines():
|
||||
if not line:
|
||||
continue
|
||||
sha, title = line.split(" ", 1)
|
||||
commits.append((sha, title))
|
||||
return commits
|
||||
|
||||
|
||||
def git_checkout(ref):
|
||||
run(["git", "checkout", "--detach", ref], check=True)
|
||||
|
||||
|
||||
def build_firmware(env):
|
||||
"""Run pio build and return the raw combined stdout+stderr."""
|
||||
result = subprocess.run(
|
||||
["pio", "run", "-e", env],
|
||||
capture_output=True, text=True, check=False
|
||||
)
|
||||
return result.returncode, result.stdout + "\n" + result.stderr
|
||||
|
||||
|
||||
def parse_flash_used(output):
|
||||
"""Extract used-bytes integer from PlatformIO output, or None."""
|
||||
m = FLASH_RE.search(output)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def write_csv(out, rows, fieldnames):
|
||||
"""Write rows as CSV to a file-like object."""
|
||||
writer = csv.DictWriter(out, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def format_table(rows):
|
||||
"""Print rows as an aligned human-readable table to stdout."""
|
||||
COL_COMMIT = 10
|
||||
COL_FLASH = 11
|
||||
COL_DELTA = 7
|
||||
|
||||
def fmt_flash(val):
|
||||
if val == "FAILED":
|
||||
return "FAILED"
|
||||
return f"{val:,}"
|
||||
|
||||
def fmt_delta(val):
|
||||
if val == "" or val is None:
|
||||
return ""
|
||||
return f"{val:+,}"
|
||||
|
||||
header = (
|
||||
f"{'Commit':<{COL_COMMIT}} "
|
||||
f"{'Flash':>{COL_FLASH}} "
|
||||
f"{'Delta':>{COL_DELTA}} "
|
||||
f"Title"
|
||||
)
|
||||
sep = (
|
||||
f"{BOX_CHAR * COL_COMMIT} "
|
||||
f"{BOX_CHAR * COL_FLASH} "
|
||||
f"{BOX_CHAR * COL_DELTA} "
|
||||
f"{BOX_CHAR * 40}"
|
||||
)
|
||||
print(header)
|
||||
print(sep)
|
||||
for row in rows:
|
||||
flash_str = fmt_flash(row["flash_bytes"])
|
||||
delta_str = fmt_delta(row["delta"])
|
||||
print(
|
||||
f"{row['commit']:<{COL_COMMIT}} "
|
||||
f"{flash_str:>{COL_FLASH}} "
|
||||
f"{delta_str:>{COL_DELTA}} "
|
||||
f"{row['title']}"
|
||||
)
|
||||
|
||||
|
||||
def build_commits_from_range(start, end):
|
||||
"""Validate a range and return (all_commits, description) for the build loop."""
|
||||
start_sha, start_title = resolve_ref(start)
|
||||
resolve_ref(end)
|
||||
|
||||
commits = git_commit_list(start, end)
|
||||
if not commits:
|
||||
print(f"[error] No commits found in range {start}..{end}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
all_commits = [(start_sha, start_title)] + commits
|
||||
desc = f"{len(all_commits)} commits (1 baseline + {len(commits)} in range)"
|
||||
return all_commits, desc
|
||||
|
||||
|
||||
def build_commits_from_list(refs):
|
||||
"""Resolve each ref and return (all_commits, description) for the build loop."""
|
||||
all_commits = [resolve_ref(ref) for ref in refs]
|
||||
desc = f"{len(all_commits)} commit{'s' if len(all_commits) != 1 else ''}"
|
||||
return all_commits, desc
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Measure firmware flash size across git commits.",
|
||||
epilog=(
|
||||
"Range mode walks every commit between START and END (one branch). "
|
||||
"List mode builds specific refs that may come from different branches."
|
||||
),
|
||||
)
|
||||
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument(
|
||||
"--range", nargs=2, metavar=("START", "END"),
|
||||
help="Older commit (exclusive baseline) and newer commit (inclusive)",
|
||||
)
|
||||
mode.add_argument(
|
||||
"--commits", nargs="+", metavar="REF",
|
||||
help="One or more git refs to build (SHAs, branches, tags, HEAD~N, ...)",
|
||||
)
|
||||
|
||||
parser.add_argument("--env", default="default", help="PlatformIO environment (default: 'default')")
|
||||
parser.add_argument(
|
||||
"--csv", nargs="?", const="-", default=None, metavar="FILE",
|
||||
help="Output as CSV (default: stdout, or specify FILE)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate refs before touching the working tree so a bad ref never
|
||||
# leaves uncommitted changes stranded in the stash.
|
||||
if args.range:
|
||||
all_commits, desc = build_commits_from_range(args.range[0], args.range[1])
|
||||
is_range = True
|
||||
else:
|
||||
all_commits, desc = build_commits_from_list(args.commits)
|
||||
is_range = False
|
||||
|
||||
original_ref = git_current_ref()
|
||||
print(f"[info] Will restore to '{original_ref}' when finished.", file=sys.stderr)
|
||||
|
||||
stash_needed = False
|
||||
status = run(["git", "status", "--porcelain"]).stdout.strip()
|
||||
if status:
|
||||
print("[info] Stashing uncommitted changes...", file=sys.stderr)
|
||||
run(["git", "stash", "push", "-m", "firmware_size_history auto-stash"])
|
||||
stash_needed = True
|
||||
|
||||
print(f"[info] Building {desc}...", file=sys.stderr)
|
||||
|
||||
results = []
|
||||
try:
|
||||
for i, (sha, title) in enumerate(all_commits):
|
||||
short = sha[:10]
|
||||
if is_range:
|
||||
label = "baseline" if i == 0 else f"{i}/{len(all_commits) - 1}"
|
||||
else:
|
||||
label = f"{i + 1}/{len(all_commits)}"
|
||||
print(f"\n[{label}] {short} {title}", file=sys.stderr)
|
||||
|
||||
git_checkout(sha)
|
||||
|
||||
print(f" Building (env: {args.env})...", file=sys.stderr)
|
||||
rc, output = build_firmware(args.env)
|
||||
|
||||
if rc != 0:
|
||||
print(f" BUILD FAILED (exit {rc}) -- skipping", file=sys.stderr)
|
||||
results.append((sha, title, None))
|
||||
continue
|
||||
|
||||
used = parse_flash_used(output)
|
||||
if used is None:
|
||||
print(" Could not parse flash size from output -- skipping", file=sys.stderr)
|
||||
results.append((sha, title, None))
|
||||
continue
|
||||
|
||||
print(f" Flash used: {used:,} bytes", file=sys.stderr)
|
||||
results.append((sha, title, used))
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n[info] Interrupted -- writing partial results.", file=sys.stderr)
|
||||
finally:
|
||||
print(f"\n[info] Restoring '{original_ref}'...", file=sys.stderr)
|
||||
run(["git", "checkout", original_ref], check=False)
|
||||
if stash_needed:
|
||||
print("[info] Restoring stashed changes...", file=sys.stderr)
|
||||
run(["git", "stash", "pop"], check=False)
|
||||
|
||||
# Build result rows with deltas
|
||||
rows = []
|
||||
prev_size = None
|
||||
for sha, title, used in results:
|
||||
if used is not None and prev_size is not None:
|
||||
delta = used - prev_size
|
||||
else:
|
||||
delta = ""
|
||||
rows.append({
|
||||
"commit": sha[:10],
|
||||
"title": title,
|
||||
"flash_bytes": used if used is not None else "FAILED",
|
||||
"delta": delta,
|
||||
})
|
||||
if used is not None:
|
||||
prev_size = used
|
||||
|
||||
fieldnames = ["commit", "title", "flash_bytes", "delta"]
|
||||
|
||||
if args.csv is not None:
|
||||
if args.csv == "-":
|
||||
write_csv(sys.stdout, rows, fieldnames)
|
||||
else:
|
||||
with open(args.csv, "w", newline="") as f:
|
||||
write_csv(f, rows, fieldnames)
|
||||
print(f"\n[done] Wrote {args.csv}", file=sys.stderr)
|
||||
else:
|
||||
print()
|
||||
format_table(rows)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user