Neon-Vision-Editor/scripts/update_download_metrics.py

644 lines
25 KiB
Python
Executable file

#!/usr/bin/env python3
"""Refresh README download badges/text and regenerate the release trend SVG.
Usage:
scripts/update_download_metrics.py
scripts/update_download_metrics.py --check
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
import os
import pathlib
import re
import subprocess
import sys
import urllib.request
import urllib.parse
from dataclasses import dataclass
ROOT = pathlib.Path(__file__).resolve().parents[1]
README = ROOT / "README.md"
SVG_PATH = ROOT / "docs" / "images" / "release-download-trend.svg"
OWNER = "h3pdesign"
REPO = "Neon-Vision-Editor"
API_URL = f"https://api.github.com/repos/{OWNER}/{REPO}/releases?per_page=100"
CLONES_API_URL = f"https://api.github.com/repos/{OWNER}/{REPO}/traffic/clones"
VIEWS_API_URL = f"https://api.github.com/repos/{OWNER}/{REPO}/traffic/views"
CLONES_WINDOW_DAYS = 14
@dataclass(frozen=True)
class ReleasePoint:
tag: str
downloads: int
published_at: dt.datetime
@dataclass(frozen=True)
class ClonePoint:
timestamp: dt.datetime
count: int
@dataclass(frozen=True)
class ViewPoint:
timestamp: dt.datetime
count: int
def github_api_get(url: str) -> object:
base_headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "neon-vision-editor-metrics-updater",
}
gh_token = os.environ.get("GH_TOKEN")
github_token = os.environ.get("GITHUB_TOKEN")
token_candidates: list[str | None] = []
if gh_token:
token_candidates.append(gh_token)
if github_token and github_token != gh_token:
token_candidates.append(github_token)
if not token_candidates:
token_candidates.append(None)
last_error: Exception | None = None
for token in token_candidates:
headers = dict(base_headers)
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read().decode("utf-8"))
except Exception as exc:
last_error = exc
if last_error is not None:
raise last_error
raise RuntimeError("GitHub API request failed without an error.")
def fetch_releases() -> list[ReleasePoint]:
payload = github_api_get(API_URL)
if not isinstance(payload, list):
raise RuntimeError("Unexpected GitHub releases payload.")
points: list[ReleasePoint] = []
for release in payload:
if release.get("draft"):
continue
tag = str(release.get("tag_name", "")).strip()
published_raw = release.get("published_at")
if not tag or not published_raw:
continue
try:
published = dt.datetime.fromisoformat(published_raw.replace("Z", "+00:00"))
except ValueError:
continue
assets = release.get("assets", [])
downloads = 0
for asset in assets:
value = asset.get("download_count", 0)
if isinstance(value, int):
downloads += value
points.append(ReleasePoint(tag=tag, downloads=downloads, published_at=published))
if not points:
raise RuntimeError("No stable releases found from GitHub API.")
return points
def fetch_clone_traffic() -> tuple[list[ClonePoint], int | None, dt.datetime | None]:
try:
payload = github_api_get(CLONES_API_URL)
except Exception:
payload = None
if payload is None:
# Local fallback: reuse authenticated gh CLI when direct API auth is unavailable.
try:
out = subprocess.run(
["gh", "api", f"repos/{OWNER}/{REPO}/traffic/clones"],
check=True,
capture_output=True,
text=True,
timeout=20,
)
payload = json.loads(out.stdout)
except Exception:
return [], None, None
if not isinstance(payload, dict):
return [], None, None
raw_points = payload.get("clones", [])
if not isinstance(raw_points, list):
raw_points = []
points: list[ClonePoint] = []
for point in raw_points:
if not isinstance(point, dict):
continue
ts_raw = point.get("timestamp")
count = point.get("count", 0)
if not isinstance(ts_raw, str) or not isinstance(count, int):
continue
try:
ts = dt.datetime.fromisoformat(ts_raw.replace("Z", "+00:00"))
except ValueError:
continue
points.append(ClonePoint(timestamp=ts, count=count))
points.sort(key=lambda p: p.timestamp)
unique_total = payload.get("uniques")
total_count = payload.get("count")
latest_timestamp = points[-1].timestamp if points else None
if isinstance(unique_total, int):
return points, unique_total, latest_timestamp
if isinstance(total_count, int):
return points, total_count, latest_timestamp
return points, None, latest_timestamp
def fetch_view_traffic() -> tuple[list[ViewPoint], int | None, dt.datetime | None]:
try:
payload = github_api_get(VIEWS_API_URL)
except Exception:
payload = None
if payload is None:
# Local fallback: reuse authenticated gh CLI when direct API auth is unavailable.
try:
out = subprocess.run(
["gh", "api", f"repos/{OWNER}/{REPO}/traffic/views"],
check=True,
capture_output=True,
text=True,
timeout=20,
)
payload = json.loads(out.stdout)
except Exception:
return [], None, None
if not isinstance(payload, dict):
return [], None, None
raw_points = payload.get("views", [])
if not isinstance(raw_points, list):
raw_points = []
points: list[ViewPoint] = []
for point in raw_points:
if not isinstance(point, dict):
continue
ts_raw = point.get("timestamp")
count = point.get("count", 0)
if not isinstance(ts_raw, str) or not isinstance(count, int):
continue
try:
ts = dt.datetime.fromisoformat(ts_raw.replace("Z", "+00:00"))
except ValueError:
continue
points.append(ViewPoint(timestamp=ts, count=count))
points.sort(key=lambda p: p.timestamp)
unique_total = payload.get("uniques")
total_count = payload.get("count")
latest_timestamp = points[-1].timestamp if points else None
if isinstance(unique_total, int):
return points, unique_total, latest_timestamp
if isinstance(total_count, int):
return points, total_count, latest_timestamp
return points, None, latest_timestamp
def y_top(max_value: int, ticks: int = 4) -> int:
if max_value <= 0:
return ticks
rough = max_value / ticks
magnitude = 10 ** max(0, int(math.log10(max(1, rough))))
step = max(1, int(math.ceil(rough / magnitude) * magnitude))
return step * ticks
def generate_svg(points: list[ReleasePoint], clone_total: int, view_total: int, snapshot_date: str) -> str:
width = 1200
height = 620
left = 130
right = 1070
top = 120
bottom = 320
max_downloads = max(p.downloads for p in points)
top_value = y_top(max_downloads, ticks=4)
if top_value == 0:
top_value = 4
span_x = right - left
span_y = bottom - top
step_x = span_x / max(1, len(points) - 1)
coords: list[tuple[float, float]] = []
for idx, point in enumerate(points):
x = left + (idx * step_x)
y = bottom - (point.downloads / top_value) * span_y
coords.append((x, y))
grid_lines: list[str] = []
y_labels: list[str] = []
for i in range(5):
value = int((top_value / 4) * i)
y = bottom - (value / top_value) * span_y if top_value else bottom
color = "#37566F" if i in (0, 4) else "#2B4255"
grid_lines.append(
f' <line x1="{left}" y1="{y:.1f}" x2="{right}" y2="{y:.1f}" stroke="{color}" stroke-width="1"/>'
)
label_x = 58 if value >= 10 else 68
y_labels.append(
f' <text x="{label_x}" y="{y + 6:.1f}" fill="#9CC3E6" font-size="14" '
'font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif">'
f"{value}</text>"
)
point_nodes: list[str] = []
x_labels: list[str] = []
value_labels: list[str] = []
colors = ["#00C2FF", "#00D7D2", "#1AE7C0", "#34EDAA", "#47F193", "#5AF57D", "#72FA64", "#8CFF5A"]
for idx, ((x, y), point) in enumerate(zip(coords, points)):
fill = colors[idx % len(colors)]
point_nodes.append(
f' <circle cx="{x:.1f}" cy="{y:.1f}" r="7" fill="{fill}" stroke="#D7F7FF" stroke-width="2"/>'
)
x_labels.append(
f' <text x="{x - 14:.1f}" y="352" fill="#D7E8F8" font-size="13" '
'font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif">'
f"{point.tag}</text>"
)
label_y = y - 14 if y > top + 26 else y + 22
value_labels.append(
f' <text x="{x - 10:.1f}" y="{label_y:.1f}" fill="#D7F7FF" font-size="15" '
'font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" '
f'font-weight="600">{point.downloads}</text>'
)
polyline_points = " ".join(f"{x:.1f},{y:.1f}" for x, y in coords)
clone_panel: list[str] = [
' <rect x="58" y="378" width="1084" height="210" rx="12" fill="#0A1A2B" stroke="#2A4762" stroke-width="1"/>',
f' <text x="84" y="412" fill="#E6F3FF" font-size="20" font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-weight="700">Repository Traffic (last {CLONES_WINDOW_DAYS} days)</text>',
]
panel_left = 86
panel_right = 1110
clone_bar_top = 450
clone_bar_bottom = 476
view_bar_top = 510
view_bar_bottom = 536
track_width = panel_right - panel_left
traffic_scale_max = max(100, y_top(max(1, clone_total, view_total), ticks=4))
clone_fill_ratio = min(1.0, clone_total / traffic_scale_max)
clone_fill_width = max(8.0, track_width * clone_fill_ratio)
view_fill_ratio = min(1.0, view_total / traffic_scale_max)
view_fill_width = max(8.0, track_width * view_fill_ratio)
mid_value = traffic_scale_max // 2
mid_x = panel_left + (track_width * 0.5)
clone_panel.extend(
[
f' <text x="{panel_left}" y="{clone_bar_top - 12}" fill="#C4B5FD" font-size="15" font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-weight="600">Unique cloners: {clone_total}</text>',
f' <rect x="{panel_left}" y="{clone_bar_top}" width="{track_width}" height="{clone_bar_bottom - clone_bar_top}" rx="10" fill="#15263A" stroke="#2B4255" stroke-width="1"/>',
f' <rect x="{panel_left}" y="{clone_bar_top}" width="{clone_fill_width:.1f}" height="{clone_bar_bottom - clone_bar_top}" rx="10" fill="url(#cloneFill)"/>',
f' <text x="{panel_left}" y="{view_bar_top - 12}" fill="#7DD3FC" font-size="15" font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-weight="600">Unique visitors: {view_total}</text>',
f' <rect x="{panel_left}" y="{view_bar_top}" width="{track_width}" height="{view_bar_bottom - view_bar_top}" rx="10" fill="#15263A" stroke="#2B4255" stroke-width="1"/>',
f' <rect x="{panel_left}" y="{view_bar_top}" width="{view_fill_width:.1f}" height="{view_bar_bottom - view_bar_top}" rx="10" fill="url(#viewFill)"/>',
f' <line x1="{panel_left}" y1="{clone_bar_top - 20}" x2="{panel_left}" y2="{view_bar_bottom + 12}" stroke="#436280" stroke-width="1"/>',
f' <line x1="{mid_x:.1f}" y1="{clone_bar_top - 20}" x2="{mid_x:.1f}" y2="{view_bar_bottom + 12}" stroke="#436280" stroke-width="1"/>',
f' <line x1="{panel_right}" y1="{clone_bar_top - 20}" x2="{panel_right}" y2="{view_bar_bottom + 12}" stroke="#436280" stroke-width="1"/>',
f' <text x="{panel_left - 2}" y="{view_bar_bottom + 30}" text-anchor="start" fill="#9CC3E6" font-size="13" font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif">0</text>',
f' <text x="{mid_x:.1f}" y="{view_bar_bottom + 30}" text-anchor="middle" fill="#9CC3E6" font-size="13" font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif">{mid_value}</text>',
f' <text x="{panel_right + 2}" y="{view_bar_bottom + 30}" text-anchor="end" fill="#9CC3E6" font-size="13" font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif">{traffic_scale_max}</text>',
f' <text x="{panel_left}" y="{view_bar_bottom + 52}" fill="#9CC3E6" font-size="14" font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif">Shared scale: 0 to {traffic_scale_max} events in the last {CLONES_WINDOW_DAYS} days.</text>',
]
)
return """<svg width="1200" height="620" viewBox="0 0 1200 620" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">GitHub Release Downloads and Traffic Trend</title>
<desc id="desc">Line chart of release downloads with 14-day traffic bars for clones and views.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1200" y2="620" gradientUnits="userSpaceOnUse">
<stop stop-color="#061423"/>
<stop offset="1" stop-color="#041C16"/>
</linearGradient>
<linearGradient id="line" x1="130" y1="86" x2="1070" y2="340" gradientUnits="userSpaceOnUse">
<stop stop-color="#00C2FF"/>
<stop offset="0.55" stop-color="#00E2B8"/>
<stop offset="1" stop-color="#8CFF5A"/>
</linearGradient>
<linearGradient id="cloneFill" x1="86" y1="450" x2="1110" y2="450" gradientUnits="userSpaceOnUse">
<stop stop-color="#7C3AED"/>
<stop offset="1" stop-color="#C084FC"/>
</linearGradient>
<linearGradient id="viewFill" x1="86" y1="482" x2="1110" y2="482" gradientUnits="userSpaceOnUse">
<stop stop-color="#0EA5E9"/>
<stop offset="1" stop-color="#7DD3FC"/>
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<rect width="1200" height="620" rx="18" fill="url(#bg)"/>
<rect x="24" y="24" width="1152" height="572" rx="14" stroke="#2A4762" stroke-width="1.5"/>
<text x="70" y="68" fill="#E6F3FF" font-size="30" font-family="SF Pro Display, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-weight="700">GitHub Release Downloads</text>
<text x="70" y="96" fill="#9CC3E6" font-size="18" font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif">Snapshot: SNAPSHOT_DATE</text>
GRID_LINES
Y_LABELS
<polyline
points="POLYLINE_POINTS"
fill="none"
stroke="url(#line)"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
filter="url(#glow)"
/>
POINT_NODES
X_LABELS
VALUE_LABELS
<text x="776" y="56" fill="#D7F7FF" font-size="15" font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif">Release trend line with highlighted points</text>
CLONE_PANEL
</svg>
""".replace("SNAPSHOT_DATE", snapshot_date).replace(
"GRID_LINES", "\n".join(grid_lines)
).replace(
"Y_LABELS", "\n".join(y_labels)
).replace(
"POLYLINE_POINTS", polyline_points
).replace(
"POINT_NODES", "\n".join(point_nodes)
).replace(
"X_LABELS", "\n".join(x_labels)
).replace(
"VALUE_LABELS", "\n".join(value_labels)
).replace(
"CLONE_PANEL", "\n".join(clone_panel)
)
def parse_existing_clone_total(content: str) -> int | None:
match = re.search(r"Git clones \(last \d+ days\): <strong>(\d+)</strong>\.", content)
if not match:
return None
try:
return int(match.group(1))
except ValueError:
return None
def parse_existing_view_total(content: str) -> int | None:
match = re.search(r"GitHub views \(last \d+ days\): <strong>(\d+)</strong>\.", content)
if not match:
return None
try:
return int(match.group(1))
except ValueError:
return None
def shields_badge(label: str, message: str, color: str, style: str = "for-the-badge") -> str:
query = urllib.parse.urlencode(
{
"label": label,
"message": message,
"color": color,
"style": style,
}
)
return f"https://img.shields.io/static/v1?{query}"
def update_readme(
content: str,
latest_tag: str,
total_downloads: int,
clone_total: int,
clone_snapshot_utc: str,
view_total: int,
view_snapshot_utc: str,
today: str,
) -> str:
release_badge_line = (
' <img alt="{tag} Downloads" '
'src="https://img.shields.io/github/downloads/h3pdesign/Neon-Vision-Editor/{tag}/total'
'?style=for-the-badge&label={tag}&color=22C55E">'
).format(tag=latest_tag)
content = re.sub(
r'(?m)^ <img alt="v[^"]+ Downloads" src="https://img\.shields\.io/github/downloads/h3pdesign/Neon-Vision-Editor/v[^/]+/total\?style=for-the-badge&label=v[^"&]+&color=22C55E">$',
release_badge_line,
content,
)
content = re.sub(
r'(?m)^<p align="center">Git clones \(last \d+ days\): <strong>\d+</strong>\.</p>\s*$',
"",
content,
)
content = re.sub(
r'(?m)^<p align="center">GitHub views \(last \d+ days\): <strong>\d+</strong>\.</p>\s*$',
"",
content,
)
content = re.sub(
r'(?m)^<p align="center">Clone data snapshot \(UTC\): <strong>[^<]+</strong>\.</p>\s*$',
"",
content,
)
content = re.sub(
r'(?m)^<p align="center">View data snapshot \(UTC\): <strong>[^<]+</strong>\.</p>\s*$',
"",
content,
)
content = re.sub(
r'(?m)^<p align="center">Snapshot total downloads: <strong>\d+</strong> across releases\.</p>\s*$',
"",
content,
)
content = re.sub(
r'(?m)^<p align="center"><strong>Release Download Trend</strong></p>$',
'<p align="center"><strong>Release Download + Traffic Trend</strong></p>',
content,
)
content = re.sub(
r'(?m)^<p align="center"><strong>Release Download \+ Clone Trend</strong></p>$',
'<p align="center"><strong>Release Download + Traffic Trend</strong></p>',
content,
)
content = re.sub(
r'(?m)^<p align="center"><em>Styled line chart with highlighted points shows per-release totals and trend direction\.</em></p>$',
'<p align="center"><em>Styled line chart shows per-release totals with 14-day traffic counters for clones and views.</em></p>',
content,
)
content = re.sub(
r'(?m)^<p align="center"><em>Styled line chart shows per-release totals plus a 14-day git clone sparkline\.</em></p>$',
'<p align="center"><em>Styled line chart shows per-release totals with 14-day traffic counters for clones and views.</em></p>',
content,
)
content = re.sub(
r'(?m)^<p align="center"><em>Styled line chart shows per-release totals plus a 14-day git clone volume strip\.</em></p>$',
'<p align="center"><em>Styled line chart shows per-release totals with 14-day traffic counters for clones and views.</em></p>',
content,
)
content = re.sub(
r'(?m)^<p align="center"><em>Styled line chart shows per-release totals plus a scaled 14-day git clone volume bar\.</em></p>$',
'<p align="center"><em>Styled line chart shows per-release totals with 14-day traffic counters for clones and views.</em></p>',
content,
)
content = re.sub(
r'(?s)<p align="center">\s*<img alt="(?:Git clones|Unique cloners) \(14d\)".*?</p>\s*'
r'<p align="center">\s*<img alt="Clone snapshot \(UTC\)".*?</p>\s*',
"",
content,
)
traffic_badges = (
"<p align=\"center\">\n"
f" <img alt=\"Unique cloners (14d)\" src=\"{shields_badge('Unique cloners (14d)', str(clone_total), '7C3AED')}\">\n"
f" <img alt=\"Unique visitors (14d)\" src=\"{shields_badge('Unique visitors (14d)', str(view_total), '0EA5E9')}\">\n"
"</p>\n"
"<p align=\"center\">\n"
f" <img alt=\"Clone snapshot (UTC)\" src=\"{shields_badge('Clone snapshot (UTC)', clone_snapshot_utc, '334155', style='flat-square')}\">\n"
f" <img alt=\"View snapshot (UTC)\" src=\"{shields_badge('View snapshot (UTC)', view_snapshot_utc, '334155', style='flat-square')}\">\n"
"</p>"
)
content = content.replace(
'<p align="center"><em>Styled line chart shows per-release totals with 14-day traffic counters for clones and views.</em></p>',
'<p align="center"><em>Styled line chart shows per-release totals with 14-day traffic counters for clones and views.</em></p>\n'
+ traffic_badges,
1,
)
content = re.sub(
r"(?m)^> Latest release: \*\*.*\*\*$",
f"> Latest release: **{latest_tag}**",
content,
)
content = re.sub(
r"(?m)^- Latest release: \*\*.*\*\*$",
f"- Latest release: **{latest_tag}**",
content,
)
content = re.sub(
r"(?m)^> Last updated \(README\): \*\*.*\*\* for release line \*\*.*\*\*$",
f"> Last updated (README): **{today}** for release line **{latest_tag}**",
content,
)
if f"label={latest_tag}" not in content:
raise RuntimeError("README download badge replacement failed.")
content = re.sub(r"\n</p>\n{3,}## Project Docs", "\n</p>\n\n## Project Docs", content)
return content
def total_downloads_for_scale(points: list[ReleasePoint]) -> int:
return max(1, sum(point.downloads for point in points))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Refresh README download metrics and chart.")
parser.add_argument("--check", action="store_true", help="Fail if files are not up-to-date.")
parser.add_argument(
"--require-traffic-api",
action="store_true",
help="Fail if clone/view traffic API data cannot be fetched.",
)
parser.add_argument(
"--require-clone-api",
action="store_true",
help=argparse.SUPPRESS,
)
return parser.parse_args()
def main() -> int:
args = parse_args()
require_traffic_api = args.require_traffic_api or args.require_clone_api
update_timestamp_utc = dt.datetime.now(dt.UTC).strftime("%Y-%m-%d")
releases = fetch_releases()
clone_points, clone_total_api, clone_latest_timestamp = fetch_clone_traffic()
view_points, view_total_api, view_latest_timestamp = fetch_view_traffic()
releases_desc = sorted(releases, key=lambda r: r.published_at, reverse=True)
latest = releases_desc[0]
total_downloads = sum(r.downloads for r in releases_desc)
trend_points = sorted(releases_desc[:8], key=lambda r: r.published_at)
snapshot_date = dt.date.today().isoformat()
readme_before = README.read_text(encoding="utf-8")
existing_clone_total = parse_existing_clone_total(readme_before)
existing_view_total = parse_existing_view_total(readme_before)
if require_traffic_api and (clone_total_api is None or clone_latest_timestamp is None):
print(
"Clone traffic API unavailable. Refusing to reuse stale README clone metrics.",
file=sys.stderr,
)
return 1
if require_traffic_api and (view_total_api is None or view_latest_timestamp is None):
print(
"View traffic API unavailable. Refusing to reuse stale README view metrics.",
file=sys.stderr,
)
return 1
if clone_total_api is None:
print(
"Warning: clone traffic API unavailable; reusing existing README clone total.",
file=sys.stderr,
)
clone_total = clone_total_api if clone_total_api is not None else (existing_clone_total or 0)
clone_snapshot_utc = update_timestamp_utc
if view_total_api is None:
print(
"Warning: view traffic API unavailable; reusing existing README view total.",
file=sys.stderr,
)
view_total = view_total_api if view_total_api is not None else (existing_view_total or 0)
view_snapshot_utc = update_timestamp_utc
svg = generate_svg(trend_points, clone_total, view_total, snapshot_date)
readme_after = update_readme(
readme_before,
latest_tag=latest.tag,
total_downloads=total_downloads,
clone_total=clone_total,
clone_snapshot_utc=clone_snapshot_utc,
view_total=view_total,
view_snapshot_utc=view_snapshot_utc,
today=snapshot_date,
)
svg_before = SVG_PATH.read_text(encoding="utf-8") if SVG_PATH.exists() else ""
changed = (readme_before != readme_after) or (svg_before != svg)
if args.check:
if changed:
print("Download metrics are outdated. Run scripts/update_download_metrics.py", file=sys.stderr)
return 1
return 0
README.write_text(readme_after, encoding="utf-8")
SVG_PATH.parent.mkdir(parents=True, exist_ok=True)
SVG_PATH.write_text(svg, encoding="utf-8")
print(
f"Updated metrics: latest={latest.tag} ({latest.downloads}) total={total_downloads} "
f"clones14d={clone_total} points={len(trend_points)} clone_points={len(clone_points)} "
f"clone_snapshot_utc={clone_snapshot_utc} views14d={view_total} "
f"view_points={len(view_points)} view_snapshot_utc={view_snapshot_utc}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())