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 000000000..9fa3f8807 Binary files /dev/null and b/scripts/star_history_lib/assets/xkcd-script.woff differ 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()