From af3e5e404db9792dfa4c9a7fad31b6359e04b972 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 6 Aug 2026 15:28:38 +0300 Subject: [PATCH] fix(ci): Render star history charts from the GitHub API --- .github/workflows/star-history.yaml | 18 +- scripts/star_history_lib/assets/README.md | 16 + .../star_history_lib/assets/xkcd-script.woff | Bin 0 -> 40056 bytes scripts/star_history_lib/chart.py | 332 +++++++++ scripts/star_history_lib/cli.py | 100 ++- scripts/star_history_lib/client.py | 92 --- scripts/star_history_lib/seal.py | 70 -- scripts/star_history_lib/stars.py | 233 +++++++ scripts/star_history_lib/svg.py | 137 ---- scripts/test_star_history.py | 634 ++++++++++++++++++ 10 files changed, 1269 insertions(+), 363 deletions(-) create mode 100644 scripts/star_history_lib/assets/README.md create mode 100644 scripts/star_history_lib/assets/xkcd-script.woff create mode 100644 scripts/star_history_lib/chart.py delete mode 100644 scripts/star_history_lib/client.py delete mode 100644 scripts/star_history_lib/seal.py create mode 100644 scripts/star_history_lib/stars.py delete mode 100644 scripts/star_history_lib/svg.py create mode 100644 scripts/test_star_history.py diff --git a/.github/workflows/star-history.yaml b/.github/workflows/star-history.yaml index 6a2e4a6a5..4d3e028b6 100644 --- a/.github/workflows/star-history.yaml +++ b/.github/workflows/star-history.yaml @@ -7,6 +7,7 @@ on: - .github/workflows/star-history.yaml - scripts/star_history.py - scripts/star_history_lib/** + - scripts/test_star_history.py schedule: - cron: '23 4 * * *' @@ -27,7 +28,6 @@ env: RELEASE_TAG: star-history/latest OUTPUT_DIR: dist/star-history SCALE: '2' - PYNACL_VERSION: '1.6.2' jobs: publish: @@ -43,20 +43,19 @@ jobs: with: python-version: '3.x' - - name: Install chart dependencies + - name: Run chart unit tests run: | set -euo pipefail - python3 -m pip install --disable-pip-version-check --quiet \ - "pynacl==$PYNACL_VERSION" + python3 -m unittest discover -s scripts -p 'test_star_history.py' - - name: Mirror upstream charts + - name: Render star history charts env: - STAR_HISTORY_TOKEN: ${{ secrets.STAR_HISTORY_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - python3 scripts/star_history.py mirror \ + python3 scripts/star_history.py build \ --repository "$GITHUB_REPOSITORY" \ --output-dir "$OUTPUT_DIR" \ > manifest.json @@ -100,10 +99,13 @@ jobs: ASSET_LIST=$(python3 scripts/star_history.py assets --manifest manifest.json) mapfile -t ASSETS <<< "$ASSET_LIST" + STARS=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["stars"])' manifest.json) + printf '%s\n' \ - "Star history charts for \`$GITHUB_REPOSITORY\`, sanitized and mirrored from star-history.com." \ + "Star history charts for \`$GITHUB_REPOSITORY\`, rendered from the GitHub stargazer API." \ "" \ "- **Updated:** $UPDATED_AT" \ + "- **Stars:** $STARS" \ "" \ "The \`.png\` assets are the ones embedded in the README. GitHub serves" \ "release assets as \`application/octet-stream\`, which its image proxy" \ diff --git a/scripts/star_history_lib/assets/README.md b/scripts/star_history_lib/assets/README.md new file mode 100644 index 000000000..35d6eea36 --- /dev/null +++ b/scripts/star_history_lib/assets/README.md @@ -0,0 +1,16 @@ +# Chart assets + +## `xkcd-script.woff` + +The handwriting face the star history charts are drawn in. It is embedded into +every generated SVG as a `data:` URI so the charts rasterize identically on any +runner and stay self-contained when viewed on their own. + +- **Font:** xkcd Script, version 1.0 +- **Upstream:** https://github.com/ipython/xkcd-font +- **License:** Creative Commons Attribution-NonCommercial 3.0 + +Note the **NonCommercial** term before reusing the generated charts in a +commercial context. These are the same font bytes the previously mirrored +star-history.com charts already carried, so publishing them is not a change in +what this repository distributes. diff --git a/scripts/star_history_lib/assets/xkcd-script.woff b/scripts/star_history_lib/assets/xkcd-script.woff new file mode 100644 index 0000000000000000000000000000000000000000..9fa3f88071e14215707c8ea08fe6619e60775d2a GIT binary patch literal 40056 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

NRIh)HZhrM*_`~vM@J;DPq|n$UXf~WOgW(D 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 Q0p^t~XXTst2Qb)Ja6Re81ONa4 literal 0 HcmV?d00001 diff --git a/scripts/star_history_lib/chart.py b/scripts/star_history_lib/chart.py new file mode 100644 index 000000000..fb099095a --- /dev/null +++ b/scripts/star_history_lib/chart.py @@ -0,0 +1,332 @@ +"""Renders star history charts in star-history.com's hand-drawn style. + +The layout, the xkcdify turbulence filter and the type scale are matched to the +charts this repository previously mirrored from star-history.com, so the README +keeps the look it had. Their watermark and their logo are deliberately left out: +these charts are built from the GitHub API, so crediting their service would +misstate where the data came from. The repository owner's avatar takes the place +their mark used to occupy. +""" + +import base64 +import functools +import math +from datetime import datetime, timedelta +from pathlib import Path +from xml.sax.saxutils import escape + +from . import ChartError + + +WIDTH = 800 +HEIGHT = 533.333 +# Chrome needs whole pixels for the screenshot viewport. +PAGE_WIDTH = 800 +PAGE_HEIGHT = 534 + +PLOT_ORIGIN = (70, 60) +PLOT_WIDTH = 700 +PLOT_HEIGHT = 423.333 +# Where a count of zero sits inside the translated plot group. +BASELINE = 423.833 + +FONT_FAMILY = "xkcd" +FONT_PATH = Path(__file__).parent / "assets" / "xkcd-script.woff" + +# The owner avatar sits left of the centred title, circular-clipped, at the same +# coordinates star-history used for its own mark. +LOGO_SIZE = 22 +LOGO_X = 316 +LOGO_Y = 12 +LOGO_CLIP_ID = "clip-circle-title" + +TITLE = "Star History" +TITLE_SIZE = 20 +TITLE_BASELINE = 30 + +PALETTES = { + "light": {"background": "#fff", "ink": "#000", "series": "#dd4528"}, + "dark": {"background": "#0d1117", "ink": "#fff", "series": "#ff6b6b"}, +} + +# star-history plots a sampled curve rather than every star; sampling first is +# what lets the spline read as a trend instead of tracing every single star. +MAX_CHART_POINTS = 11 +X_TICK_TARGET = 5 +Y_TICK_TARGET = 6 +MONTH_STEPS = (1, 3, 6, 12) +DAYS_PER_MONTH = 30.44 +WEEK_SPAN_DAYS = 14 +DAY_SPAN_DAYS = 60 + +LEGEND_TEXT_ORIGIN = 29 +LEGEND_CHARACTER_WIDTH = 7.5 + +E10 = math.sqrt(50) +E5 = math.sqrt(10) +E2 = math.sqrt(2) + + +def palette_of(theme: str) -> dict: + try: + return PALETTES[theme] + except KeyError: + raise ChartError(f"unknown chart theme '{theme}'") from None + + +@functools.lru_cache(maxsize=1) +def font_face() -> str: + try: + woff = FONT_PATH.read_bytes() + except OSError as error: + raise ChartError(f"the embedded chart font is missing: {error}") from error + encoded = base64.b64encode(woff).decode("ascii") + return ( + f'@font-face{{font-family:"{FONT_FAMILY}";' + f"src:url(data:application/font-woff;charset=utf-8;base64,{encoded})" + 'format("woff")}' + ) + + +def number(value: float) -> str: + return f"{round(value, 3):g}" + + +def tick_step(maximum: float, count: int) -> float: + """d3's tickIncrement, so the axis lands on the same numbers d3 would pick.""" + raw = maximum / max(1, count) + if raw <= 0: + return 1 + power = math.floor(math.log10(raw)) + error = raw / 10**power + if error >= E10: + factor = 10 + elif error >= E5: + factor = 5 + elif error >= E2: + factor = 2 + else: + factor = 1 + return factor * 10**power + + +def value_ticks(maximum: int) -> list[int]: + step = max(1, round(tick_step(maximum, Y_TICK_TARGET))) + return [tick for tick in range(0, maximum + 1, step)] + + +def month_step(span: timedelta) -> int: + months = span.days / DAYS_PER_MONTH + for step in MONTH_STEPS: + if months / step <= X_TICK_TARGET: + return step + return MONTH_STEPS[-1] + + +def month_ticks(start: datetime, end: datetime, step: int) -> list[datetime]: + ticks = [] + cursor = start.year * 12 + start.month - 1 + while True: + year, month = divmod(cursor, 12) + moment = datetime(year, month + 1, 1, tzinfo=start.tzinfo) + if moment > end: + return ticks + if moment >= start and month % step == 0: + ticks.append(moment) + cursor += 1 + + +def spaced_ticks(start: datetime, end: datetime, stride: timedelta) -> list[datetime]: + ticks = [] + moment = start.replace(hour=0, minute=0, second=0, microsecond=0) + stride + while moment <= end: + ticks.append(moment) + moment += stride + return ticks + + +def time_ticks(start: datetime, end: datetime) -> list[tuple[datetime, str]]: + span = end - start + if span.days <= WEEK_SPAN_DAYS: + return [(tick, f"{tick:%b %d}") for tick in spaced_ticks(start, end, timedelta(days=2))] + if span.days <= DAY_SPAN_DAYS: + return [(tick, f"{tick:%b %d}") for tick in spaced_ticks(start, end, timedelta(days=7))] + + ticks = month_ticks(start, end, month_step(span)) + return [ + (tick, f"{tick:%Y}" if tick.month == 1 else f"{tick:%B}") for tick in ticks + ] + + +def downsample(points: list[tuple], limit: int = MAX_CHART_POINTS) -> list[tuple]: + if len(points) <= limit: + return points + stride = (len(points) - 1) / (limit - 1) + return [points[round(index * stride)] for index in range(limit)] + + +def cubic(before: tuple, current: tuple, following: tuple) -> str: + """One uniform cubic B-spline span, in d3 curveBasis' bezier form.""" + x0, y0 = before + x1, y1 = current + x2, y2 = following + return ( + f"C{number((2 * x0 + x1) / 3)} {number((2 * y0 + y1) / 3)}" + f" {number((x0 + 2 * x1) / 3)} {number((y0 + 2 * y1) / 3)}" + f" {number((x0 + 4 * x1 + x2) / 6)} {number((y0 + 4 * y1 + y2) / 6)}" + ) + + +def basis_path(coordinates: list[tuple[float, float]]) -> str: + """A B-spline through the samples, matching d3's curveBasis. + + The spline approximates rather than interpolates, which is what smooths the + corners a plain interpolation leaves behind. Being variation-diminishing, it + still cannot dip below a series that only ever rises. + """ + first = coordinates[0] + path = f"M{number(first[0])} {number(first[1])}" + if len(coordinates) == 1: + return path + if len(coordinates) == 2: + last = coordinates[1] + return f"{path}L{number(last[0])} {number(last[1])}" + + second = coordinates[1] + path += ( + f"L{number((5 * first[0] + second[0]) / 6)}" + f" {number((5 * first[1] + second[1]) / 6)}" + ) + for index in range(2, len(coordinates)): + path += cubic(coordinates[index - 2], coordinates[index - 1], coordinates[index]) + + last = coordinates[-1] + path += cubic(coordinates[-2], last, last) + return f"{path}L{number(last[0])} {number(last[1])}" + + +def projector(start: datetime, end: datetime, maximum: int): + span = (end - start).total_seconds() or 1.0 + + def project(moment: datetime, value: float) -> tuple[float, float]: + x = (moment - start).total_seconds() / span * PLOT_WIDTH + y = BASELINE - (value / maximum if maximum else 0) * PLOT_HEIGHT + return x, y + + return project + + +def x_axis(ticks: list[tuple[datetime, str]], project, ink: str) -> str: + labels = "".join( + f'' + f"{escape(label)}" + for moment, label in ticks + ) + return ( + '' + f'' + f"{labels}" + ) + + +def y_axis(ticks: list[int], maximum: int, ink: str) -> str: + rows = "" + for tick in ticks: + y = BASELINE - (tick / maximum if maximum else 0) * PLOT_HEIGHT + # star-history blanks the zero label so it does not collide with the x axis. + label = " " if tick == 0 else str(tick) + rows += ( + '' + f'' + f'{escape(label)}' + ) + return ( + '' + f'' + f"{rows}" + ) + + +def legend(repository: str, colors: dict) -> str: + box = LEGEND_TEXT_ORIGIN + LEGEND_CHARACTER_WIDTH * len(repository) + return ( + f'' + f'' + f'{escape(repository)}' + ) + + +def logo(avatar: str) -> str: + if not avatar: + return "" + radius = LOGO_SIZE / 2 + return ( + f'' + f'' + f'' + ) + + +def titles(ink: str, avatar: str) -> str: + return ( + f"{logo(avatar)}" + f'{escape(TITLE)}' + f'Date' + f'GitHub Stars' + ) + + +def render(repository: str, timeline: dict, theme: str) -> str: + points = downsample(timeline["points"]) + if not points: + raise ChartError(f"no stargazer points to chart for {repository}") + + colors = palette_of(theme) + start, end = points[0][0], points[-1][0] + if end <= start: + end = start + timedelta(days=1) + + maximum = max(timeline["total"], max(count for _, count in points)) + project = projector(start, end, maximum) + curve = basis_path([project(moment, count) for moment, count in points]) + + return ( + f'' + f"" + '' + '' + '' + f'' + f"{x_axis(time_ticks(start, end), project, colors['ink'])}" + f"{y_axis(value_ticks(maximum), maximum, colors['ink'])}" + f'' + f"{legend(repository, colors)}" + "" + f"{titles(colors['ink'], timeline.get('avatar', ''))}" + "" + ) diff --git a/scripts/star_history_lib/cli.py b/scripts/star_history_lib/cli.py index d711dbe2a..4f0f0659c 100644 --- a/scripts/star_history_lib/cli.py +++ b/scripts/star_history_lib/cli.py @@ -2,32 +2,30 @@ import json import os import sys +from datetime import datetime, timezone from . import ChartError -from .client import CHART_ENDPOINT, chart_url, fetch_svg +from .chart import PAGE_HEIGHT, PAGE_WIDTH, palette_of, render from .raster import asset_paths, rasterize, write_raster_page -from .seal import seal_token -from .svg import background_of, sanitize_svg, viewport_of +from .stars import API_ROOT, star_timeline THEMES = ("light", "dark") -def add_mirror_parser(subparsers) -> None: - parser = subparsers.add_parser("mirror") +def add_build_parser(subparsers) -> None: + parser = subparsers.add_parser("build") parser.add_argument("--repository", required=True) parser.add_argument("--output-dir", required=True) parser.add_argument("--basename", default="star-history") - parser.add_argument("--token", default=os.environ.get("STAR_HISTORY_TOKEN", "")) - parser.add_argument("--chart-type", default="date") - parser.add_argument("--legend", default="top-left") - parser.set_defaults(handler=mirror) + parser.add_argument("--token", default=os.environ.get("GITHUB_TOKEN", "")) + parser.set_defaults(handler=build) def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Mirror star-history.com charts") + parser = argparse.ArgumentParser(description="Chart a repository's star history") subparsers = parser.add_subparsers(required=True) - add_mirror_parser(subparsers) + add_build_parser(subparsers) raster_parser = subparsers.add_parser("rasterize") raster_parser.add_argument("--manifest", required=True) @@ -41,65 +39,55 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() -def fetch_sanitized_svg(url: str) -> str: - return sanitize_svg(fetch_svg(url)) +def write_chart(args: argparse.Namespace, timeline: dict, theme: str) -> dict: + svg = render(args.repository, timeline, theme) + svg_path = os.path.join(args.output_dir, f"{args.basename}-{theme}.svg") + png_path = os.path.join(args.output_dir, f"{args.basename}-{theme}.png") + with open(svg_path, "w", encoding="utf-8") as handle: + handle.write(svg) + background = palette_of(theme)["background"] + return { + "theme": theme, + "svg": svg_path, + "png": png_path, + "raster_page": write_raster_page( + svg_path, background, PAGE_WIDTH, PAGE_HEIGHT + ), + "background": background, + "width": PAGE_WIDTH, + "height": PAGE_HEIGHT, + "bytes": len(svg.encode("utf-8")), + } -def build_manifest(args: argparse.Namespace) -> dict: - if args.repository.count("/") != 1: - raise ChartError(f"--repository must be owner/name, got '{args.repository}'") +def build_manifest(args: argparse.Namespace, now: datetime) -> dict: os.makedirs(args.output_dir, exist_ok=True) - charts = [] - assets = [] - - sealed = seal_token(args.token) if args.token else "" - if not sealed: - print( - "::warning::No STAR_HISTORY_TOKEN set; falling back to the shared " - "star-history.com token pool, which is often rate-limited", - file=sys.stderr, - ) + timeline = star_timeline(args.repository, args.token, now) - for theme in THEMES: - url = chart_url( - args.repository, theme, sealed, args.chart_type, args.legend - ) - svg = fetch_sanitized_svg(url) - svg_path = os.path.join(args.output_dir, f"{args.basename}-{theme}.svg") - png_path = os.path.join(args.output_dir, f"{args.basename}-{theme}.png") - with open(svg_path, "w", encoding="utf-8") as handle: - handle.write(svg) - - width, height = viewport_of(svg, theme) - background = background_of(svg, theme) - charts.append( - { - "theme": theme, - "svg": svg_path, - "png": png_path, - "raster_page": write_raster_page( - svg_path, background, width, height - ), - "background": background, - "width": width, - "height": height, - "bytes": len(svg.encode("utf-8")), - } - ) - assets.extend((svg_path, png_path)) + charts = [write_chart(args, timeline, theme) for theme in THEMES] + assets = [path for chart in charts for path in (chart["svg"], chart["png"])] return { "repository": args.repository, - "source": CHART_ENDPOINT, + "source": f"{API_ROOT}/repos/{args.repository}/stargazers", "authenticated": bool(args.token), + "stars": timeline["total"], + "sampled": timeline["sampled"], + "points": len(timeline["points"]), + "generated_at": now.isoformat(timespec="seconds"), "assets": assets, "charts": charts, } -def mirror(args: argparse.Namespace) -> int: - print(json.dumps(build_manifest(args))) +def build(args: argparse.Namespace) -> int: + if not args.token: + raise ChartError( + "no GitHub token available; the workflow must pass GITHUB_TOKEN so the " + "stargazer pages can be read without hitting the anonymous rate limit" + ) + print(json.dumps(build_manifest(args, datetime.now(timezone.utc)))) return 0 diff --git a/scripts/star_history_lib/client.py b/scripts/star_history_lib/client.py deleted file mode 100644 index e6ade0d2d..000000000 --- a/scripts/star_history_lib/client.py +++ /dev/null @@ -1,92 +0,0 @@ -import re -import sys -import time -import urllib.error -import urllib.parse -import urllib.request - -from . import ChartError - - -CHART_ENDPOINT = "https://api.star-history.com/chart" -USER_AGENT = "opentaint-star-history" -REQUEST_TIMEOUT = 60 -RETRIES = 3 -RETRY_BACKOFF_SECONDS = 5 -MIN_CHART_BYTES = 2048 -ERROR_BODY_LIMIT = 200 - -TERMINAL_STATUS_HINTS = { - 400: ( - "the sealed token was rejected before it was decrypted; star-history.com " - "may have rotated the key in star_history_lib.seal.SEALING_PUBLIC_KEY" - ), - 401: ( - "the GitHub token was decrypted but refused; refresh the " - "STAR_HISTORY_TOKEN secret with a valid personal access token" - ), -} - - -def chart_url( - repository: str, theme: str, token: str, chart_type: str, legend: str -) -> str: - query = [("repos", repository), ("type", chart_type), ("legend", legend)] - if theme != "light": - query.append(("theme", theme)) - if token: - query.append(("sealed_token", token)) - return f"{CHART_ENDPOINT}?{urllib.parse.urlencode(query)}" - - -def redact(url: str) -> str: - return re.sub(r"(sealed_token=)[^&]*", r"\1***", url) - - -def describe_http_error(error: urllib.error.HTTPError) -> str: - try: - body = error.read().decode("utf-8", "replace").strip() - except OSError: - body = "" - if not body: - return f"HTTP {error.code}" - return f"HTTP {error.code}: {redact(body[:ERROR_BODY_LIMIT])}" - - -def fetch_svg(url: str) -> str: - request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) - last_error = "" - - for attempt in range(1, RETRIES + 1): - try: - with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response: - content_type = response.headers.get("Content-Type", "") - body = response.read().decode("utf-8") - except urllib.error.HTTPError as error: - last_error = describe_http_error(error) - hint = TERMINAL_STATUS_HINTS.get(error.code) - if hint: - raise ChartError(f"{redact(url)} failed: {last_error} — {hint}") - except (urllib.error.URLError, TimeoutError) as error: - last_error = str(error) - except UnicodeDecodeError as error: - raise ChartError( - f"{redact(url)} returned non-text data: {error}" - ) from error - else: - if "svg" not in content_type: - last_error = f"unexpected Content-Type '{content_type}'" - elif len(body) < MIN_CHART_BYTES: - last_error = f"chart is suspiciously small ({len(body)} bytes)" - else: - return body - - if attempt < RETRIES: - print( - f"::warning::Attempt {attempt}/{RETRIES} for {redact(url)} failed: " - f"{last_error}; retrying", - file=sys.stderr, - ) - time.sleep(RETRY_BACKOFF_SECONDS * attempt) - - raise ChartError(f"{redact(url)} failed after {RETRIES} attempts: {last_error}") diff --git a/scripts/star_history_lib/seal.py b/scripts/star_history_lib/seal.py deleted file mode 100644 index 77bc7b082..000000000 --- a/scripts/star_history_lib/seal.py +++ /dev/null @@ -1,70 +0,0 @@ -import base64 -import binascii -import hashlib - -from . import ChartError - - -SEALING_PUBLIC_KEY = "vP9uLIF/19VVvLUEZMms8LXJo/RCjRys/Z9S543eG10=" - -NONCE_BYTES = 24 -PUBLIC_KEY_BYTES = 32 - -GITHUB_TOKEN_PREFIXES = ( - "ghp_", - "gho_", - "ghu_", - "ghs_", - "ghr_", - "github_pat_", -) - - -def require_nacl(): - try: - from nacl.public import Box, PrivateKey, PublicKey - except ImportError as error: - raise ChartError( - "sealing a token requires PyNaCl; install it with 'pip install pynacl'" - ) from error - return Box, PrivateKey, PublicKey - - -def validate_token(token: str) -> None: - if token != token.strip(): - raise ChartError("the GitHub token has leading or trailing whitespace") - if not token.startswith(GITHUB_TOKEN_PREFIXES): - raise ChartError( - "the GitHub token must be a personal access token starting with one " - f"of {', '.join(GITHUB_TOKEN_PREFIXES)}. The workflow seals the token " - "itself, so store the bare token rather than a pre-sealed one." - ) - - -def decode_public_key(public_key: str) -> bytes: - try: - key = base64.b64decode(public_key, validate=True) - except (binascii.Error, ValueError) as error: - raise ChartError(f"the sealing public key is not valid base64: {error}") from error - if len(key) != PUBLIC_KEY_BYTES: - raise ChartError( - f"the sealing public key must be {PUBLIC_KEY_BYTES} bytes, got {len(key)}" - ) - return key - - -def seal_token(token: str, public_key: str = SEALING_PUBLIC_KEY) -> str: - validate_token(token) - Box, PrivateKey, PublicKey = require_nacl() - - recipient_key = decode_public_key(public_key) - recipient = PublicKey(recipient_key) - ephemeral = PrivateKey.generate() - ephemeral_key = bytes(ephemeral.public_key) - - nonce = hashlib.sha512(ephemeral_key + recipient_key).digest()[:NONCE_BYTES] - ciphertext = Box(ephemeral, recipient).encrypt( - token.encode("utf-8"), nonce - ).ciphertext - - return base64.urlsafe_b64encode(ephemeral_key + ciphertext).decode("ascii").rstrip("=") diff --git a/scripts/star_history_lib/stars.py b/scripts/star_history_lib/stars.py new file mode 100644 index 000000000..14c6492b9 --- /dev/null +++ b/scripts/star_history_lib/stars.py @@ -0,0 +1,233 @@ +import base64 +import json +import math +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone + +from . import ChartError + + +API_ROOT = "https://api.github.com" +USER_AGENT = "opentaint-star-history" +API_VERSION = "2022-11-28" +STAR_MEDIA_TYPE = "application/vnd.github.star+json" +JSON_MEDIA_TYPE = "application/vnd.github+json" + +AVATAR_PIXELS = 128 +PER_PAGE = 100 +# GitHub refuses to paginate past 400 pages of stargazers. +MAX_PAGE = 400 +# Beyond this many pages we sample the curve instead of reading every star. +MAX_PAGE_REQUESTS = 30 + +REQUEST_TIMEOUT = 30 +RETRIES = 4 +RETRY_BACKOFF_SECONDS = 3 +ERROR_BODY_LIMIT = 200 + +TERMINAL_STATUS_HINTS = { + 401: ( + "the GitHub token was refused; refresh the token the workflow passes as " + "GITHUB_TOKEN" + ), + 404: ( + "the repository was not found; check the name and that the token can see it" + ), +} + + +def headers(token: str, accept: str) -> dict[str, str]: + sent = { + "Accept": accept, + "User-Agent": USER_AGENT, + "X-GitHub-Api-Version": API_VERSION, + } + if token: + sent["Authorization"] = f"Bearer {token}" + return sent + + +def describe_http_error(error: urllib.error.HTTPError) -> str: + try: + body = error.read().decode("utf-8", "replace").strip() + except OSError: + body = "" + if not body: + return f"HTTP {error.code}" + return f"HTTP {error.code}: {body[:ERROR_BODY_LIMIT]}" + + +def rate_limited(error: urllib.error.HTTPError) -> bool: + if error.code not in (403, 429): + return False + return error.headers.get("X-RateLimit-Remaining") == "0" + + +def fetch(url: str, request_headers: dict[str, str]) -> tuple[str, bytes]: + request = urllib.request.Request(url, headers=request_headers) + last_error = "" + + for attempt in range(1, RETRIES + 1): + try: + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response: + return response.headers.get("Content-Type", ""), response.read() + except urllib.error.HTTPError as error: + last_error = describe_http_error(error) + if rate_limited(error): + raise ChartError( + f"{url} failed: {last_error} — the GitHub API rate limit is " + "exhausted for this token" + ) + hint = TERMINAL_STATUS_HINTS.get(error.code) + if hint: + raise ChartError(f"{url} failed: {last_error} — {hint}") + except (urllib.error.URLError, TimeoutError) as error: + last_error = str(error) + + if attempt < RETRIES: + print( + f"::warning::Attempt {attempt}/{RETRIES} for {url} failed: " + f"{last_error}; retrying", + file=sys.stderr, + ) + time.sleep(RETRY_BACKOFF_SECONDS * attempt) + + raise ChartError(f"{url} failed after {RETRIES} attempts: {last_error}") + + +def fetch_json(url: str, token: str, accept: str = JSON_MEDIA_TYPE): + _, payload = fetch(url, headers(token, accept)) + try: + return json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ChartError(f"{url} returned invalid JSON: {error}") from error + + +def avatar_data_uri(url: str) -> str: + """The owner's avatar, inlined so the chart stays a single self-contained file.""" + if not url: + return "" + + separator = "&" if "?" in url else "?" + sized = f"{url}{separator}s={AVATAR_PIXELS}" + try: + # A different host, so the GitHub token deliberately does not travel here. + content_type, payload = fetch(sized, {"User-Agent": USER_AGENT}) + except ChartError as error: + print(f"::warning::{error}; the chart will omit the logo", file=sys.stderr) + return "" + + media_type = content_type.split(";")[0].strip() + if not media_type.startswith("image/"): + print( + f"::warning::the avatar came back as '{media_type}' rather than an " + "image; the chart will omit the logo", + file=sys.stderr, + ) + return "" + + encoded = base64.b64encode(payload).decode("ascii") + return f"data:{media_type};base64,{encoded}" + + +def parse_timestamp(value: str) -> datetime: + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone( + timezone.utc + ) + except (AttributeError, ValueError) as error: + raise ChartError(f"unparseable starred_at timestamp {value!r}") from error + + +def repository_facts(repository: str, token: str) -> tuple[int, datetime, str]: + """The star total, the creation date the curve starts from, and the logo.""" + payload = fetch_json(f"{API_ROOT}/repos/{repository}", token) + count = payload.get("stargazers_count") + if not isinstance(count, int): + raise ChartError(f"{repository} returned no stargazers_count") + created = payload.get("created_at") + if not created: + raise ChartError(f"{repository} returned no created_at") + owner = payload.get("owner") or {} + return count, parse_timestamp(created), owner.get("avatar_url", "") + + +def stargazer_page(repository: str, page: int, token: str) -> list[dict]: + url = ( + f"{API_ROOT}/repos/{repository}/stargazers" + f"?per_page={PER_PAGE}&page={page}" + ) + payload = fetch_json(url, token, accept=STAR_MEDIA_TYPE) + if not isinstance(payload, list): + raise ChartError(f"{url} returned {type(payload).__name__}, expected a list") + return payload + + +def sampled_pages(page_count: int, budget: int = MAX_PAGE_REQUESTS) -> list[int]: + """Evenly spaced page numbers, always including the first and the last.""" + if page_count <= budget or budget < 2: + return list(range(1, min(page_count, max(budget, 1)) + 1)) + step = (page_count - 1) / (budget - 1) + pages = {1 + round(index * step) for index in range(budget)} + return sorted(pages) + + +def exact_timeline(repository: str, page_count: int, token: str) -> list[tuple]: + timeline = [] + for page in range(1, page_count + 1): + for entry in stargazer_page(repository, page, token): + timeline.append(parse_timestamp(entry["starred_at"])) + timeline.sort() + return [(moment, index) for index, moment in enumerate(timeline, start=1)] + + +def sampled_timeline(repository: str, page_count: int, token: str) -> list[tuple]: + timeline = [] + for page in sampled_pages(page_count): + entries = stargazer_page(repository, page, token) + if not entries: + continue + moment = parse_timestamp(entries[0]["starred_at"]) + timeline.append((moment, (page - 1) * PER_PAGE + 1)) + return timeline + + +def star_timeline(repository: str, token: str, now: datetime) -> dict: + """Cumulative star counts over time, plus the metadata the manifest reports.""" + if repository.count("/") != 1: + raise ChartError(f"repository must be owner/name, got '{repository}'") + + total, created, avatar_url = repository_facts(repository, token) + if total < 1: + raise ChartError(f"{repository} has no stargazers yet, so there is no chart") + + page_count = min(math.ceil(total / PER_PAGE), MAX_PAGE) + sampled = page_count > MAX_PAGE_REQUESTS + if sampled: + points = sampled_timeline(repository, page_count, token) + print( + f"::notice::{repository} has {total} stars; sampling " + f"{MAX_PAGE_REQUESTS} of {page_count} stargazer pages", + file=sys.stderr, + ) + else: + points = exact_timeline(repository, page_count, token) + + if not points: + raise ChartError(f"{repository} returned no stargazer timestamps") + + # Anchor the curve where the repository started, at zero stars. + if created < points[0][0]: + points = [(created, 0), *points] + if now > points[-1][0]: + points = [*points, (now, total)] + + return { + "total": total, + "sampled": sampled, + "points": points, + "avatar": avatar_data_uri(avatar_url), + } diff --git a/scripts/star_history_lib/svg.py b/scripts/star_history_lib/svg.py deleted file mode 100644 index 1cde575df..000000000 --- a/scripts/star_history_lib/svg.py +++ /dev/null @@ -1,137 +0,0 @@ -import math -import re -import sys -import xml.etree.ElementTree as ET - -from . import ChartError - - -SVG_NAMESPACE = "http://www.w3.org/2000/svg" -DEFAULT_BACKGROUNDS = {"light": "#ffffff", "dark": "#0d1117"} -DEFAULT_SIZE = (800, 534) - -BACKGROUND_RE = re.compile(r"background:\s*(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)") -WIDTH_RE = re.compile(r'\bwidth="([0-9.]+)"') -HEIGHT_RE = re.compile(r'\bheight="([0-9.]+)"') -CSS_URL_RE = re.compile(r"url\(\s*(['\"]?)(.*?)\1\s*\)", re.IGNORECASE | re.DOTALL) -CSS_URL_START_RE = re.compile(r"url\s*\(", re.IGNORECASE) -UNSAFE_CSS_RE = re.compile( - r"@import|expression\s*\(|behavior\s*:|" - r"(?:file|ftp|https?|javascript|vbscript)\s*:", - re.IGNORECASE, -) -SAFE_RASTER_DATA_RE = re.compile( - r"^data:image/(?:gif|jpeg|png|webp)(?:[;,])", re.IGNORECASE -) -SAFE_FONT_DATA_RE = re.compile( - r"^data:(?:application/font-woff|font/woff2?)(?:[;,])", re.IGNORECASE -) -DANGEROUS_ELEMENTS = { - "a", - "animate", - "animatemotion", - "animatetransform", - "discard", - "embed", - "foreignobject", - "handler", - "iframe", - "listener", - "object", - "script", - "set", -} - - -def split_xml_name(name: str) -> tuple[str, str]: - if name.startswith("{"): - namespace, local_name = name[1:].split("}", 1) - return namespace, local_name - return "", name - - -def validate_resource_reference(reference: str, *, allow_font: bool) -> None: - reference = reference.strip() - if reference.startswith("#") or SAFE_RASTER_DATA_RE.match(reference): - return - if allow_font and SAFE_FONT_DATA_RE.match(reference): - return - raise ChartError(f"unsafe SVG resource reference: {reference[:80]!r}") - - -def validate_css(css: str) -> None: - if "\\" in css: - raise ChartError("SVG CSS escapes are not allowed") - if UNSAFE_CSS_RE.search(css): - raise ChartError("SVG CSS contains an active or external construct") - - matches = list(CSS_URL_RE.finditer(css)) - if len(matches) != len(CSS_URL_START_RE.findall(css)): - raise ChartError("SVG CSS contains a malformed url() reference") - for match in matches: - validate_resource_reference(match.group(2), allow_font=True) - - -def sanitize_svg(svg: str) -> str: - lowered = svg.lower() - if " is not allowed" - ) - if element_name.lower() in DANGEROUS_ELEMENTS: - raise ChartError(f"active SVG element <{element_name}> is not allowed") - - for attribute_name, value in element.attrib.items(): - _, local_name = split_xml_name(attribute_name) - lowered_name = local_name.lower() - if lowered_name.startswith("on"): - raise ChartError(f"SVG event attribute {local_name!r} is not allowed") - if lowered_name in {"href", "src"}: - validate_resource_reference(value, allow_font=False) - if lowered_name == "style" or CSS_URL_START_RE.search(value): - validate_css(value) - - if element_name.lower() == "style": - validate_css(element.text or "") - - ET.register_namespace("", SVG_NAMESPACE) - return ET.tostring(root, encoding="unicode") - - -def background_of(svg: str, theme: str) -> str: - match = BACKGROUND_RE.search(svg[:1024]) - if match: - return match.group(1) - print( - f"::warning::No background declared in the {theme} chart; " - f"falling back to {DEFAULT_BACKGROUNDS[theme]}", - file=sys.stderr, - ) - return DEFAULT_BACKGROUNDS[theme] - - -def viewport_of(svg: str, theme: str) -> tuple[int, int]: - root = svg[:1024] - width, height = WIDTH_RE.search(root), HEIGHT_RE.search(root) - if width and height: - return math.ceil(float(width.group(1))), math.ceil(float(height.group(1))) - print( - f"::warning::No intrinsic size on the {theme} chart; " - f"falling back to {DEFAULT_SIZE[0]}x{DEFAULT_SIZE[1]}", - file=sys.stderr, - ) - return DEFAULT_SIZE diff --git a/scripts/test_star_history.py b/scripts/test_star_history.py new file mode 100644 index 000000000..5fd4592d7 --- /dev/null +++ b/scripts/test_star_history.py @@ -0,0 +1,634 @@ +import argparse +import base64 +import json +import re +import os +import sys +import tempfile +import unittest +import urllib.error +import xml.etree.ElementTree as ET +from datetime import datetime, timedelta, timezone +from email.message import Message +from io import BytesIO +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from star_history_lib import ChartError, chart, cli, raster, stars + + +UTC = timezone.utc + + +def moment(day: int, month: int = 1, year: int = 2025) -> datetime: + return datetime(year, month, day, tzinfo=UTC) + + +AVATAR = "data:image/png;base64,aGVsbG8=" + + +def timeline(points, total=None, sampled=False, avatar=AVATAR) -> dict: + return { + "points": points, + "total": total if total is not None else points[-1][1], + "sampled": sampled, + "avatar": avatar, + } + + +def http_error(code: int, body: bytes = b"", headers: dict | None = None): + message = Message() + for key, value in (headers or {}).items(): + message[key] = value + return urllib.error.HTTPError("https://api.github.com", code, "", message, None) + + +AVATAR_URL = "https://avatars.githubusercontent.com/u/1?v=4" +AVATAR_BYTES = b"\x89PNG\r\n\x1a\nfake-avatar" + + +class FakeResponse: + def __init__(self, payload=None, *, body=None, content_type="application/json"): + if body is None: + body = json.dumps(payload).encode("utf-8") + self.stream = BytesIO(body) + self.headers = {"Content-Type": content_type} + + def read(self): + return self.stream.read() + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + +class FakeGitHub: + """Serves repository metadata and stargazer pages from an in-memory clock.""" + + def __init__(self, count: int, first: datetime = moment(1), spacing_hours=6): + self.count = count + self.first = first + self.created = first - timedelta(days=1) + self.spacing = timedelta(hours=spacing_hours) + self.requests = [] + + def starred_at(self, index: int) -> str: + return (self.first + self.spacing * index).strftime("%Y-%m-%dT%H:%M:%SZ") + + def __call__(self, request, timeout=None): + url = request.full_url + self.requests.append(url) + if "avatars.githubusercontent.com" in url: + self.avatar_headers = dict(request.headers) + return FakeResponse(body=AVATAR_BYTES, content_type="image/png") + if "/stargazers" not in url: + return FakeResponse( + { + "stargazers_count": self.count, + "created_at": self.created.strftime("%Y-%m-%dT%H:%M:%SZ"), + "owner": {"avatar_url": AVATAR_URL}, + } + ) + + page = int(url.split("page=")[-1]) + start = (page - 1) * stars.PER_PAGE + end = min(start + stars.PER_PAGE, self.count) + return FakeResponse( + [{"starred_at": self.starred_at(index)} for index in range(start, end)] + ) + + +class TestFetchJson(unittest.TestCase): + def test_returns_decoded_payload(self): + with mock.patch("urllib.request.urlopen", FakeGitHub(7)): + payload = stars.fetch_json("https://api.github.com/repos/a/b", "t") + self.assertEqual(payload["stargazers_count"], 7) + + def test_sends_bearer_token_and_media_type(self): + sent = stars.headers("secret", stars.STAR_MEDIA_TYPE) + self.assertEqual(sent["Authorization"], "Bearer secret") + self.assertEqual(sent["Accept"], stars.STAR_MEDIA_TYPE) + + def test_omits_authorization_without_a_token(self): + self.assertNotIn("Authorization", stars.headers("", stars.JSON_MEDIA_TYPE)) + + def test_retries_transient_failures_then_succeeds(self): + attempts = [] + + def flaky(request, timeout=None): + attempts.append(request.full_url) + if len(attempts) < 3: + raise urllib.error.URLError("connection reset") + return FakeResponse({"stargazers_count": 3}) + + with mock.patch("urllib.request.urlopen", flaky), mock.patch("time.sleep"): + payload = stars.fetch_json("https://api.github.com/repos/a/b", "t") + + self.assertEqual(payload, {"stargazers_count": 3}) + self.assertEqual(len(attempts), 3) + + def test_gives_up_after_the_retry_budget(self): + def always_failing(request, timeout=None): + raise TimeoutError("timed out") + + with mock.patch("urllib.request.urlopen", always_failing), mock.patch( + "time.sleep" + ): + with self.assertRaises(ChartError) as caught: + stars.fetch_json("https://api.github.com/repos/a/b", "t") + + self.assertIn(f"after {stars.RETRIES} attempts", str(caught.exception)) + + def test_does_not_retry_a_missing_repository(self): + attempts = [] + + def missing(request, timeout=None): + attempts.append(request.full_url) + raise http_error(404, b'{"message":"Not Found"}') + + with mock.patch("urllib.request.urlopen", missing), mock.patch("time.sleep"): + with self.assertRaises(ChartError) as caught: + stars.fetch_json("https://api.github.com/repos/a/b", "t") + + self.assertEqual(len(attempts), 1) + self.assertIn("repository was not found", str(caught.exception)) + + def test_reports_an_exhausted_rate_limit(self): + def limited(request, timeout=None): + raise http_error(403, b"rate limited", {"X-RateLimit-Remaining": "0"}) + + with mock.patch("urllib.request.urlopen", limited), mock.patch("time.sleep"): + with self.assertRaises(ChartError) as caught: + stars.fetch_json("https://api.github.com/repos/a/b", "t") + + self.assertIn("rate limit is exhausted", str(caught.exception)) + + def test_retries_a_server_error(self): + attempts = [] + + def unstable(request, timeout=None): + attempts.append(request.full_url) + if len(attempts) < 2: + raise http_error(500, b"upstream boom") + return FakeResponse({"stargazers_count": 1}) + + with mock.patch("urllib.request.urlopen", unstable), mock.patch("time.sleep"): + stars.fetch_json("https://api.github.com/repos/a/b", "t") + + self.assertEqual(len(attempts), 2) + + +class TestSampling(unittest.TestCase): + def test_returns_every_page_within_budget(self): + self.assertEqual(stars.sampled_pages(4, budget=10), [1, 2, 3, 4]) + + def test_samples_endpoints_inclusively(self): + pages = stars.sampled_pages(400, budget=30) + self.assertEqual(pages[0], 1) + self.assertEqual(pages[-1], 400) + self.assertLessEqual(len(pages), 30) + self.assertEqual(pages, sorted(set(pages))) + + +class TestStarTimeline(unittest.TestCase): + def test_rejects_a_malformed_repository(self): + with self.assertRaises(ChartError): + stars.star_timeline("opentaint", "t", moment(2)) + + def test_rejects_a_repository_with_no_stars(self): + with mock.patch("urllib.request.urlopen", FakeGitHub(0)): + with self.assertRaises(ChartError): + stars.star_timeline("seqra/opentaint", "t", moment(2)) + + def test_counts_every_star_when_pages_fit_the_budget(self): + github = FakeGitHub(127) + now = moment(1, month=6) + with mock.patch("urllib.request.urlopen", github): + result = stars.star_timeline("seqra/opentaint", "t", now) + + self.assertFalse(result["sampled"]) + self.assertEqual(result["total"], 127) + # A zero-star anchor, 127 stars, then a point carrying the curve to `now`. + self.assertEqual(len(result["points"]), 129) + self.assertEqual(result["points"][0], (github.created, 0)) + self.assertEqual(result["points"][1][1], 1) + self.assertEqual(result["points"][-1], (now, 127)) + + def test_counts_rise_monotonically(self): + with mock.patch("urllib.request.urlopen", FakeGitHub(150)): + points = stars.star_timeline("seqra/opentaint", "t", moment(1, 6))["points"] + + counts = [count for _, count in points] + dates = [when for when, _ in points] + self.assertEqual(counts, sorted(counts)) + self.assertEqual(dates, sorted(dates)) + + def test_samples_pages_for_a_large_repository(self): + github = FakeGitHub(50_000, spacing_hours=1) + with mock.patch("urllib.request.urlopen", github): + result = stars.star_timeline("seqra/opentaint", "t", moment(1, 6, 2030)) + + self.assertTrue(result["sampled"]) + stargazer_calls = [url for url in github.requests if "/stargazers" in url] + self.assertLessEqual(len(stargazer_calls), stars.MAX_PAGE_REQUESTS) + self.assertEqual(result["points"][-1][1], 50_000) + + def test_skips_the_trailing_point_when_now_is_not_newer(self): + github = FakeGitHub(3) + last = github.first + github.spacing * 2 + with mock.patch("urllib.request.urlopen", github): + result = stars.star_timeline("seqra/opentaint", "t", last) + + self.assertEqual(len(result["points"]), 4) + self.assertEqual(result["points"][-1][1], 3) + + def test_rejects_an_unparseable_timestamp(self): + def broken(request, timeout=None): + if "/stargazers" in request.full_url: + return FakeResponse([{"starred_at": "not-a-date"}]) + return FakeResponse( + {"stargazers_count": 1, "created_at": "2025-01-01T00:00:00Z"} + ) + + with mock.patch("urllib.request.urlopen", broken): + with self.assertRaises(ChartError): + stars.star_timeline("seqra/opentaint", "t", moment(2)) + + +class TestAvatar(unittest.TestCase): + def test_the_timeline_carries_the_owner_avatar(self): + with mock.patch("urllib.request.urlopen", FakeGitHub(5)): + result = stars.star_timeline("seqra/opentaint", "t", moment(1, 6)) + + expected = base64.b64encode(AVATAR_BYTES).decode("ascii") + self.assertEqual(result["avatar"], f"data:image/png;base64,{expected}") + + def test_the_avatar_is_requested_at_a_usable_size(self): + github = FakeGitHub(5) + with mock.patch("urllib.request.urlopen", github): + stars.star_timeline("seqra/opentaint", "t", moment(1, 6)) + + avatar_calls = [url for url in github.requests if "avatars." in url] + self.assertEqual(len(avatar_calls), 1) + self.assertIn(f"s={stars.AVATAR_PIXELS}", avatar_calls[0]) + + def test_the_github_token_is_not_sent_to_the_avatar_host(self): + github = FakeGitHub(5) + with mock.patch("urllib.request.urlopen", github): + stars.star_timeline("seqra/opentaint", "t", moment(1, 6)) + + sent = {key.lower() for key in github.avatar_headers} + self.assertNotIn("authorization", sent) + + def test_a_failing_avatar_does_not_fail_the_chart(self): + def flaky(request, timeout=None): + if "avatars." in request.full_url: + raise urllib.error.URLError("avatar host down") + return FakeGitHub(5)(request, timeout) + + with mock.patch("urllib.request.urlopen", flaky), mock.patch( + "time.sleep" + ), mock.patch("sys.stderr", new_callable=lambda: open(os.devnull, "w")): + result = stars.star_timeline("seqra/opentaint", "t", moment(1, 6)) + + self.assertEqual(result["avatar"], "") + self.assertEqual(result["total"], 5) + + def test_a_non_image_response_is_ignored(self): + def wrong_type(request, timeout=None): + if "avatars." in request.full_url: + return FakeResponse(body=b"", content_type="text/html") + return FakeGitHub(5)(request, timeout) + + with mock.patch("urllib.request.urlopen", wrong_type), mock.patch( + "sys.stderr", new_callable=lambda: open(os.devnull, "w") + ): + result = stars.star_timeline("seqra/opentaint", "t", moment(1, 6)) + + self.assertEqual(result["avatar"], "") + + +class TestAxes(unittest.TestCase): + def test_tick_step_matches_d3(self): + # The values d3.tickIncrement produces for the same inputs. + for maximum, expected in ((126, 20), (5, 1), (1000, 200), (4200, 500)): + self.assertEqual(chart.tick_step(maximum, chart.Y_TICK_TARGET), expected) + + def test_value_ticks_reproduce_the_reference_axis(self): + self.assertEqual(chart.value_ticks(126), [0, 20, 40, 60, 80, 100, 120]) + + def test_value_ticks_never_exceed_the_maximum(self): + self.assertLessEqual(chart.value_ticks(127)[-1], 127) + + def test_a_tiny_repository_still_gets_integer_ticks(self): + self.assertEqual(chart.value_ticks(3), [0, 1, 2, 3]) + + def test_a_year_of_history_steps_by_three_months(self): + self.assertEqual(chart.month_step(timedelta(days=310)), 3) + + def test_a_long_history_steps_by_a_year(self): + self.assertEqual(chart.month_step(timedelta(days=2000)), 12) + + def test_quarter_ticks_land_on_january_april_july_october(self): + ticks = chart.time_ticks(moment(30, 9, 2025), moment(6, 8, 2026)) + self.assertEqual( + [label for _, label in ticks], ["October", "2026", "April", "July"] + ) + + def test_january_is_labelled_with_the_year(self): + ticks = chart.time_ticks(moment(30, 9, 2025), moment(6, 8, 2026)) + january = [tick for tick, label in ticks if label == "2026"][0] + self.assertEqual((january.month, january.year), (1, 2026)) + + def test_ticks_stay_inside_the_domain(self): + start, end = moment(14, 10, 2025), moment(6, 8, 2026) + for tick, _ in chart.time_ticks(start, end): + self.assertGreaterEqual(tick, start) + self.assertLessEqual(tick, end) + + def test_a_short_history_falls_back_to_day_ticks(self): + ticks = chart.time_ticks(moment(1), moment(9)) + self.assertTrue(ticks) + self.assertRegex(ticks[0][1], r"^[A-Z][a-z]{2} \d{2}$") + + +def path_numbers(path: str) -> list[float]: + return [float(value) for value in re.findall(r"-?\d+\.?\d*", path)] + + +class TestCurve(unittest.TestCase): + def test_a_single_point_is_just_a_move(self): + self.assertEqual(chart.basis_path([(0.0, 10.0)]), "M0 10") + + def test_two_points_are_a_straight_line(self): + self.assertEqual(chart.basis_path([(0.0, 0.0), (5.0, 5.0)]), "M0 0L5 5") + + def test_each_span_emits_one_cubic(self): + path = chart.basis_path([(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)]) + self.assertEqual(path.count("C"), 2) + self.assertTrue(path.startswith("M0 0")) + + def test_the_curve_starts_and_ends_on_the_data(self): + points = [(0.0, 0.0), (10.0, 5.0), (20.0, 30.0), (30.0, 31.0)] + numbers = path_numbers(chart.basis_path(points)) + self.assertEqual((numbers[0], numbers[1]), points[0]) + self.assertEqual((numbers[-2], numbers[-1]), points[-1]) + + def test_a_rising_series_never_dips(self): + # curveBasis is variation-diminishing, so a falling y (rising count) + # must never produce a control point above where it started. + points = [(0.0, 100.0), (10.0, 100.0), (20.0, 40.0), (30.0, 0.0)] + ys = path_numbers(chart.basis_path(points))[1::2] + self.assertTrue(all(y <= 100.0001 for y in ys)) + self.assertTrue(all(y >= -0.0001 for y in ys)) + + def test_the_spline_smooths_rather_than_interpolates(self): + # A corner point is approximated, which is exactly what removes the kink. + points = [(0.0, 0.0), (10.0, 0.0), (20.0, 100.0), (30.0, 100.0)] + self.assertNotIn("100 100", chart.basis_path(points)[: -len("L30 100")]) + + def test_downsample_keeps_the_ends(self): + points = [(moment(1) + timedelta(days=i), i) for i in range(200)] + reduced = chart.downsample(points, 11) + self.assertEqual(len(reduced), 11) + self.assertEqual(reduced[0], points[0]) + self.assertEqual(reduced[-1], points[-1]) + + def test_downsample_leaves_short_series_alone(self): + points = [(moment(1), 1), (moment(2), 2)] + self.assertEqual(chart.downsample(points, 11), points) + + +class TestRender(unittest.TestCase): + def setUp(self): + self.timeline = timeline( + [(moment(1, month), month * 10) for month in range(1, 13)] + ) + + def series_path(self, svg: str) -> str: + match = re.search( + r'stroke="#[0-9a-f]+" d="([^"]+)" class="xkcd-chart-xyline"', svg + ) + self.assertIsNotNone(match, "the series path is missing") + return match.group(1) + + def test_renders_well_formed_svg_for_both_themes(self): + for theme in cli.THEMES: + svg = chart.render("seqra/opentaint", self.timeline, theme) + root = ET.fromstring(svg) + self.assertEqual(root.tag, "{http://www.w3.org/2000/svg}svg") + self.assertEqual(root.get("width"), str(chart.WIDTH)) + self.assertEqual(root.get("height"), "533.333") + self.assertIn( + f'background:{chart.PALETTES[theme]["background"]}', root.get("style") + ) + + def test_carries_the_xkcdify_filter(self): + svg = chart.render("a/b", self.timeline, "light") + self.assertIn('&", timeline([(moment(1), 5)]), "light") + self.assertNotIn("", svg) + ET.fromstring(svg) + + def test_the_curve_stays_inside_the_plot_area(self): + svg = chart.render("a/b", self.timeline, "light") + numbers = path_numbers(self.series_path(svg)) + xs, ys = numbers[0::2], numbers[1::2] + self.assertGreaterEqual(min(xs), 0) + self.assertLessEqual(max(xs), chart.PLOT_WIDTH) + self.assertGreaterEqual(min(ys), 0) + self.assertLessEqual(max(ys), chart.BASELINE) + + def test_the_curve_spans_the_full_width(self): + numbers = path_numbers( + self.series_path(chart.render("a/b", self.timeline, "light")) + ) + self.assertEqual(numbers[0], 0) + self.assertEqual(numbers[-2], chart.PLOT_WIDTH) + + def test_embeds_the_owner_avatar_beside_the_title(self): + svg = chart.render("a/b", self.timeline, "light") + self.assertIn(f'href="{AVATAR}"', svg) + self.assertIn( + f' dict: + args = argparse.Namespace( + repository="seqra/opentaint", + output_dir=os.path.join(directory, "dist"), + basename="star-history", + token="t", + ) + with mock.patch("urllib.request.urlopen", FakeGitHub(count)): + return cli.build_manifest(args, moment(1, 6)) + + def test_writes_an_svg_and_a_raster_page_per_theme(self): + with tempfile.TemporaryDirectory() as directory: + manifest = self.build(directory) + + self.assertEqual([c["theme"] for c in manifest["charts"]], ["light", "dark"]) + for entry in manifest["charts"]: + self.assertTrue(os.path.isfile(entry["svg"])) + self.assertTrue(os.path.isfile(entry["raster_page"])) + self.assertEqual(entry["width"], chart.WIDTH) + self.assertGreater(entry["bytes"], 0) + + def test_assets_list_the_svg_and_png_of_each_theme(self): + with tempfile.TemporaryDirectory() as directory: + manifest = self.build(directory) + self.assertEqual(len(manifest["assets"]), 4) + self.assertEqual( + [os.path.basename(path) for path in manifest["assets"]], + [ + "star-history-light.svg", + "star-history-light.png", + "star-history-dark.svg", + "star-history-dark.png", + ], + ) + + def test_records_the_star_count_and_source(self): + with tempfile.TemporaryDirectory() as directory: + manifest = self.build(directory) + self.assertEqual(manifest["stars"], 127) + self.assertFalse(manifest["sampled"]) + self.assertTrue(manifest["authenticated"]) + self.assertIn("seqra/opentaint/stargazers", manifest["source"]) + + def test_the_manifest_round_trips_through_the_asset_reader(self): + with tempfile.TemporaryDirectory() as directory: + manifest = self.build(directory) + path = os.path.join(directory, "manifest.json") + with open(path, "w", encoding="utf-8") as handle: + json.dump(manifest, handle) + + self.assertEqual(raster.asset_paths(path), manifest["assets"]) + + def test_build_refuses_to_run_without_a_token(self): + args = argparse.Namespace( + repository="seqra/opentaint", + output_dir="dist", + basename="star-history", + token="", + ) + with self.assertRaises(ChartError): + cli.build(args) + + +class TestRasterPage(unittest.TestCase): + def test_page_references_the_svg_and_its_background(self): + with tempfile.TemporaryDirectory() as directory: + svg_path = os.path.join(directory, "star-history-dark.svg") + page = raster.write_raster_page(svg_path, "#0d1117", 800, 534) + + with open(page, encoding="utf-8") as handle: + document = handle.read() + + self.assertIn('src="star-history-dark.svg"', document) + self.assertIn("background:#0d1117", document) + self.assertIn("Content-Security-Policy", document) + + +class TestEntryPoint(unittest.TestCase): + def test_main_reports_chart_errors_as_workflow_errors(self): + argv = ["star_history.py", "build", "--repository", "bad", "--output-dir", "d"] + with mock.patch.object(sys, "argv", argv), mock.patch.dict( + os.environ, {"GITHUB_TOKEN": "t"} + ), mock.patch("star_history_lib.cli.build_manifest", side_effect=ChartError("x")): + with mock.patch("sys.stderr", new_callable=lambda: open(os.devnull, "w")): + self.assertEqual(cli.main(), 1) + + +if __name__ == "__main__": + unittest.main()