|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Fetch BettaFish's aggregate GitHub Star count without loading the renderer. |
| 3 | +
|
| 4 | +The successful stdout contract is deliberately tiny: one non-negative decimal |
| 5 | +integer followed by a newline. Errors are fixed, sanitized messages on stderr. |
| 6 | +""" |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +import json |
| 11 | +import os |
| 12 | +import sys |
| 13 | +import urllib.error |
| 14 | +import urllib.request |
| 15 | +from typing import Any |
| 16 | + |
| 17 | + |
| 18 | +API_URL = "https://api.github.com/repos/666ghj/BettaFish" |
| 19 | +API_VERSION = "2026-03-10" |
| 20 | +MAX_HTTP_BYTES = 1_000_000 |
| 21 | +TIMEOUT_SECONDS = 20 |
| 22 | + |
| 23 | + |
| 24 | +class FetchError(RuntimeError): |
| 25 | + """A safe error whose message never includes response or secret data.""" |
| 26 | + |
| 27 | + |
| 28 | +class NoRedirectHandler(urllib.request.HTTPRedirectHandler): |
| 29 | + """Refuse every redirect so credentials cannot be forwarded elsewhere.""" |
| 30 | + |
| 31 | + def redirect_request(self, *_args: Any, **_kwargs: Any) -> None: |
| 32 | + return None |
| 33 | + |
| 34 | + |
| 35 | +def _build_opener() -> urllib.request.OpenerDirector: |
| 36 | + return urllib.request.build_opener(NoRedirectHandler()) |
| 37 | + |
| 38 | + |
| 39 | +def _status_error(status: int) -> FetchError: |
| 40 | + if status in {301, 302, 303, 307, 308}: |
| 41 | + return FetchError("GitHub API redirect was refused") |
| 42 | + if status == 401: |
| 43 | + return FetchError("GitHub API authentication failed") |
| 44 | + if status == 403: |
| 45 | + return FetchError("GitHub API request was denied") |
| 46 | + if status == 404: |
| 47 | + return FetchError("BettaFish repository metadata was not found") |
| 48 | + if status == 429: |
| 49 | + return FetchError("GitHub API rate limit was exhausted") |
| 50 | + if 500 <= status <= 599: |
| 51 | + return FetchError("GitHub API is unavailable") |
| 52 | + return FetchError("GitHub API request failed") |
| 53 | + |
| 54 | + |
| 55 | +def _read_response(response: Any) -> bytes: |
| 56 | + raw_length = response.headers.get("Content-Length") |
| 57 | + if raw_length is not None: |
| 58 | + try: |
| 59 | + content_length = int(raw_length, 10) |
| 60 | + except (TypeError, ValueError) as exc: |
| 61 | + raise FetchError("GitHub API returned invalid response metadata") from exc |
| 62 | + if content_length < 0 or content_length > MAX_HTTP_BYTES: |
| 63 | + raise FetchError("GitHub API response exceeded the size limit") |
| 64 | + |
| 65 | + payload = response.read(MAX_HTTP_BYTES + 1) |
| 66 | + if len(payload) > MAX_HTTP_BYTES: |
| 67 | + raise FetchError("GitHub API response exceeded the size limit") |
| 68 | + return payload |
| 69 | + |
| 70 | + |
| 71 | +def fetch_star_count(token: str, opener: Any | None = None) -> int: |
| 72 | + if not token or len(token) > 4_096 or "\r" in token or "\n" in token: |
| 73 | + raise FetchError("GITHUB_TOKEN is missing or invalid") |
| 74 | + |
| 75 | + request = urllib.request.Request( |
| 76 | + API_URL, |
| 77 | + headers={ |
| 78 | + "Accept": "application/vnd.github+json", |
| 79 | + "Authorization": f"Bearer {token}", |
| 80 | + "User-Agent": "BettaFish-Star-History-Fetcher", |
| 81 | + "X-GitHub-Api-Version": API_VERSION, |
| 82 | + }, |
| 83 | + method="GET", |
| 84 | + ) |
| 85 | + client = opener or _build_opener() |
| 86 | + try: |
| 87 | + response = client.open(request, timeout=TIMEOUT_SECONDS) |
| 88 | + except urllib.error.HTTPError as exc: |
| 89 | + status = exc.code |
| 90 | + exc.close() |
| 91 | + raise _status_error(status) from None |
| 92 | + except (urllib.error.URLError, TimeoutError, OSError): |
| 93 | + raise FetchError("GitHub API network request failed") from None |
| 94 | + except Exception: |
| 95 | + raise FetchError("GitHub API request could not be started") from None |
| 96 | + |
| 97 | + try: |
| 98 | + with response: |
| 99 | + if response.geturl() != API_URL: |
| 100 | + raise FetchError("GitHub API redirect was refused") |
| 101 | + status = response.getcode() |
| 102 | + if status != 200: |
| 103 | + raise _status_error(status) |
| 104 | + payload = _read_response(response) |
| 105 | + except FetchError: |
| 106 | + raise |
| 107 | + except (TimeoutError, OSError): |
| 108 | + raise FetchError("GitHub API response could not be read") from None |
| 109 | + except Exception: |
| 110 | + raise FetchError("GitHub API response could not be processed") from None |
| 111 | + |
| 112 | + try: |
| 113 | + document = json.loads(payload) |
| 114 | + except (UnicodeDecodeError, json.JSONDecodeError, ValueError): |
| 115 | + raise FetchError("GitHub API returned malformed JSON") from None |
| 116 | + if not isinstance(document, dict): |
| 117 | + raise FetchError("GitHub API response had an unexpected shape") |
| 118 | + |
| 119 | + count = document.get("stargazers_count") |
| 120 | + if type(count) is not int or count < 0: |
| 121 | + raise FetchError("GitHub API returned an invalid stargazers_count") |
| 122 | + return count |
| 123 | + |
| 124 | + |
| 125 | +def main(argv: list[str] | None = None) -> int: |
| 126 | + arguments = sys.argv[1:] if argv is None else argv |
| 127 | + if arguments: |
| 128 | + print("error: this command accepts no arguments", file=sys.stderr) |
| 129 | + return 2 |
| 130 | + |
| 131 | + try: |
| 132 | + count = fetch_star_count(os.environ.get("GITHUB_TOKEN", "")) |
| 133 | + except FetchError as exc: |
| 134 | + print(f"error: {exc}", file=sys.stderr) |
| 135 | + return 1 |
| 136 | + except Exception: |
| 137 | + print("error: unexpected internal failure", file=sys.stderr) |
| 138 | + return 1 |
| 139 | + |
| 140 | + print(count) |
| 141 | + return 0 |
| 142 | + |
| 143 | + |
| 144 | +if __name__ == "__main__": |
| 145 | + raise SystemExit(main()) |
0 commit comments