128 lines
3.8 KiB
Python
128 lines
3.8 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
import requests
|
|
from packaging.version import InvalidVersion, Version
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UpdateInfo:
|
|
current_version: str
|
|
latest_version: str
|
|
latest_tag: str
|
|
html_url: Optional[str]
|
|
|
|
@property
|
|
def update_available(self) -> bool:
|
|
try:
|
|
return Version(self.latest_version) > Version(self.current_version)
|
|
except InvalidVersion:
|
|
return False
|
|
|
|
|
|
class UpdateCheckError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _normalize_tag_to_version(tag: str) -> str:
|
|
tag = (tag or "").strip()
|
|
if tag.lower().startswith("v"):
|
|
tag = tag[1:]
|
|
return tag
|
|
|
|
|
|
def get_current_version() -> str:
|
|
# Prefer an explicit env override (useful for ad-hoc builds)
|
|
import os
|
|
|
|
env_ver = os.getenv("IMAGE_PROCESSOR_VERSION")
|
|
if env_ver:
|
|
return env_ver.strip()
|
|
|
|
# Prefer version.py in repo / bundled app
|
|
try:
|
|
from version import __version__ # type: ignore
|
|
|
|
return str(__version__).strip()
|
|
except Exception:
|
|
return "0.0.0.dev0"
|
|
|
|
|
|
def _github_get_json(url: str, timeout_seconds: float = 10.0) -> dict:
|
|
headers = {
|
|
"Accept": "application/vnd.github+json",
|
|
"User-Agent": "images_py-update-checker",
|
|
}
|
|
|
|
try:
|
|
response = requests.get(url, headers=headers, timeout=timeout_seconds)
|
|
except requests.RequestException as exc:
|
|
raise UpdateCheckError(f"GitHub request failed: {exc}") from exc
|
|
|
|
# Rate-limit or forbidden
|
|
if response.status_code == 403:
|
|
remaining = response.headers.get("X-RateLimit-Remaining")
|
|
if remaining == "0":
|
|
raise UpdateCheckError("GitHub API rate limit reached. Try again later.")
|
|
|
|
if response.status_code >= 400:
|
|
raise UpdateCheckError(f"GitHub API error {response.status_code}: {response.text[:200]}")
|
|
|
|
try:
|
|
return response.json()
|
|
except ValueError as exc:
|
|
raise UpdateCheckError("GitHub API returned invalid JSON") from exc
|
|
|
|
|
|
def get_latest_github_release(owner: str, repo: str) -> tuple[str, str, Optional[str]]:
|
|
"""Returns (latest_version, latest_tag, html_url).
|
|
|
|
Uses releases/latest first; falls back to tags if no releases exist.
|
|
"""
|
|
|
|
releases_url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
|
|
try:
|
|
payload = _github_get_json(releases_url)
|
|
tag = str(payload.get("tag_name") or "").strip()
|
|
html_url = payload.get("html_url")
|
|
version = _normalize_tag_to_version(tag)
|
|
if version:
|
|
return version, tag, html_url
|
|
except UpdateCheckError:
|
|
# fall back to tags
|
|
pass
|
|
|
|
tags_url = f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=1"
|
|
payload = _github_get_json(tags_url)
|
|
if not isinstance(payload, list) or not payload:
|
|
raise UpdateCheckError("No releases or tags found on GitHub")
|
|
|
|
tag = str(payload[0].get("name") or "").strip()
|
|
version = _normalize_tag_to_version(tag)
|
|
if not version:
|
|
raise UpdateCheckError("Latest GitHub tag is missing a version")
|
|
|
|
# Tags endpoint does not include a nice html_url for a release.
|
|
return version, tag, f"https://github.com/{owner}/{repo}/releases/tag/{tag}"
|
|
|
|
|
|
def check_for_update(owner: str, repo: str, current_version: Optional[str] = None) -> UpdateInfo:
|
|
current = (current_version or get_current_version()).strip()
|
|
latest_version, latest_tag, html_url = get_latest_github_release(owner, repo)
|
|
|
|
# Validate versions for comparison; if invalid, still return info.
|
|
try:
|
|
Version(current)
|
|
Version(latest_version)
|
|
except InvalidVersion:
|
|
pass
|
|
|
|
return UpdateInfo(
|
|
current_version=current,
|
|
latest_version=latest_version,
|
|
latest_tag=latest_tag,
|
|
html_url=html_url,
|
|
)
|