#!/usr/bin/env python3 """Generate OpenCADStudio GitHub growth SVG charts.""" from __future__ import annotations import argparse import json import math import os import re import urllib.request from datetime import datetime, timedelta, timezone from pathlib import Path from xml.sax.saxutils import escape API_VERSION = "2026-03-10" WIDTH = 960 HEIGHT = 410 PLOT_LEFT = 72 PLOT_RIGHT = 876 PLOT_TOP = 108 PLOT_BOTTOM = 350 # Isolated historical spike replaced by its neighboring-release mean. DOWNLOAD_OVERRIDES = {"v0.4.1": 61} THEMES = { "light": { "background": "#ffffff", "border": "#dbe3ef", "grid": "#dbe3ef", "text": "#0f172a", "muted": "#64748b", "line": "#0284c7", "area": "#38bdf8", "point": "#0369a1", "download_line": "#ea580c", "download_point": "#c2410c", }, "dark": { "background": "#0b1220", "border": "#263449", "grid": "#263449", "text": "#e5edf7", "muted": "#94a3b8", "line": "#38bdf8", "area": "#0ea5e9", "point": "#7dd3fc", "download_line": "#fb923c", "download_point": "#fdba74", }, } def next_link(header: str | None) -> str | None: if not header: return None for item in header.split(","): match = re.match(r'\s*<([^>]+)>;\s*rel="([^"]+)"', item) if match and match.group(2) == "next": return match.group(1) return None def fetch_star_dates(repository: str, token: str | None) -> list[datetime]: url = f"https://api.github.com/repos/{repository}/stargazers?per_page=100" headers = { "Accept": "application/vnd.github.star+json", "User-Agent": "OpenCADStudio-star-history", "X-GitHub-Api-Version": API_VERSION, } if token: headers["Authorization"] = f"Bearer {token}" dates: list[datetime] = [] for _ in range(100): request = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(request, timeout=30) as response: payload = json.load(response) link = response.headers.get("Link") for entry in payload: value = entry.get("starred_at") if value: dates.append(datetime.fromisoformat(value.replace("Z", "+00:00"))) url = next_link(link) if not url: break else: raise RuntimeError("stargazer pagination exceeded 100 pages") if not dates: raise RuntimeError("GitHub returned no dated stargazers") dates.sort() return dates def fetch_release_downloads( repository: str, token: str | None ) -> list[tuple[str, datetime, int]]: url = f"https://api.github.com/repos/{repository}/releases?per_page=100" headers = { "Accept": "application/vnd.github+json", "User-Agent": "OpenCADStudio-growth-history", "X-GitHub-Api-Version": API_VERSION, } if token: headers["Authorization"] = f"Bearer {token}" releases: list[tuple[str, datetime, int]] = [] for _ in range(100): request = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(request, timeout=30) as response: payload = json.load(response) link = response.headers.get("Link") for release in payload: published_at = release.get("published_at") if release.get("draft") or not published_at: continue tag = str(release.get("tag_name", "")) downloads = sum( int(asset.get("download_count", 0)) for asset in release.get("assets", []) ) releases.append( ( tag, datetime.fromisoformat(published_at.replace("Z", "+00:00")), DOWNLOAD_OVERRIDES.get(tag, downloads), ) ) url = next_link(link) if not url: break else: raise RuntimeError("release pagination exceeded 100 pages") releases.sort(key=lambda release: release[1]) if len(releases) < 2: raise RuntimeError("GitHub returned fewer than two published releases") return releases[:-1] def nice_axis(value: int) -> tuple[int, int]: rough_step = max(value / 5, 1) magnitude = 10 ** math.floor(math.log10(rough_step)) step = magnitude for multiplier in (1, 2, 5, 10): candidate = multiplier * magnitude if candidate >= rough_step: step = candidate break top = math.ceil(value / step) * step if top <= value: top += step return int(top), int(step) def render_svg( repository: str, dates: list[datetime], releases: list[tuple[str, datetime, int]], theme: str, ) -> str: colors = THEMES[theme] now = datetime.now(timezone.utc) start = min(dates[0], releases[0][1]) - timedelta(days=2) end = max(now, dates[-1], releases[-1][1]) + timedelta(days=2) duration = max((end - start).total_seconds(), 1) plot_width = PLOT_RIGHT - PLOT_LEFT plot_height = PLOT_BOTTOM - PLOT_TOP y_top, y_step = nice_axis(len(dates)) download_top, download_step = nice_axis(max(downloads for _, _, downloads in releases)) def x_position(moment: datetime) -> float: ratio = (moment - start).total_seconds() / duration return PLOT_LEFT + ratio * plot_width def y_position(stars: int) -> float: return PLOT_BOTTOM - (stars / y_top) * plot_height def download_y_position(downloads: int) -> float: return PLOT_BOTTOM - (downloads / download_top) * plot_height line_parts = [f"M {PLOT_LEFT:.1f} {PLOT_BOTTOM:.1f}"] for count, moment in enumerate(dates, 1): line_parts.append(f"H {x_position(moment):.1f} V {y_position(count):.1f}") line_path = " ".join(line_parts) area_path = f"{line_path} V {PLOT_BOTTOM:.1f} H {PLOT_LEFT:.1f} Z" download_path = " ".join( ( "M" if index == 0 else "L" ) + f" {x_position(published):.1f} {download_y_position(downloads):.1f}" for index, (_, published, downloads) in enumerate(releases) ) grid = [] for value in range(0, y_top + 1, y_step): y = y_position(value) grid.append( f'' ) grid.append( f'{value}' ) download_axis = [] for value in range(0, download_top + 1, download_step): y = download_y_position(value) download_axis.append( f'' ) download_axis.append( f'{value}' ) x_labels = [] for index in range(5): moment = start + (end - start) * (index / 4) x = x_position(moment) if index in (0, 4): label = moment.strftime("%b %Y") else: label = moment.strftime("%b") x_labels.append( f'' ) x_labels.append( f'{escape(label)}' ) title = f"{repository.split('/')[-1]} Growth" total_downloads = sum(downloads for _, _, downloads in releases) subtitle = ( f"{len(dates)} GitHub stars · {total_downloads:,} cleaned asset downloads across " f"{len(releases)} releases · Latest excluded · Updated {now:%d %b %Y}" ) last_x = x_position(dates[-1]) last_y = y_position(len(dates)) label_y = max(PLOT_TOP + 16, last_y - 14) last_release, last_published, last_downloads = releases[-1] last_release_x = x_position(last_published) last_release_y = download_y_position(last_downloads) download_points = "".join( f'' for _, published, downloads in releases ) return f''' {escape(title)} {escape(subtitle)} {escape(title)} {escape(subtitle)} GitHub stars Release downloads Stars Downloads / release {''.join(grid)} {''.join(download_axis)} {''.join(x_labels)} {download_points} {len(dates)} stars {escape(last_release)} · {last_downloads} ''' def main() -> None: parser = argparse.ArgumentParser() parser.add_argument( "--repository", default=os.environ.get("GITHUB_REPOSITORY", "HakanSeven12/OpenCADStudio"), ) parser.add_argument("--output-dir", type=Path, default=Path("dist")) args = parser.parse_args() token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") dates = fetch_star_dates(args.repository, token) releases = fetch_release_downloads(args.repository, token) args.output_dir.mkdir(parents=True, exist_ok=True) for theme in THEMES: output = args.output_dir / f"star-history-{theme}.svg" output.write_text( render_svg(args.repository, dates, releases, theme), encoding="utf-8" ) print( f"growth history: {len(dates)} stars, " f"{sum(downloads for _, _, downloads in releases)} downloads" ) if __name__ == "__main__": main()