Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Prerequisites for the full `make check`: Python 3.10 or newer, Node 18 or newer
| `check_issue_refs.py` | A `#NNN` in ROADMAP, CHANGELOG, CONTRIBUTORS or METRICS is not a real issue or PR | Correct the number |
| `check_explainer_count.py` | An "N explainers" figure, or an explainer listing in the README or `llms.txt`, is stale | `make fix-explainer-count` (a missing listing has to be added by hand) |
| `check_explainer_structure.py` | An explainer is missing a required section (definition, why it matters, concrete example, detection code, limitations, related concepts, further reading), has no `python` block, has fewer than two Further Reading entries, or lacks the footer line | Add the section (the check accepts the usual heading variants, e.g. "Why This Matters"); pages with a known gap are listed in `KNOWN_EXCEPTIONS` in the script, and an exception is removed when its page is fixed |
| `check_traction.py` | Stars, forks, contributors, watching, social reach, countries or audits differ between README, ROADMAP and METRICS, or the README's "Show N completed items" differs from its `- [x]` lines | Edit the figure in the file(s) the message names so they agree; the README's "External Contributors" is one below the total; `--live` (opt-in, needs `gh`, run weekly by `traction-live.yml`) also compares stars, forks, watching and contributors with GitHub |
| `check_traction.py` | Stars, forks, contributors, watching, social reach, countries or audits differ between README, ROADMAP and METRICS, or the README's "Show N completed items" differs from its `- [x]` lines | Edit the figure in the file(s) the message names so they agree; the README's "External Contributors" is one below the total; `--live` (opt-in, needs `gh`, run weekly by `traction-live.yml`) also compares stars, forks, watching, contributors and Issues Closed for the latest METRICS ISO week with GitHub |
| `check_dataset_sources.py` | A URL in a comment of an audit's `unfair.py` or `fair.py` is not on that audit's `**Dataset:**` line in the README | Change the link in both places so they match (a script with no link is fine) |
| `check_contributors.py` | An entry in CONTRIBUTORS.md has a merged-PR count or first-merged date that differs from `gh pr list --state merged` (counted only up to the PR the snapshot line names), an entry is out of order (PRs, then commits, then first merge), or a merged-PR author has no entry | Edit the entry's `**N merged PRs · M commits · first merged DATE**` line or move it; run it offline with `--prs FILE` (JSON from `gh pr list --state merged --limit 5000 --json number,author,mergedAt`); it is skipped when `gh` is missing |
| `key-fold-check` | The JavaScript case-folding table `KEY_FOLD` no longer matches Python | `make key-fold`, then commit `assets/profiler-engine.js` |
Expand Down
83 changes: 68 additions & 15 deletions scripts/check_traction.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@
count has its own checker, scripts/check_explainer_count.py.

python3 scripts/check_traction.py
python3 scripts/check_traction.py --live # also compare with GitHub (needs gh; opt-in) (#1148)
python3 scripts/check_traction.py --live-data FILE # same, from a saved {"stars", "forks", "watching", "contributors"}
python3 scripts/check_traction.py --live # compare live figures, including Issues Closed for the latest ISO week
python3 scripts/check_traction.py --live-data FILE # same, from saved GitHub figures including issues_closed

Exit code: 0 = everything agrees, 1 = at least one disagreement or a figure that could not be found.

The files can all agree and still be stale (stars were 52 in all three while GitHub said 53), so --live reads
stars, forks, watchers (subscribers) and contributors without bots from `gh api` and names every place whose
stars, forks, watchers (subscribers), contributors without bots, and Issues Closed for the latest ISO week from `gh api` and names every place whose
figure differs. It is off by default, so `make lint` and a pull request never depend on the network; the weekly
traction workflow runs it. The social reach, countries and audits figures have no GitHub source and are not
compared. Contributors count everyone the contributors API lists who is not a bot, owner included, matching
Expand All @@ -33,11 +33,12 @@
import shutil
import subprocess
import sys
from datetime import date, timedelta
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
REPO = "yakew7/Fair-Code"
LIVE_FIGURES = ("stars", "forks", "watching", "contributors")
LIVE_FIGURES = ("stars", "forks", "watching", "contributors", "issues_closed")

FIGURES = ("stars", "contributors", "forks", "watching", "social", "countries", "audits")

Expand All @@ -60,7 +61,7 @@
"Watching": "watching", "Countries": "countries"}
# | Week | Stars | Forks | Watching | Contributors | Social Views | Countries | Issues Closed | Code Audits |
METRICS_ROW_COLUMNS = {1: "stars", 2: "forks", 3: "watching", 4: "contributors", 5: "social",
6: "countries", 8: "audits"}
6: "countries", 7: "issues_closed", 8: "audits"}

# Observations are (figure, where, value), value being the number as written, e.g. "30K+" or "51".
Observation = tuple[str, str, str]
Expand Down Expand Up @@ -125,6 +126,28 @@ def metrics_observations(text: str) -> tuple[list[Observation], list[str]]:
return found, problems


def latest_metrics_week(text: str) -> str | None:
"""The ISO year-week on the newest weekly row, or None when no row exists."""
rows = [line for line in text.splitlines() if re.match(r"\|\s*\d{4}-W\d+", line)]
if not rows:
return None
cells = [_clean(cell) for cell in rows[-1].strip().strip("|").split("|")]
return cells[0] if cells else None


def iso_week_bounds(week: str) -> tuple[str, str]:
"""Inclusive Monday and Sunday dates for an ISO year-week label."""
match = re.fullmatch(r"(?P<year>\d{4})-W(?P<week>\d{2})", week)
if not match:
raise ValueError(f"METRICS.md: latest week {week!r} is not a valid ISO year-week")
try:
monday = date.fromisocalendar(int(match["year"]), int(match["week"]), 1)
except ValueError as exc:
raise ValueError(f"METRICS.md: latest week {week!r} is not a valid ISO year-week") from exc
sunday = monday + timedelta(days=6)
return monday.isoformat(), sunday.isoformat()


def completed_items(text: str) -> tuple[int | None, int | None]:
"""(claimed, counted) for the README's "Show N completed items" section; None if not found."""
m = re.search(r"Show (\d+) completed items", text)
Expand Down Expand Up @@ -166,23 +189,37 @@ def check(root: Path = ROOT) -> list[str]:
return problems


def fetch_live(repo: str = REPO) -> dict[str, int] | None:
"""{"stars", "forks", "watching", "contributors"} from `gh api`, or None when gh is missing or fails."""
def fetch_live(repo: str = REPO, week_bounds: tuple[str, str] | None = None) -> dict[str, int] | None:
"""Live repository figures and, when requested, closed issues for an inclusive ISO-week date range."""
if shutil.which("gh") is None:
return None

def api(*args: str):
done = subprocess.run(["gh", "api", *args], capture_output=True, text=True)
return json.loads(done.stdout) if done.returncode == 0 and done.stdout.strip() else None
if done.returncode != 0 or not done.stdout.strip():
return None
try:
return json.loads(done.stdout)
except json.JSONDecodeError:
return None

info = api(f"repos/{repo}")
people = api("--paginate", "--slurp", f"repos/{repo}/contributors?per_page=100")
if info is None or people is None:
if not isinstance(info, dict) or not isinstance(people, list):
return None
people = [p for page in people for p in (page if isinstance(page, list) else [])]
return {"stars": info["stargazers_count"], "forks": info["forks_count"],
live = {"stars": info["stargazers_count"], "forks": info["forks_count"],
"watching": info["subscribers_count"],
"contributors": sum(1 for p in people if p.get("type") != "Bot" and not p.get("login", "").endswith("[bot]"))}
if week_bounds is not None:
start, end = week_bounds
query = f"repo:{repo} is:issue is:closed closed:{start}..{end}"
closed = api("--method", "GET", "search/issues", "-f", f"q={query}")
if (not isinstance(closed, dict) or closed.get("incomplete_results") is not False
or not isinstance(closed.get("total_count"), int)):
return None
live["issues_closed"] = closed["total_count"]
return live


def live_differences(observations: list[Observation], live: dict[str, int]) -> list[str]:
Expand All @@ -194,16 +231,32 @@ def live_differences(observations: list[Observation], live: dict[str, int]) -> l

def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--live", action="store_true", help="also compare stars, forks, watching and contributors with GitHub (needs gh)")
parser.add_argument("--live-data", metavar="FILE", help="GitHub's figures as JSON instead of asking gh (implies --live)")
parser.add_argument("--live", action="store_true", help="also compare live figures, including Issues Closed for the latest METRICS week (needs gh)")
parser.add_argument("--live-data", metavar="FILE", help="GitHub's figures, including issues_closed, as JSON instead of asking gh (implies --live)")
args = parser.parse_args(argv or [])
problems = check()
if args.live or args.live_data:
live = json.loads(Path(args.live_data).read_text(encoding="utf-8")) if args.live_data else fetch_live()
observations, _, texts = collect()
week_bounds = None
week = latest_metrics_week(texts["METRICS.md"])
if week is not None:
try:
week_bounds = iso_week_bounds(week)
except ValueError as exc:
problems.append(str(exc))
if args.live_data:
live = json.loads(Path(args.live_data).read_text(encoding="utf-8"))
needs_issues_closed = any(figure == "issues_closed" for figure, _, _ in observations)
if needs_issues_closed and (not isinstance(live, dict) or type(live.get("issues_closed")) is not int):
print("error: --live-data JSON must include an integer issues_closed count for the latest METRICS week", file=sys.stderr)
return 2
else:
if not any(figure == "issues_closed" for figure, _, _ in observations):
week_bounds = None
live = fetch_live(week_bounds=week_bounds)
if live is None:
print("error: --live needs the GitHub CLI (gh) signed in and online", file=sys.stderr)
print("error: --live could not fetch GitHub figures; check that gh is installed, signed in and online, and that search results are complete", file=sys.stderr)
return 2
observations, _, _ = collect()
problems += live_differences(observations, live)
if problems:
print("Traction figures are out of step:", file=sys.stderr)
Expand Down
73 changes: 70 additions & 3 deletions tests/test_check_traction.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ def test_weekly_row_skips_empty_and_dash_cells():
"contributors",
"social",
"countries",
"issues_closed",
"audits",
}

Expand Down Expand Up @@ -219,7 +220,7 @@ def test_script_entry_point_exits_with_the_checker_result(monkeypatch):
raise AssertionError("script entry point did not exit")


LIVE = {"stars": 51, "forks": 63, "watching": 8, "contributors": 54}
LIVE = {"stars": 51, "forks": 63, "watching": 8, "contributors": 54, "issues_closed": 28}


def _observations(tmp_path, **overrides):
Expand All @@ -245,6 +246,63 @@ def test_live_ignores_figures_github_cannot_supply(tmp_path):
assert check.live_differences([("social", "README.md", "30K+")], {"social": 1}) == []


def test_live_reports_a_weekly_issues_closed_mismatch(tmp_path):
messages = check.live_differences(_observations(tmp_path), {**LIVE, "issues_closed": 29})

assert messages == [
"issues_closed: METRICS.md (latest weekly row, 2026-W43) says 28, GitHub says 29"
]


def test_latest_metrics_iso_week_uses_monday_sunday_and_crosses_year_boundaries():
assert check.latest_metrics_week(METRICS) == "2026-W43"
assert check.iso_week_bounds("2026-W43") == ("2026-10-19", "2026-10-25")
assert check.iso_week_bounds("2026-W01") == ("2025-12-29", "2026-01-04")
assert check.iso_week_bounds("2020-W53") == ("2020-12-28", "2021-01-03")


def test_iso_week_bounds_rejects_malformed_or_nonexistent_weeks():
import pytest

for week in ("2026-W1", "2026-W54", "not-a-week"):
with pytest.raises(ValueError, match="not a valid ISO year-week"):
check.iso_week_bounds(week)


def test_fetch_live_queries_issue_only_count_for_the_week_and_fails_on_incomplete_results(monkeypatch):
import json
from types import SimpleNamespace

monkeypatch.setattr(check.shutil, "which", lambda command: "/usr/bin/gh")
calls = []
replies = [
{"stargazers_count": 51, "forks_count": 63, "subscribers_count": 8},
[[{"login": "person"}, {"login": "helper[bot]", "type": "Bot"}]],
{"total_count": 28, "incomplete_results": False, "items": []},
]

def run(command, capture_output, text):
calls.append(command)
return SimpleNamespace(returncode=0, stdout=json.dumps(replies[len(calls) - 1]))

monkeypatch.setattr(check.subprocess, "run", run)
live = check.fetch_live(week_bounds=("2026-10-19", "2026-10-25"))

assert live == {"stars": 51, "forks": 63, "watching": 8, "contributors": 1, "issues_closed": 28}
assert calls[2] == [
"gh", "api", "--method", "GET", "search/issues", "-f",
"q=repo:yakew7/Fair-Code is:issue is:closed closed:2026-10-19..2026-10-25",
]

replies[2] = {"total_count": 28, "incomplete_results": True, "items": []}
calls.clear()
assert check.fetch_live(week_bounds=("2026-10-19", "2026-10-25")) is None

replies[2] = []
calls.clear()
assert check.fetch_live(week_bounds=("2026-10-19", "2026-10-25")) is None


def test_main_live_data_fails_on_a_stale_figure_and_passes_when_current(tmp_path, monkeypatch, capsys):
import json
_site(tmp_path)
Expand All @@ -257,12 +315,21 @@ def test_main_live_data_fails_on_a_stale_figure_and_passes_when_current(tmp_path
assert check.main(["--live-data", str(live)]) == 0
assert "and with GitHub." in capsys.readouterr().out

live.write_text(json.dumps({**LIVE, "issues_closed": 29}), encoding="utf-8")
assert check.main(["--live-data", str(live)]) == 1
assert "issues_closed: METRICS.md (latest weekly row, 2026-W43) says 28, GitHub says 29" in capsys.readouterr().err

legacy_live = {key: value for key, value in LIVE.items() if key != "issues_closed"}
live.write_text(json.dumps(legacy_live), encoding="utf-8")
assert check.main(["--live-data", str(live)]) == 2
assert "must include an integer issues_closed count" in capsys.readouterr().err

live.write_text(json.dumps({**LIVE, "stars": 52}), encoding="utf-8")
assert check.main(["--live-data", str(live)]) == 1
assert "stars: README.md (GitHub Stars) says 51, GitHub says 52" in capsys.readouterr().err


def test_live_without_gh_is_an_error_not_a_pass(monkeypatch, capsys):
monkeypatch.setattr(check, "fetch_live", lambda repo=check.REPO: None)
monkeypatch.setattr(check, "fetch_live", lambda repo=check.REPO, week_bounds=None: None)
assert check.main(["--live"]) == 2
assert "needs the GitHub CLI" in capsys.readouterr().err
assert "could not fetch GitHub figures" in capsys.readouterr().err
Loading