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 @@
+
+
+ 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 @@ + 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'