Files

367 lines
13 KiB
Python

"""Minimal GitHub Releases client for plugin wheel discovery and download."""
from __future__ import annotations
import json
import logging
import re
import ssl
import tempfile
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional
from .catalogue import (
JSON_DOWNLOAD_TIMEOUT_SECONDS,
MAX_JSON_BYTES,
MAX_WHEEL_BYTES,
WHEEL_DOWNLOAD_TIMEOUT_SECONDS,
_bounded_chunks,
)
logger = logging.getLogger("PluginGitHubReleases")
GITHUB_API_BASE = "https://api.github.com"
DEFAULT_CACHE_TTL_SECONDS = 600 # 10 minutes
USER_AGENT = "openhop-repeater-plugin-manager/1.0"
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_ssl_ctx: Optional[ssl.SSLContext] = None
class GitHubReleasesError(Exception):
"""User-facing GitHub Releases failure."""
def __init__(self, message: str, *, code: int = 502):
super().__init__(message)
self.code = code
class GitHubRateLimitError(GitHubReleasesError):
def __init__(self, message: str = "GitHub API rate limit exceeded"):
super().__init__(message, code=429)
def _ssl_context() -> ssl.SSLContext:
global _ssl_ctx
if _ssl_ctx is None:
_ssl_ctx = ssl.create_default_context()
return _ssl_ctx
def normalize_repository(repository: str) -> str:
repo = str(repository or "").strip()
if repo.startswith("https://github.com/"):
repo = repo[len("https://github.com/") :]
if repo.startswith("http://github.com/"):
repo = repo[len("http://github.com/") :]
repo = repo.strip("/")
if repo.endswith(".git"):
repo = repo[: -len(".git")]
if not _REPO_RE.match(repo):
raise GitHubReleasesError(f"invalid repository format: {repository!r}", code=400)
return repo
def normalize_version(tag_or_version: str) -> str:
"""Strip a leading ``v`` from release tags (``v1.2.3`` → ``1.2.3``)."""
value = str(tag_or_version or "").strip()
if value.lower().startswith("v") and len(value) > 1 and value[1].isdigit():
return value[1:]
return value
def _parse_version_key(version: str):
try:
from packaging.version import Version
return Version(normalize_version(version))
except Exception:
return normalize_version(version)
@dataclass(frozen=True)
class ReleaseAsset:
name: str
browser_download_url: str
size: int = 0
content_type: str = ""
@dataclass(frozen=True)
class PluginRelease:
tag: str
version: str
name: str
body: str
prerelease: bool
draft: bool
html_url: str
published_at: str
assets: tuple[ReleaseAsset, ...]
def wheel_assets(self) -> list[ReleaseAsset]:
return [a for a in self.assets if a.name.endswith(".whl")]
def select_wheel_asset(release: PluginRelease) -> ReleaseAsset:
wheels = release.wheel_assets()
if not wheels:
raise GitHubReleasesError(
f"release {release.tag} has no .whl asset",
code=400,
)
if len(wheels) > 1:
names = ", ".join(a.name for a in wheels)
raise GitHubReleasesError(
f"release {release.tag} has multiple .whl assets ({names}); "
"attach exactly one plugin wheel",
code=400,
)
return wheels[0]
class GitHubReleaseClient:
"""Fetch and cache GitHub Releases for ``owner/repo`` plugin projects."""
def __init__(
self,
*,
cache_ttl: float = DEFAULT_CACHE_TTL_SECONDS,
opener: Optional[Callable[..., Any]] = None,
user_agent: str = USER_AGENT,
):
self.cache_ttl = cache_ttl
self._opener = opener or self._default_open
self.user_agent = user_agent
# repo -> (monotonic_ts, list[PluginRelease])
self._cache: dict[str, tuple[float, list[PluginRelease]]] = {}
def _default_open(self, request: urllib.request.Request, timeout: float = 30.0):
return urllib.request.urlopen( # nosec B310 - https only to api.github.com / github
request, timeout=timeout, context=_ssl_context()
)
def clear_cache(self, repository: Optional[str] = None) -> None:
if repository is None:
self._cache.clear()
return
self._cache.pop(normalize_repository(repository), None)
def list_releases(
self,
repository: str,
*,
include_prereleases: bool = False,
force_refresh: bool = False,
) -> list[PluginRelease]:
repo = normalize_repository(repository)
now = time.monotonic()
if not force_refresh and repo in self._cache:
ts, cached = self._cache[repo]
if (now - ts) < self.cache_ttl:
return self._filter(cached, include_prereleases=include_prereleases)
raw = self._fetch_releases_json(repo)
releases = [self._parse_release(item) for item in raw if isinstance(item, dict)]
# Keep drafts out of cache entirely
releases = [r for r in releases if not r.draft]
releases.sort(key=lambda r: _parse_version_key(r.version), reverse=True)
self._cache[repo] = (now, releases)
return self._filter(releases, include_prereleases=include_prereleases)
@staticmethod
def _filter(releases: list[PluginRelease], *, include_prereleases: bool) -> list[PluginRelease]:
if include_prereleases:
return list(releases)
return [r for r in releases if not r.prerelease]
def latest_stable(
self, repository: str, *, force_refresh: bool = False
) -> Optional[PluginRelease]:
releases = self.list_releases(
repository, include_prereleases=False, force_refresh=force_refresh
)
return releases[0] if releases else None
def find_release(
self,
repository: str,
version: str,
*,
include_prereleases: bool = False,
force_refresh: bool = False,
) -> PluginRelease:
target = normalize_version(version)
releases = self.list_releases(
repository,
include_prereleases=include_prereleases,
force_refresh=force_refresh,
)
for rel in releases:
if normalize_version(rel.version) == target or rel.tag == version:
return rel
raise GitHubReleasesError(
f"release version {version!r} not found for {repository}",
code=404,
)
def download_wheel(
self,
release: PluginRelease,
dest_dir: Path | str,
*,
asset: Optional[ReleaseAsset] = None,
) -> Path:
"""Download the release wheel into dest_dir; return the local path."""
dest_dir = Path(dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
chosen = asset or select_wheel_asset(release)
dest = dest_dir / Path(chosen.name).name
if not dest.name.endswith(".whl"):
raise GitHubReleasesError(f"refusing non-wheel asset: {chosen.name}", code=400)
request = urllib.request.Request(
chosen.browser_download_url,
headers={
"User-Agent": self.user_agent,
"Accept": "application/octet-stream",
},
method="GET",
)
deadline = time.monotonic() + WHEEL_DOWNLOAD_TIMEOUT_SECONDS
temporary = None
try:
with tempfile.NamedTemporaryFile(
prefix=".wheel-", dir=dest_dir, delete=False
) as output:
temporary = Path(output.name)
total = 0
with self._opener(request, timeout=WHEEL_DOWNLOAD_TIMEOUT_SECONDS) as resp:
for chunk in _bounded_chunks(
resp, max_bytes=MAX_WHEEL_BYTES, deadline=deadline
):
output.write(chunk)
total += len(chunk)
if not total:
raise GitHubReleasesError("downloaded wheel is empty", code=502)
temporary.replace(dest)
except GitHubReleasesError:
raise
except urllib.error.HTTPError as exc:
raise self._map_http_error(exc) from exc
except urllib.error.URLError as exc:
raise GitHubReleasesError(f"failed to download wheel: {exc.reason}", code=502) from exc
except Exception as exc:
raise GitHubReleasesError(f"failed to download wheel: {exc}", code=502) from exc
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return dest
def download_latest_wheel(
self,
repository: str,
dest_dir: Path | str,
*,
version: Optional[str] = None,
force_refresh: bool = False,
) -> tuple[PluginRelease, Path]:
if version:
release = self.find_release(repository, version, force_refresh=force_refresh)
else:
release = self.latest_stable(repository, force_refresh=force_refresh)
if release is None:
raise GitHubReleasesError(f"no stable releases found for {repository}", code=404)
path = self.download_wheel(release, dest_dir)
return release, path
def _fetch_releases_json(self, repo: str) -> list[Any]:
url = f"{GITHUB_API_BASE}/repos/{repo}/releases?per_page=30"
request = urllib.request.Request(
url,
headers={
"User-Agent": self.user_agent,
"Accept": "application/vnd.github+json",
},
method="GET",
)
deadline = time.monotonic() + JSON_DOWNLOAD_TIMEOUT_SECONDS
try:
with self._opener(request, timeout=JSON_DOWNLOAD_TIMEOUT_SECONDS) as resp:
body = bytearray()
for chunk in _bounded_chunks(resp, max_bytes=MAX_JSON_BYTES, deadline=deadline):
body.extend(chunk)
except GitHubReleasesError:
raise
except urllib.error.HTTPError as exc:
raise self._map_http_error(exc) from exc
except urllib.error.URLError as exc:
raise GitHubReleasesError(
f"GitHub Releases unavailable: {exc.reason}", code=502
) from exc
except Exception as exc:
raise GitHubReleasesError(f"GitHub Releases unavailable: {exc}", code=502) from exc
try:
data = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise GitHubReleasesError(f"invalid GitHub Releases response: {exc}", code=502) from exc
if not isinstance(data, list):
raise GitHubReleasesError("unexpected GitHub Releases response shape", code=502)
return data
@staticmethod
def _map_http_error(exc: urllib.error.HTTPError) -> GitHubReleasesError:
if exc.code == 403:
# Rate limit or auth — treat 403 with rate-limit headers as 429
remaining = exc.headers.get("X-RateLimit-Remaining") if exc.headers else None
if remaining == "0" or "rate limit" in str(exc.reason).lower():
return GitHubRateLimitError("GitHub API rate limit exceeded; try again later")
return GitHubReleasesError(f"GitHub API forbidden ({exc.reason})", code=403)
if exc.code == 404:
return GitHubReleasesError("GitHub repository or releases not found", code=404)
return GitHubReleasesError(f"GitHub API error HTTP {exc.code}: {exc.reason}", code=502)
@staticmethod
def _parse_release(item: dict[str, Any]) -> PluginRelease:
tag = str(item.get("tag_name") or "").strip()
assets_raw = item.get("assets") or []
assets: list[ReleaseAsset] = []
if isinstance(assets_raw, list):
for raw in assets_raw:
if not isinstance(raw, dict):
continue
name = str(raw.get("name") or "").strip()
url = str(raw.get("browser_download_url") or "").strip()
if not name or not url:
continue
assets.append(
ReleaseAsset(
name=name,
browser_download_url=url,
size=int(raw.get("size") or 0),
content_type=str(raw.get("content_type") or ""),
)
)
return PluginRelease(
tag=tag,
version=normalize_version(tag),
name=str(item.get("name") or tag),
body=str(item.get("body") or ""),
prerelease=bool(item.get("prerelease")),
draft=bool(item.get("draft")),
html_url=str(item.get("html_url") or ""),
published_at=str(item.get("published_at") or ""),
assets=tuple(assets),
)
def make_download_temp_dir(parent: Path | str) -> Path:
parent = Path(parent)
parent.mkdir(parents=True, exist_ok=True)
return Path(tempfile.mkdtemp(prefix="openhop-plugin-download-", dir=str(parent)))