Files
2026-07-13 13:39:38 +08:00

175 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Fetch PyPI download stats for all LiveKit packages and show popularity/growth."""
import concurrent.futures
import json
import pathlib
import sys
import urllib.request
from datetime import datetime, timedelta
def _read_pypi_name(plugin_dir: pathlib.Path) -> str:
"""Read the actual PyPI package name from pyproject.toml or setup.py."""
import re
pyproject = plugin_dir / "pyproject.toml"
if pyproject.exists():
m = re.search(r'^name\s*=\s*"([^"]+)"', pyproject.read_text(), re.MULTILINE)
if m:
return m.group(1)
setup_py = plugin_dir / "setup.py"
if setup_py.exists():
m = re.search(r'name\s*=\s*"([^"]+)"', setup_py.read_text())
if m:
return m.group(1)
return plugin_dir.name
def _iter_plugin_names() -> list[str]:
plugins_root = pathlib.Path(__file__).parent.parent / "livekit-plugins"
names = []
for d in sorted(plugins_root.glob("livekit-plugins-*")):
if d.is_dir():
names.append(_read_pypi_name(d))
return names
def _fetch_daily(package: str, retries: int = 3) -> tuple[str, dict[str, int]]:
"""Fetch daily download counts excluding mirrors, with retries."""
import time
for attempt in range(retries):
try:
req = urllib.request.Request(
f"https://pypistats.org/api/packages/{package}/overall?mirrors=false",
headers={"User-Agent": "livekit-stats/1.0"},
)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read())
daily = {row["date"]: row["downloads"] for row in data.get("data", [])}
if daily:
return package, daily
except Exception:
pass
if attempt < retries - 1:
time.sleep(1)
return package, {}
def _sum_range(daily: dict[str, int], start: str, end: str) -> int:
return sum(count for date, count in daily.items() if start <= date <= end)
def _growth_pct(current: int, previous: int) -> float | None:
if previous <= 0:
return None
return (current - previous) / previous * 100
def _fmt_growth(val: float | None) -> str:
return f"{val:+.0f}%" if val is not None else "—"
def _fmt_downloads(n: int) -> str:
if n >= 1_000_000:
return f"{n / 1_000_000:.1f}M"
if n >= 1_000:
return f"{n / 1_000:.0f}K"
return str(n)
def _short_name(pkg: str) -> str:
if pkg.startswith("livekit-plugins-"):
return "plugins-" + pkg[len("livekit-plugins-"):]
return pkg
def _fetch_all() -> list[tuple[str, int, int, int, float | None, float | None, float | None]]:
packages = [
"livekit",
"livekit-api",
"livekit-protocol",
"livekit-agents",
"livekit-blingfire",
"livekit-blockguard",
"livekit-durable",
] + _iter_plugin_names()
today = datetime.now().date()
wow_cur_end = (today - timedelta(days=1)).isoformat()
wow_cur_start = (today - timedelta(days=7)).isoformat()
wow_prev_start = (today - timedelta(days=14)).isoformat()
wow_prev_end = (today - timedelta(days=8)).isoformat()
mom_cur_start = (today - timedelta(days=30)).isoformat()
mom_prev_start = (today - timedelta(days=60)).isoformat()
mom_prev_end = (today - timedelta(days=31)).isoformat()
qoq_cur_start = (today - timedelta(days=90)).isoformat()
qoq_prev_start = (today - timedelta(days=180)).isoformat()
qoq_prev_end = (today - timedelta(days=91)).isoformat()
print(f"Fetching stats for {len(packages)} packages...", file=sys.stderr)
all_daily: dict[str, dict[str, int]] = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
futures = {pool.submit(_fetch_daily, pkg): pkg for pkg in packages}
for fut in concurrent.futures.as_completed(futures):
pkg, daily = fut.result()
all_daily[pkg] = daily
results = []
for pkg in packages:
daily = all_daily[pkg]
last_7d = _sum_range(daily, wow_cur_start, wow_cur_end)
prev_7d = _sum_range(daily, wow_prev_start, wow_prev_end)
last_30d = _sum_range(daily, mom_cur_start, wow_cur_end)
prev_30d = _sum_range(daily, mom_prev_start, mom_prev_end)
last_90d = _sum_range(daily, qoq_cur_start, wow_cur_end)
prev_90d = _sum_range(daily, qoq_prev_start, qoq_prev_end)
results.append((
pkg, last_7d, last_30d, last_90d,
_growth_pct(last_7d, prev_7d),
_growth_pct(last_30d, prev_30d),
_growth_pct(last_90d, prev_90d),
))
results.sort(key=lambda r: r[1], reverse=True)
return results
def main() -> None:
results = _fetch_all()
today = datetime.now().date()
w = 24 # name column width
print(f"PyPI Stats (no mirrors) — {today}")
print(f"{'Package':<{w}} {'7d':>6} {'30d':>6} {'90d':>6} {'WoW':>5} {'MoM':>5} {'QoQ':>5}")
print("-" * (w + 38))
for pkg, last_7d, last_30d, last_90d, wow, mom, qoq in results:
name = _short_name(pkg)
if len(name) > w:
name = name[: w - 1] + "…"
print(
f"{name:<{w}} {_fmt_downloads(last_7d):>6} {_fmt_downloads(last_30d):>6}"
f" {_fmt_downloads(last_90d):>6} {_fmt_growth(wow):>5} {_fmt_growth(mom):>5}"
f" {_fmt_growth(qoq):>5}"
)
for label, idx, min_prev in [("WoW", 4, 500), ("MoM", 5, 2000), ("QoQ", 6, 5000)]:
prev_idx = {4: 1, 5: 2, 6: 3} # map growth idx -> current period volume field
growers = [
(r[0], r[idx]) for r in results
if r[idx] is not None and r[idx] > 0 and r[prev_idx[idx]] >= min_prev
]
growers.sort(key=lambda x: x[1], reverse=True)
if growers:
print(f"\nFastest growing ({label}):")
for pkg, g in growers[:10]:
print(f" {_short_name(pkg):<{w}} {g:+.0f}%")
if __name__ == "__main__":
main()