diff --git a/.github/scripts/update_metrics.py b/.github/scripts/update_metrics.py index 08d7052..bc5ea90 100644 --- a/.github/scripts/update_metrics.py +++ b/.github/scripts/update_metrics.py @@ -1,9 +1,10 @@ -"""Refresh METRICS.md from PyPI + GitHub APIs. +"""Refresh METRICS.md and download charts from PyPI + GitHub APIs. Pulled signals: - PyPI download counts (last day / week / month) via pypistats.org JSON API. - GitHub repo metadata (stars, forks, watchers, open issues/PRs). - GitHub traffic (clones, views, referrers, paths) — last 14 days. +- PyPI daily download chart via pepy.tech. Plugin install proxy: `/plugin marketplace add /` is a git clone under the hood, so daily clone counts approximate plugin installs. @@ -19,12 +20,14 @@ import sys import urllib.error import urllib.request -from datetime import datetime, timezone +from datetime import date, datetime, timedelta, timezone +from html import escape from pathlib import Path REPO = os.environ.get("GITHUB_REPOSITORY", "raintree-technology/docpull") PKG = os.environ.get("PYPI_PACKAGE", "docpull") OUTPUT = Path(os.environ.get("METRICS_OUTPUT", "METRICS.md")) +DOWNLOAD_CHART_OUTPUT = Path(os.environ.get("DOWNLOAD_CHART_OUTPUT", "docs/downloads-history.svg")) def gh(path: str) -> dict | list: @@ -66,11 +69,37 @@ def pypistats(path: str) -> dict: return json.loads(r.read().decode("utf-8")) +def pepy_project() -> dict: + """GET project download history from Pepy. + + Pepy exposes all-time totals plus a per-version daily breakdown for the + last roughly 90 days. We aggregate that version map into daily totals for + the README chart. + """ + url = f"https://pepy.tech/api/v2/projects/{PKG}" + req = urllib.request.Request( + url, + headers={"User-Agent": f"{REPO}-metrics-workflow (+https://github.com/{REPO})"}, + ) + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read().decode("utf-8")) + + def fmt(n: int | float) -> str: """Format a number with thousands separators.""" return f"{int(n):,}" +def short_fmt(n: int | float) -> str: + """Compact label for chart axes.""" + n = int(n) + if abs(n) >= 1_000_000: + return f"{n / 1_000_000:.1f}M" + if abs(n) >= 1_000: + return f"{n / 1_000:.1f}k" + return str(n) + + def safe_get(fn, default, *, on_error: list[str] | None = None): """Best-effort wrapper — never let a transient API hiccup blank METRICS.md. @@ -93,6 +122,139 @@ def safe_get(fn, default, *, on_error: list[str] | None = None): return default +def download_series(pepy: dict, *, days: int = 90) -> list[tuple[date, int]]: + """Aggregate Pepy's per-version daily downloads into one daily series.""" + raw = pepy.get("downloads", {}) + totals: dict[date, int] = {} + for day, versions in raw.items(): + try: + day_key = date.fromisoformat(day) + except ValueError: + continue + if isinstance(versions, dict): + totals[day_key] = sum(int(v or 0) for v in versions.values()) + + end = max(totals) if totals else datetime.now(timezone.utc).date() + start = end - timedelta(days=days - 1) + return [(start + timedelta(days=i), totals.get(start + timedelta(days=i), 0)) for i in range(days)] + + +def render_download_chart(series: list[tuple[date, int]], *, total_downloads: int) -> str: + """Render a dependency-free SVG chart for README embedding.""" + width = 920 + height = 360 + left = 70 + right = 28 + top = 72 + bottom = 64 + chart_w = width - left - right + chart_h = height - top - bottom + max_y = max([value for _, value in series] + [1]) + # Give the line a little headroom and round the top tick to a clean value. + top_tick = max(10, int(((max_y * 1.18) + 9) // 10 * 10)) + + def x_for(i: int) -> float: + if len(series) <= 1: + return left + return left + (i / (len(series) - 1)) * chart_w + + def y_for(value: int | float) -> float: + return top + chart_h - (float(value) / top_tick) * chart_h + + points = [(x_for(i), y_for(value)) for i, (_, value) in enumerate(series)] + line_points = " ".join(f"{x:.1f},{y:.1f}" for x, y in points) + area_points = ( + f"{left:.1f},{top + chart_h:.1f} " + line_points + f" {left + chart_w:.1f},{top + chart_h:.1f}" + ) + + grid_lines: list[str] = [] + axis_labels: list[str] = [] + for tick in range(5): + value = top_tick * tick / 4 + y = y_for(value) + grid_lines.append( + f'' + ) + axis_labels.append( + f'{escape(short_fmt(value))}' + ) + + month_labels: list[str] = [] + seen_months: set[tuple[int, int]] = set() + for i, (day, _) in enumerate(series): + key = (day.year, day.month) + if key in seen_months: + continue + seen_months.add(key) + x = x_for(i) + month_labels.append( + f'{escape(day.strftime("%b %d"))}' + ) + + start_label = series[0][0].strftime("%b %-d, %Y") if series else "" + end_label = series[-1][0].strftime("%b %-d, %Y") if series else "" + latest_value = series[-1][1] if series else 0 + total_window = sum(value for _, value in series) + subtitle = ( + f"Daily downloads, last {len(series)} days · {fmt(total_window)} in window · " + f"{fmt(total_downloads)} all time" + ) + generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + svg_lines = [ + ( + f'' + ), + f' {escape(PKG)} PyPI download history', + ( + f' Daily PyPI downloads from Pepy for {escape(PKG)} ' + f"between {escape(start_label)} and {escape(end_label)}." + ), + f' ', + ( + f' {escape(PKG)} PyPI downloads' + ), + f' {escape(subtitle)}', + ( + f' {escape(short_fmt(latest_value))}' + ), + f' latest day', + *grid_lines, + *axis_labels, + ( + f' ' + ), + f' ', + ( + f' ' + ), + *month_labels, + (f' Source: pepy.tech API'), + ( + f' Generated {escape(generated_at)}' + ), + "", + "", + ] + return "\n".join(svg_lines) + + +def write_download_chart(pepy: dict) -> None: + series = download_series(pepy) + total_downloads = int(pepy.get("total_downloads", 0) or 0) + DOWNLOAD_CHART_OUTPUT.parent.mkdir(parents=True, exist_ok=True) + DOWNLOAD_CHART_OUTPUT.write_text(render_download_chart(series, total_downloads=total_downloads)) + + def main() -> int: now = datetime.now(timezone.utc) timestamp = now.strftime("%Y-%m-%d %H:%M UTC") @@ -121,7 +283,10 @@ def main() -> int: pypi_errors: list[str] = [] recent = safe_get(lambda: pypistats("/recent")["data"], {}, on_error=pypi_errors) + pepy = safe_get(lambda: pepy_project(), {}, on_error=pypi_errors) pypi_blocked = bool(pypi_errors) + if pepy: + write_download_chart(pepy) stars = repo.get("stargazers_count", 0) forks = repo.get("forks_count", 0) diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml index f9728b6..460e378 100644 --- a/.github/workflows/metrics.yml +++ b/.github/workflows/metrics.yml @@ -1,9 +1,10 @@ name: Update metrics -# Refreshes METRICS.md from PyPI + GitHub APIs. +# Refreshes METRICS.md and the README downloads chart from PyPI + GitHub APIs. # # Signals: # - PyPI downloads (24h / 7d / 30d) via pypistats.org JSON API. +# - PyPI downloads chart via pepy.tech JSON API. # - GitHub repo metadata (stars, forks, watchers, open issues/PRs). # - GitHub traffic (clones, views, referrers, paths) — last 14 days. # @@ -48,7 +49,7 @@ jobs: with: python-version: "3.11" - - name: Refresh METRICS.md + - name: Refresh metrics artifacts env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }} @@ -68,7 +69,9 @@ jobs: uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 continue-on-error: true with: - add-paths: METRICS.md + add-paths: | + METRICS.md + docs/downloads-history.svg branch: automation/metrics-refresh delete-branch: true commit-message: "chore(metrics): refresh metrics" diff --git a/METRICS.md b/METRICS.md index 05982c7..edb3889 100644 --- a/METRICS.md +++ b/METRICS.md @@ -1,23 +1,23 @@ # docpull metrics -_Last updated: 2026-05-22 11:59 UTC. Auto-generated by `.github/workflows/metrics.yml`; do not edit by hand._ +_Last updated: 2026-06-12 18:16 UTC. Auto-generated by `.github/workflows/metrics.yml`; do not edit by hand._ ## Snapshot | Metric | Value | |---|---| -| PyPI downloads (last 24h) | 0 | -| PyPI downloads (last 7d) | 12 | -| PyPI downloads (last 30d) | 763 | +| PyPI downloads (last 24h) | 12 | +| PyPI downloads (last 7d) | 425 | +| PyPI downloads (last 30d) | 933 | | GitHub stars | 21 | | GitHub forks | 2 | | GitHub watchers | 1 | | Open issues | 0 | -| Open PRs | 0 | -| Repo clones (last 14d) | 219 | -| Unique cloners (last 14d) | 105 | -| Repo views (last 14d) | 15 | -| Unique visitors (last 14d) | 10 | +| Open PRs | 6 | +| Repo clones (last 14d) | 3,029 | +| Unique cloners (last 14d) | 425 | +| Repo views (last 14d) | 95 | +| Unique visitors (last 14d) | 9 | ## Plugin install proxy: daily clones (last 14d) @@ -25,37 +25,42 @@ _Last updated: 2026-05-22 11:59 UTC. Auto-generated by `.github/workflows/metric | Date | Clones | Unique cloners | |---|---|---| -| 2026-05-21 | 25 | 14 | -| 2026-05-20 | 14 | 6 | -| 2026-05-19 | 9 | 7 | -| 2026-05-18 | 6 | 5 | -| 2026-05-17 | 12 | 7 | -| 2026-05-16 | 23 | 13 | -| 2026-05-15 | 10 | 6 | -| 2026-05-14 | 26 | 13 | -| 2026-05-13 | 9 | 7 | -| 2026-05-12 | 13 | 8 | -| 2026-05-11 | 16 | 10 | -| 2026-05-10 | 15 | 10 | -| 2026-05-09 | 22 | 13 | -| 2026-05-08 | 19 | 8 | +| 2026-06-10 | 121 | 26 | +| 2026-06-09 | 173 | 40 | +| 2026-06-08 | 315 | 68 | +| 2026-06-07 | 33 | 13 | +| 2026-06-06 | 422 | 78 | +| 2026-06-05 | 6 | 5 | +| 2026-06-04 | 489 | 92 | +| 2026-06-03 | 125 | 22 | +| 2026-06-02 | 16 | 7 | +| 2026-06-01 | 10 | 7 | +| 2026-05-31 | 23 | 13 | +| 2026-05-30 | 159 | 19 | +| 2026-05-29 | 1,135 | 122 | +| 2026-05-28 | 2 | 1 | ## Top referrers (last 14d) | Source | Views | Unique | |---|---|---| -| github.com | 6 | 5 | -| Google | 1 | 1 | +| github.com | 17 | 6 | +| Google | 2 | 2 | ## Top paths (last 14d) | Path | Views | Unique | |---|---|---| -| `/` | 11 | 9 | -| `/blob/main/.editorconfig` | 1 | 1 | -| `/blob/main/.gitignore` | 1 | 1 | -| `/graphs/contributors` | 1 | 1 | -| `/tree/main/mcp` | 1 | 1 | +| `/` | 43 | 8 | +| `/pulls` | 11 | 1 | +| `/pull/49` | 4 | 1 | +| `/branches` | 3 | 1 | +| `/pull/61` | 3 | 1 | +| `/actions/runs/26967522318/job/79574197483` | 2 | 1 | +| `/actions/runs/27053968208/job/79854561710` | 2 | 1 | +| `/actions/runs/27073500057` | 2 | 1 | +| `/pull/43` | 2 | 1 | +| `/pull/63` | 2 | 1 | ## Drill deeper diff --git a/README.md b/README.md index 9273f5c..ff4df7e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@

+## Download History + + + PyPI download history chart for docpull + + ## Star History diff --git a/docs/downloads-history.svg b/docs/downloads-history.svg new file mode 100644 index 0000000..a65bffa --- /dev/null +++ b/docs/downloads-history.svg @@ -0,0 +1,28 @@ + + docpull PyPI download history + Daily PyPI downloads from Pepy for docpull between Mar 14, 2026 and Jun 11, 2026. + + docpull PyPI downloads + Daily downloads, last 90 days · 6,275 in window · 11,351 all time + 26 + latest day + + + + + +0 +265 +530 +795 +1.1k + + + +Mar 14 +Apr 01 +May 01 +Jun 01 + Source: pepy.tech API + Generated 2026-06-12 18:17 UTC +