#!/usr/bin/env python3
"""Generate a top-N bar chart of translated counts and refresh README.
The chart uses the translated totals recorded in ``TEAM.md``
(``teammd_totals``), which the nightly maintenance run keeps in sync with
git-blame attribution while preserving Transifex-era and restored counts, and
draws a pastel bar chart saved to ``reports/contributor_stats_latest.png``
(fixed filename, overwritten each run). The README block between the
``STATS_START``/``STATS_END`` markers is refreshed with a new date, but only
when its content (counts or chart path) actually changed, so a nightly run
with no real changes doesn't create a commit.
Requires: ``pip install polib matplotlib``
Usage::
python3 scripts/contributor_chart.py # top 10 contributors
python3 scripts/contributor_chart.py --top-n 15 # different cutoff
python3 scripts/contributor_chart.py --dry-run # report only, no writes
"""
import argparse
import datetime
import re
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg") # must be selected before pyplot is imported
import matplotlib.pyplot as plt # noqa: E402
import matplotlib.ticker as mticker # noqa: E402
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from team_stats import teammd_totals # noqa: E402
CHART_DIR = REPO_ROOT / "reports"
CHART_PREFIX = "contributor_stats_"
CHART_FILENAME = f"{CHART_PREFIX}latest.png"
README_PATH = REPO_ROOT / "README.md"
STATS_START = ""
STATS_END = ""
DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}")
PASTEL_COLORS = [
"#A6C7E8",
"#B5EAD7",
"#FFDFD3",
"#FFF1AC",
"#E2D1F9",
"#FFD7BA",
"#FFABAB",
"#C7F0DB",
"#FFDAC1",
"#C7CEEA",
]
def chart_data(counts: dict[str, int], top_n: int) -> list[tuple[str, int]]:
"""Return contributors with a positive count, sorted, trimmed to top_n."""
eligible = [
(name, count)
for name, count in counts.items()
if name != "(unassigned)" and count > 0
]
return sorted(eligible, key=lambda item: item[1], reverse=True)[:top_n]
def draw_chart(data: list[tuple[str, int]], top_n: int) -> None:
usernames = [name for name, _ in data]
totals = [count for _, count in data]
colors = [PASTEL_COLORS[i % len(PASTEL_COLORS)] for i in range(len(usernames))]
plt.figure(figsize=(12, 7))
bars = plt.bar(usernames, totals, color=colors)
title = "User Contributions"
if top_n:
title += f" (Top {top_n})"
plt.title(title)
plt.xlabel("Username")
plt.ylabel("Translated Count")
plt.xticks(rotation=45, ha="right")
plt.gca().yaxis.set_major_locator(mticker.MaxNLocator(integer=True))
plt.grid(axis="y", linestyle="--", alpha=0.7)
for bar in bars:
height = bar.get_height()
if height > 0:
plt.text(
bar.get_x() + bar.get_width() / 2.0,
height + 0.1,
f"{int(height)}",
ha="center",
fontweight="bold",
)
plt.tight_layout()
def refresh_readme(
chart_file: Path,
dry_run: bool,
top_contribs: list[tuple[str, int]],
) -> bool:
text = README_PATH.read_text(encoding="utf-8")
date_iso = datetime.date.today().isoformat()
rel = chart_file.relative_to(REPO_ROOT).as_posix()
alt = " " + " ".join(
f"{name} {count}" for name, count in top_contribs
)
block = (
f"{STATS_START}\n"
"### \n"
f"\n"
f"(: {date_iso})\n"
f"{STATS_END}"
)
pattern = re.compile(
re.escape(STATS_START) + ".*?" + re.escape(STATS_END),
flags=re.DOTALL,
)
match = pattern.search(text)
if not match:
print(f" error: could not find {STATS_START}/{STATS_END} in {README_PATH}")
return False
# Ignore the date when deciding whether anything changed; otherwise the
# block differs every day and the nightly run always makes a commit.
if DATE_RE.sub("", match.group(0)) == DATE_RE.sub("", block):
print(" README block unchanged (ignoring date)")
return False
new_text = pattern.sub(lambda _: block, text)
if dry_run:
print("[dry-run] would update README stats block")
else:
README_PATH.write_text(new_text, encoding="utf-8")
print(f"Updated README stats block to {rel} ({date_iso})")
return True
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--top-n",
type=int,
default=10,
help="limit the chart to the top N contributors (default: 10)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="print what would be done without changing files",
)
args = parser.parse_args()
counts = teammd_totals()
data = chart_data(counts, args.top_n)
if not data:
print("No contributor data to generate chart.")
return
out_path = CHART_DIR / CHART_FILENAME
draw_chart(data, args.top_n)
if args.dry_run:
print(f"[dry-run] would save chart to {out_path}")
else:
CHART_DIR.mkdir(parents=True, exist_ok=True)
# Software=None drops the matplotlib version string from the PNG
# metadata, so upgrading matplotlib alone doesn't change the file.
plt.savefig(out_path, metadata={"Software": None})
print(f"Saved user contributions chart to {out_path}")
plt.close()
refresh_readme(out_path, args.dry_run, data)
if __name__ == "__main__":
main()