mirror of
https://github.com/MinaSaad1/pbi-cli
synced 2026-04-21 13:37:19 +00:00
feat: add cumulative downloads chart to README
- scripts/generate_downloads_chart.py fetches pypistats.org data (mirrors excluded, stdlib only) and renders a dark-theme SVG matching the existing asset style - assets/downloads-chart.svg seeded with the current 15-day history - .github/workflows/downloads-chart.yml runs daily at 06:15 UTC and commits only when the chart actually changes - README shows the chart directly under stats.svg
This commit is contained in:
parent
ff96cc3f3b
commit
cb71ba1cf7
4 changed files with 302 additions and 0 deletions
35
.github/workflows/downloads-chart.yml
vendored
Normal file
35
.github/workflows/downloads-chart.yml
vendored
Normal file
|
|
@ -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
|
||||||
|
|
@ -41,6 +41,14 @@
|
||||||
<img src="https://raw.githubusercontent.com/MinaSaad1/pbi-cli/master/assets/stats.svg" alt="pbi-cli at a Glance" width="850"/>
|
<img src="https://raw.githubusercontent.com/MinaSaad1/pbi-cli/master/assets/stats.svg" alt="pbi-cli at a Glance" width="850"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://raw.githubusercontent.com/MinaSaad1/pbi-cli/master/assets/downloads-chart.svg" alt="pbi-cli cumulative downloads from PyPI" width="850"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub>Cumulative downloads, refreshed daily from <a href="https://pypistats.org/packages/pbi-cli-tool">pypistats.org</a> via GitHub Actions.</sub>
|
||||||
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Get Started
|
## Get Started
|
||||||
|
|
|
||||||
39
assets/downloads-chart.svg
Normal file
39
assets/downloads-chart.svg
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="850" height="340" viewBox="0 0 850 340">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#58a6ff" stop-opacity="0.45"/>
|
||||||
|
<stop offset="100%" stop-color="#58a6ff" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="100%" height="100%" fill="#0d1117" rx="8"/>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<text x="425" y="33" font-family="'Segoe UI', Arial, sans-serif" font-size="17" fill="#F2C811" text-anchor="middle" font-weight="bold">pbi-cli Downloads Over Time</text>
|
||||||
|
<text x="425" y="52" font-family="'Segoe UI', Arial, sans-serif" font-size="11" fill="#8b949e" text-anchor="middle">Cumulative installs from PyPI • mirrors excluded • source: pypistats.org</text>
|
||||||
|
|
||||||
|
<!-- Gridlines & y-labels -->
|
||||||
|
<line x1="70" y1="280.0" x2="810" y2="280.0" stroke="#21262d" stroke-width="1" stroke-dasharray="2,3"/><line x1="70" y1="239.6" x2="810" y2="239.6" stroke="#21262d" stroke-width="1" stroke-dasharray="2,3"/><line x1="70" y1="199.2" x2="810" y2="199.2" stroke="#21262d" stroke-width="1" stroke-dasharray="2,3"/><line x1="70" y1="158.8" x2="810" y2="158.8" stroke="#21262d" stroke-width="1" stroke-dasharray="2,3"/><line x1="70" y1="118.4" x2="810" y2="118.4" stroke="#21262d" stroke-width="1" stroke-dasharray="2,3"/><line x1="70" y1="78.0" x2="810" y2="78.0" stroke="#21262d" stroke-width="1" stroke-dasharray="2,3"/>
|
||||||
|
<text x="62" y="284.0" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="end">0</text><text x="62" y="243.6" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="end">1,000</text><text x="62" y="203.2" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="end">2,000</text><text x="62" y="162.8" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="end">3,000</text><text x="62" y="122.4" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="end">4,000</text><text x="62" y="82.0" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="end">5,000</text>
|
||||||
|
|
||||||
|
<!-- Area fill -->
|
||||||
|
<path d="M 70.00,280 L 70.00,255.48 L 122.86,244.77 L 175.71,243.48 L 228.57,243.24 L 281.43,241.90 L 334.29,241.22 L 387.14,239.92 L 440.00,236.81 L 492.86,230.35 L 545.71,226.75 L 598.57,215.04 L 651.43,206.84 L 704.29,182.51 L 757.14,172.66 L 810.00,166.76 L 810.00,280 Z" fill="url(#areaFill)"/>
|
||||||
|
|
||||||
|
<!-- Line -->
|
||||||
|
<path d="M 70.00,255.48 L 122.86,244.77 L 175.71,243.48 L 228.57,243.24 L 281.43,241.90 L 334.29,241.22 L 387.14,239.92 L 440.00,236.81 L 492.86,230.35 L 545.71,226.75 L 598.57,215.04 L 651.43,206.84 L 704.29,182.51 L 757.14,172.66 L 810.00,166.76" fill="none" stroke="#58a6ff" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Data points -->
|
||||||
|
<circle cx="70.00" cy="255.48" r="2.5" fill="#58a6ff"/><circle cx="122.86" cy="244.77" r="2.5" fill="#58a6ff"/><circle cx="175.71" cy="243.48" r="2.5" fill="#58a6ff"/><circle cx="228.57" cy="243.24" r="2.5" fill="#58a6ff"/><circle cx="281.43" cy="241.90" r="2.5" fill="#58a6ff"/><circle cx="334.29" cy="241.22" r="2.5" fill="#58a6ff"/><circle cx="387.14" cy="239.92" r="2.5" fill="#58a6ff"/><circle cx="440.00" cy="236.81" r="2.5" fill="#58a6ff"/><circle cx="492.86" cy="230.35" r="2.5" fill="#58a6ff"/><circle cx="545.71" cy="226.75" r="2.5" fill="#58a6ff"/><circle cx="598.57" cy="215.04" r="2.5" fill="#58a6ff"/><circle cx="651.43" cy="206.84" r="2.5" fill="#58a6ff"/><circle cx="704.29" cy="182.51" r="2.5" fill="#58a6ff"/><circle cx="757.14" cy="172.66" r="2.5" fill="#58a6ff"/><circle cx="810.00" cy="166.76" r="4" fill="#F2C811"/>
|
||||||
|
|
||||||
|
<!-- Last-value callout -->
|
||||||
|
<rect x="720.0" y="138.8" width="82" height="24" rx="4" fill="#0d1a2a" stroke="#F2C811" stroke-width="1"/>
|
||||||
|
<text x="761.0" y="154.8" font-family="'Segoe UI', Arial, sans-serif" font-size="12" fill="#F2C811" text-anchor="middle" font-weight="bold">2,803 total</text>
|
||||||
|
|
||||||
|
<!-- X-axis labels -->
|
||||||
|
<text x="70.0" y="298" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="middle">Mar 26</text><text x="228.6" y="298" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="middle">Mar 29</text><text x="440.0" y="298" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="middle">Apr 02</text><text x="651.4" y="298" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="middle">Apr 06</text><text x="810.0" y="298" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="middle">Apr 09</text>
|
||||||
|
|
||||||
|
<!-- Footer: date range -->
|
||||||
|
<text x="70" y="328" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e">Mar 26, 2026 → Apr 09, 2026</text>
|
||||||
|
<text x="810" y="328" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="end">15 days of data</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5 KiB |
220
scripts/generate_downloads_chart.py
Normal file
220
scripts/generate_downloads_chart.py
Normal file
|
|
@ -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'<line x1="{PLOT_LEFT}" y1="{y:.1f}" x2="{PLOT_RIGHT}" y2="{y:.1f}" '
|
||||||
|
f'stroke="{GRID}" stroke-width="1" stroke-dasharray="2,3"/>'
|
||||||
|
)
|
||||||
|
label = f"{int(v):,}"
|
||||||
|
y_labels.append(
|
||||||
|
f'<text x="{PLOT_LEFT - 8}" y="{y + 4:.1f}" font-family="\'Segoe UI\', Arial, sans-serif" '
|
||||||
|
f'font-size="10" fill="{TEXT_SECONDARY}" text-anchor="end">{label}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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'<text x="{x:.1f}" y="{PLOT_BOTTOM + 18}" font-family="\'Segoe UI\', Arial, sans-serif" '
|
||||||
|
f'font-size="10" fill="{TEXT_SECONDARY}" text-anchor="middle">{d.strftime("%b %d")}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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'<circle cx="{x:.2f}" cy="{y:.2f}" r="{r}" fill="{fill}"/>')
|
||||||
|
|
||||||
|
# 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"""<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="{LINE_BLUE}" stop-opacity="0.45"/>
|
||||||
|
<stop offset="100%" stop-color="{LINE_BLUE}" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="100%" height="100%" fill="{BG}" rx="8"/>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<text x="{WIDTH // 2}" y="33" font-family="'Segoe UI', Arial, sans-serif" font-size="17" fill="{ACCENT_YELLOW}" text-anchor="middle" font-weight="bold">pbi-cli Downloads Over Time</text>
|
||||||
|
<text x="{WIDTH // 2}" y="52" font-family="'Segoe UI', Arial, sans-serif" font-size="11" fill="{TEXT_SECONDARY}" text-anchor="middle">Cumulative installs from PyPI • mirrors excluded • source: pypistats.org</text>
|
||||||
|
|
||||||
|
<!-- Gridlines & y-labels -->
|
||||||
|
{"".join(gridlines)}
|
||||||
|
{"".join(y_labels)}
|
||||||
|
|
||||||
|
<!-- Area fill -->
|
||||||
|
<path d="{area_path}" fill="url(#areaFill)"/>
|
||||||
|
|
||||||
|
<!-- Line -->
|
||||||
|
<path d="{line_path}" fill="none" stroke="{LINE_BLUE}" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Data points -->
|
||||||
|
{"".join(dots)}
|
||||||
|
|
||||||
|
<!-- Last-value callout -->
|
||||||
|
<rect x="{callout_x:.1f}" y="{callout_y:.1f}" width="82" height="24" rx="4" fill="{CARD_BG}" stroke="{ACCENT_YELLOW}" stroke-width="1"/>
|
||||||
|
<text x="{callout_x + 41:.1f}" y="{callout_y + 16:.1f}" font-family="'Segoe UI', Arial, sans-serif" font-size="12" fill="{ACCENT_YELLOW}" text-anchor="middle" font-weight="bold">{total_str} total</text>
|
||||||
|
|
||||||
|
<!-- X-axis labels -->
|
||||||
|
{"".join(x_labels)}
|
||||||
|
|
||||||
|
<!-- Footer: date range -->
|
||||||
|
<text x="{PLOT_LEFT}" y="{HEIGHT - 12}" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="{TEXT_SECONDARY}">{first_date} → {last_date}</text>
|
||||||
|
<text x="{PLOT_RIGHT}" y="{HEIGHT - 12}" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="{TEXT_SECONDARY}" text-anchor="end">{n} days of data</text>
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
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())
|
||||||
Loading…
Reference in a new issue