diff --git a/.github/workflows/downloads-chart.yml b/.github/workflows/downloads-chart.yml new file mode 100644 index 0000000..636de15 --- /dev/null +++ b/.github/workflows/downloads-chart.yml @@ -0,0 +1,35 @@ +name: Update downloads chart + +on: + schedule: + # Daily at 06:15 UTC (after pypistats has processed the prior day) + - cron: "15 6 * * *" + workflow_dispatch: + +permissions: + contents: write + +jobs: + regenerate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Regenerate cumulative downloads chart + run: python scripts/generate_downloads_chart.py + + - name: Commit updated chart + run: | + if git diff --quiet assets/downloads-chart.svg; then + echo "No changes to downloads chart." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add assets/downloads-chart.svg + git commit -m "chore: refresh downloads chart" + git push diff --git a/README.md b/README.md index aa7ace5..61d1be3 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,14 @@ pbi-cli at a Glance

+

+ pbi-cli cumulative downloads from PyPI +

+ +

+ Cumulative downloads, refreshed daily from pypistats.org via GitHub Actions. +

+ --- ## Get Started diff --git a/assets/downloads-chart.svg b/assets/downloads-chart.svg new file mode 100644 index 0000000..6f33d91 --- /dev/null +++ b/assets/downloads-chart.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + pbi-cli Downloads Over Time + Cumulative installs from PyPI • mirrors excluded • source: pypistats.org + + + + 01,0002,0003,0004,0005,000 + + + + + + + + + + + + + 2,803 total + + + Mar 26Mar 29Apr 02Apr 06Apr 09 + + + Mar 26, 2026 → Apr 09, 2026 + 15 days of data + diff --git a/scripts/generate_downloads_chart.py b/scripts/generate_downloads_chart.py new file mode 100644 index 0000000..fe1d316 --- /dev/null +++ b/scripts/generate_downloads_chart.py @@ -0,0 +1,220 @@ +"""Regenerate assets/downloads-chart.svg from pypistats.org data. + +Fetches daily download counts for pbi-cli-tool (mirrors excluded), computes a +cumulative series, and writes a dark-theme SVG line chart that matches the +visual style of the other assets in this repo. + +Runs with stdlib only so it works in CI without extra dependencies. + +Usage: + python scripts/generate_downloads_chart.py +""" + +from __future__ import annotations + +import json +import sys +import urllib.request +from datetime import date, datetime +from pathlib import Path + +PACKAGE = "pbi-cli-tool" +API_URL = f"https://pypistats.org/api/packages/{PACKAGE}/overall?mirrors=false" +OUTPUT_PATH = Path(__file__).resolve().parent.parent / "assets" / "downloads-chart.svg" + +# Chart geometry +WIDTH = 850 +HEIGHT = 340 +PLOT_LEFT = 70 +PLOT_RIGHT = 810 +PLOT_TOP = 78 +PLOT_BOTTOM = 280 + +# Colors (match stats.svg / banner.svg palette) +BG = "#0d1117" +ACCENT_YELLOW = "#F2C811" +LINE_BLUE = "#58a6ff" +GRID = "#21262d" +TEXT_PRIMARY = "#c9d1d9" +TEXT_SECONDARY = "#8b949e" +CARD_BG = "#0d1a2a" + + +def fetch_downloads() -> list[tuple[date, int]]: + """Return sorted list of (date, daily_downloads) from pypistats.""" + with urllib.request.urlopen(API_URL, timeout=30) as resp: + payload = json.loads(resp.read()) + + rows = [ + (datetime.strptime(r["date"], "%Y-%m-%d").date(), int(r["downloads"])) + for r in payload["data"] + if r["category"] == "without_mirrors" + ] + rows.sort(key=lambda item: item[0]) + return rows + + +def to_cumulative(rows: list[tuple[date, int]]) -> list[tuple[date, int]]: + total = 0 + out: list[tuple[date, int]] = [] + for d, n in rows: + total += n + out.append((d, total)) + return out + + +def nice_ceiling(value: int) -> int: + """Round up to a nice axis maximum (1-2-5 * 10^n).""" + if value <= 0: + return 10 + import math + + exp = math.floor(math.log10(value)) + base = 10**exp + for step in (1, 2, 2.5, 5, 10): + candidate = int(step * base) + if candidate >= value: + return candidate + return int(10 * base) + + +def build_svg(series: list[tuple[date, int]]) -> str: + if not series: + raise RuntimeError("No download data returned from pypistats") + + n = len(series) + max_val = series[-1][1] + y_max = nice_ceiling(int(max_val * 1.15)) + + plot_width = PLOT_RIGHT - PLOT_LEFT + plot_height = PLOT_BOTTOM - PLOT_TOP + + def x_at(i: int) -> float: + if n == 1: + return PLOT_LEFT + plot_width / 2 + return PLOT_LEFT + (i / (n - 1)) * plot_width + + def y_at(v: int) -> float: + return PLOT_BOTTOM - (v / y_max) * plot_height + + points = [(x_at(i), y_at(v)) for i, (_, v) in enumerate(series)] + + line_path = "M " + " L ".join(f"{x:.2f},{y:.2f}" for x, y in points) + area_path = ( + f"M {points[0][0]:.2f},{PLOT_BOTTOM} " + + "L " + + " L ".join(f"{x:.2f},{y:.2f}" for x, y in points) + + f" L {points[-1][0]:.2f},{PLOT_BOTTOM} Z" + ) + + # Y-axis gridlines (5 steps) + gridlines = [] + y_labels = [] + for step in range(6): + v = y_max * step / 5 + y = PLOT_BOTTOM - (v / y_max) * plot_height + gridlines.append( + f'' + ) + label = f"{int(v):,}" + y_labels.append( + f'{label}' + ) + + # X-axis labels: first, last, and ~3 intermediate + label_indices = sorted({0, n - 1, n // 4, n // 2, (3 * n) // 4}) + x_labels = [] + for i in label_indices: + d, _ = series[i] + x = x_at(i) + x_labels.append( + f'{d.strftime("%b %d")}' + ) + + # Data point dots + highlight on last + dots = [] + for i, (x, y) in enumerate(points): + is_last = i == n - 1 + r = 4 if is_last else 2.5 + fill = ACCENT_YELLOW if is_last else LINE_BLUE + dots.append(f'') + + # Last-value callout + last_x, last_y = points[-1] + last_val = series[-1][1] + callout_x = min(last_x + 10, PLOT_RIGHT - 90) + callout_y = max(last_y - 28, PLOT_TOP + 14) + + # Summary stats + first_date = series[0][0].strftime("%b %d, %Y") + last_date = series[-1][0].strftime("%b %d, %Y") + total_str = f"{max_val:,}" + + svg = f""" + + + + + + + + + + + + pbi-cli Downloads Over Time + Cumulative installs from PyPI • mirrors excluded • source: pypistats.org + + + {"".join(gridlines)} + {"".join(y_labels)} + + + + + + + + + {"".join(dots)} + + + + {total_str} total + + + {"".join(x_labels)} + + + {first_date} → {last_date} + {n} days of data + +""" + return svg + + +def main() -> int: + try: + daily = fetch_downloads() + except Exception as exc: # noqa: BLE001 + print(f"Failed to fetch pypistats data: {exc}", file=sys.stderr) + return 1 + + if not daily: + print("pypistats returned no rows", file=sys.stderr) + return 1 + + cumulative = to_cumulative(daily) + svg = build_svg(cumulative) + + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + OUTPUT_PATH.write_text(svg, encoding="utf-8") + print(f"Wrote {OUTPUT_PATH} ({len(cumulative)} days, {cumulative[-1][1]:,} total downloads)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())