Skip to content
Open
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
87 changes: 87 additions & 0 deletions .github/scripts/download_badges.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Build shields.io endpoint JSON for PyPI monthly downloads.

pypistats.org exposes two relevant endpoints. `/recent` is the one shields.io
reads for its own `pypi/dm` badge, and it is aggressively rate limited: when it
refuses, shields renders "rate limited by upstream service" and GitHub's camo
proxy caches that error image for hours. `/overall` returns the daily series and
answers reliably, so this script sums the last 30 days from it instead.

Counts exclude mirrors, which is what shields.io reports and what represents
real installs. Including mirrors roughly quadruples the number.

Usage: download_badges.py <output-dir>
"""

import json
import sys
import time
import urllib.error
import urllib.request
from datetime import date, timedelta

API = "https://pypistats.org/api/packages/{pkg}/overall?mirrors=false"
WINDOW_DAYS = 30
PACKAGES = {
"rosetta-mcp": "MCP downloads",
"rosetta-cli": "CLI downloads",
}


def fetch(pkg: str, attempts: int = 5) -> dict:
"""GET the overall series, retrying with linear backoff on transient errors."""
url = API.format(pkg=pkg)
req = urllib.request.Request(url, headers={"User-Agent": "rosetta-badges/1.0"})
last = None
for attempt in range(1, attempts + 1):
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.load(resp)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
last = exc
if attempt < attempts:
time.sleep(5 * attempt)
raise RuntimeError(f"{pkg}: pypistats unreachable after {attempts} attempts: {last}")


def monthly(pkg: str) -> int:
cutoff = (date.today() - timedelta(days=WINDOW_DAYS)).isoformat()
rows = fetch(pkg)["data"]
return sum(r["downloads"] for r in rows if r["date"] >= cutoff)


def human(n: int) -> str:
if n >= 1_000_000:
return f"{n / 1_000_000:.1f}M".replace(".0M", "M")
if n >= 10_000:
return f"{n // 1000}k"
if n >= 1_000:
return f"{n / 1000:.1f}k".replace(".0k", "k")
return str(n)


def main() -> int:
if len(sys.argv) != 2:
print(__doc__, file=sys.stderr)
return 2
out = sys.argv[1]

for pkg, label in PACKAGES.items():
count = monthly(pkg)
payload = {
"schemaVersion": 1,
"label": label,
"message": f"{human(count)}/month",
"color": "blue",
}
path = f"{out}/{pkg}.json"
with open(path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2)
fh.write("\n")
print(f"{pkg}: {count} downloads in {WINDOW_DAYS}d -> {path}")

return 0


if __name__ == "__main__":
sys.exit(main())
73 changes: 73 additions & 0 deletions .github/workflows/download-badges.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: Download Badges

# Refreshes the PyPI monthly-download badges shown in README.md.
#
# Why this exists: shields.io's own `pypi/dm` badge reads pypistats.org's
# `/recent` endpoint, which is aggressively rate limited. When it refuses,
# shields renders "rate limited by upstream service" and GitHub's camo proxy
# caches that error image, so the badge stays broken long after the upstream
# recovers. This job reads `/overall` instead, which answers reliably, and
# publishes a shields endpoint JSON to the orphan `badges` branch.
#
# Counts exclude mirrors, matching what shields.io reports.
#
# No secrets required: pushes with the default GITHUB_TOKEN.

on:
schedule:
- cron: '17 4 * * *'
workflow_dispatch:
push:
branches:
- main
paths:
- '.github/workflows/download-badges.yml'
- '.github/scripts/download_badges.py'

permissions:
contents: write

concurrency:
group: download-badges
cancel-in-progress: false

jobs:
refresh:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Build badge JSON
run: |
mkdir -p "$RUNNER_TEMP/badges"
python .github/scripts/download_badges.py "$RUNNER_TEMP/badges"

- name: Publish to badges branch
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

if git ls-remote --exit-code --heads origin badges >/dev/null 2>&1; then
git fetch origin badges --depth=1
git switch --force badges
else
# git switch --orphan starts with an empty tree, unlike git checkout --orphan
git switch --orphan badges
fi

cp -f "$RUNNER_TEMP"/badges/*.json .

git add ./*.json
if git diff --cached --quiet; then
echo "No change in download counts; nothing to publish."
else
git commit -m "chore: refresh download badges"
git push origin badges
fi