#!/usr/bin/env python3 """Generate profile stats cards (SVG) for a Gitea/Forgejo user profile README. Stdlib only (no pip dependencies). Pulls public data from the Gitea API and renders two cards: stats/overview.svg - repos, stars earned, forks, followers, stars given stats/languages.svg - top languages bar + legend Configuration via environment variables: GIT_URL base URL of the instance (default: https://git.turbo-data.com) USER username to generate cards for ACCESS_TOKEN optional API token ("read:user" + "read:repository" scopes); omit if the profile is fully public OUTPUT_DIR where to write the cards (default: stats) Cards use a transparent background and a neutral palette that stays readable in both Gitea light and dark themes. """ import json import os import sys import urllib.parse import urllib.request from datetime import datetime, timezone GIT_URL = os.environ.get("GIT_URL", "https://git.turbo-data.com").rstrip("/") USER = os.environ.get("USER", "") or sys.exit("USER env var not set") TOKEN = os.environ.get("ACCESS_TOKEN", "") # optional OUT = os.environ.get("OUTPUT_DIR", "stats") UA = "gitea-profile-stats/1.0" API = GIT_URL + "/api/v1" # Neutral palette readable on both light (#fff) and dark (#0d1117) backgrounds INK = "#768390" # secondary text INK_STRONG = "#a5acaf" # primary text ACCENT = "#4493f8" STROKE = "#768390" MAX_LANGS = 5 # Small fallback language color table (used if remote colors fetch fails) FALLBACK_COLORS = { "Python": "#3572A5", "TypeScript": "#3178c6", "JavaScript": "#f1e05a", "Shell": "#89e051", "Go": "#00ADD8", "Rust": "#dea584", "C": "#555555", "C++": "#f34b7d", "Java": "#b07219", "HTML": "#e34c26", "CSS": "#563d7c", "SQL": "#e38c00", "Dockerfile": "#384d54", "Makefile": "#427819", "JSON": "#404e5c", "Markdown": "#083fa1", "YAML": "#cb171e", "Ruby": "#701516", "PHP": "#4F5D95", "Lua": "#000080", "SCSS": "#c6538c", "Vue": "#41b883", "Dart": "#00B4AB", "Kotlin": "#A97BFF", } def api(path, params=None): """GET {API}{path} -> (json_body, response_headers) or (None, None) on failure.""" url = f"{API}{path}" if params: url += "?" + urllib.parse.urlencode(params) req = urllib.request.Request(url, headers={"User-Agent": UA}) if TOKEN: req.add_header("Authorization", f"token {TOKEN}") try: with urllib.request.urlopen(req, timeout=15) as r: return json.load(r), dict(r.headers) except Exception: # noqa: BLE001 - some endpoints 401 without a token return None, None def total_count(path, params=None): """Count a list endpoint via X-Total-Count, using a cheap page-1 call. Returns None when the endpoint is not available (e.g. 401 without a token). """ _body, headers = api(path, dict(params or {}, limit=1)) if not headers: return None return int(headers.get("x-total-count", 0)) def esc(s): return ( str(s).replace("&", "&").replace("<", "<") .replace(">", ">").replace('"', """) ) def fetch(): user, _ = api(f"/users/{urllib.parse.quote(USER)}") repos, _ = api(f"/users/{urllib.parse.quote(USER)}/repos", {"limit": 50}) data = { "name": user.get("full_name") or user.get("login", USER), "login": user.get("login", USER), "visit_year": user.get("created", "")[:4], "repositories": user.get("public_repos", len(repos)), "followers": total_count(f"/users/{urllib.parse.quote(USER)}/followers"), "following": total_count(f"/users/{urllib.parse.quote(USER)}/following"), } stars = forks = 0 by_lang = {} for r in repos: stars += r.get("stars_count", 0) or 0 forks += r.get("forks_count", 0) or 0 lang = (r.get("language") or "").strip() if lang: weight = r.get("size") or 1 # primary-language weight by repo size by_lang[lang] = by_lang.get(lang, 0) + weight data.update(stars=stars, forks=forks, languages=by_lang, starred=total_count(f"/users/{urllib.parse.quote(USER)}/starred")) return data def colors_for(languages): colors = {} try: req = urllib.request.Request( "https://raw.githubusercontent.com/ozh/github-colors/master/colors.json", headers={"User-Agent": UA}, ) with urllib.request.urlopen(req, timeout=10) as r: colors = {k: "#" + v["color"] for k, v in json.load(r).items() if k in languages} except Exception: # noqa: BLE001 - offline instances still need cards pass autofill = [f"#{(abs(hash(l)) % 0xAAAAAA) + 0x555555:06x}" for i, l in enumerate(languages)] return {l: colors.get(l, FALLBACK_COLORS.get(l, c)) for l, c in zip(languages, autofill)} def svg_head(w, h): return (f'' '') def stat_items(d): return [ ("Repositories", d["repositories"]), ("Stars earned", d["stars"]), ("Forks", d["forks"]), ("Followers", d["followers"]), ("Stars given", d["starred"]), ("Member since", d["visit_year"] or "—"), ] def draw_overview(d): items = stat_items(d) cells = [] for i, (label, value) in enumerate(items): x = 26 + (i % 2) * 228 y = 84 + (i // 2) * 44 cells.append( f'' f'{esc(label)}' f'{esc(value if value is not None else "—")}' ) return ( svg_head(480, 200) + f'' + f'' + esc(d["name"]) + "" + f'' + esc(f"Gitea · @{d['login']}") + "" + "".join(cells) + f'' + esc("updated " + datetime.now(timezone.utc).strftime("%Y-%m-%d")) + "" ) def draw_languages(d): langs = sorted(d["languages"].items(), key=lambda kv: -kv[1])[:MAX_LANGS] cells = [] if langs: total = sum(v for _, v in langs) colors = colors_for([l for l, _ in langs]) x = 26 bar_w = 428 segs = [] legend = [] for lang, v in langs: share = v / total w = bar_w * share if share > 0.02 else max(bar_w * share, 3) segs.append( f'' ) lx = 26 + ((len(legend) % 2) * 214) ly = 106 + (len(legend) // 2) * 26 legend.append( f'' f'' + esc(lang) + f'' f"{share * 100:.1f}%" ) x += w label = f"Top languages" footer_y = 186 if len(legend) > 3 else 146 cells = ( [label] + [f'' for bar_x, bar_y in [(25.5, 65.5)]] + segs + legend + [f'' + esc("updated " + datetime.now(timezone.utc).strftime("%Y-%m-%d")) + ""] ) height = footer_y + 16 else: height = 100 cells = [ f'No public repositories ' f'with languages yet.', ] body = ( svg_head(480, height) + f'' + "".join(cells) + "" ) return body def main(): os.makedirs(OUT, exist_ok=True) d = fetch() print(f"Loaded profile for {d['login']}: {d['repositories']} repos, " f"{d['stars']} stars, {d['forks']} forks, {d['followers']} followers") for name, svg in (("overview", draw_overview(d)), ("languages", draw_languages(d))): path = os.path.join(OUT, f"{name}.svg") with open(path, "w", encoding="utf-8") as f: f.write(svg) print(f"wrote {path}") if __name__ == "__main__": main()