"""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 \u2022 mirrors excluded \u2022 source: pypistats.org {"".join(gridlines)} {"".join(y_labels)} {"".join(dots)} {total_str} total {"".join(x_labels)} {first_date} \u2192 {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())