175 lines
5.9 KiB
Python
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()
|