"""Provider-agnostic update checker service.""" import asyncio import logging import re from functools import total_ordering from typing import Any, Optional from .release_provider import ReleaseProvider from .websocket_manager import ws_manager logger = logging.getLogger(__name__) _PRE_PATTERN = re.compile( r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|rc)[.-]?(\d+)$", re.IGNORECASE ) _PRE_ORDER = {"alpha": 0, "beta": 1, "rc": 2} @total_ordering class _Version: """Lightweight PEP 440-ish version for comparison without packaging dep. Supports: X.Y.Z and X.Y.Z-{alpha,beta,rc}.N Pre-releases sort before the corresponding stable release. """ __slots__ = ("_release", "_pre") def __init__(self, release: tuple[int, ...], pre: Optional[tuple[int, int]]) -> None: self._release = release self._pre = pre def __eq__(self, other: object) -> bool: if not isinstance(other, _Version): return NotImplemented return self._release == other._release and self._pre == other._pre def __lt__(self, other: object) -> bool: if not isinstance(other, _Version): return NotImplemented if self._release != other._release: return self._release < other._release # No pre-release (stable) is greater than any pre-release if self._pre is None and other._pre is None: return False if self._pre is not None and other._pre is None: return True if self._pre is None and other._pre is not None: return False return self._pre < other._pre # type: ignore[operator] def __repr__(self) -> str: v = ".".join(str(p) for p in self._release) if self._pre is not None: labels = {0: "alpha", 1: "beta", 2: "rc"} v += f"-{labels[self._pre[0]]}.{self._pre[1]}" return f"_Version('{v}')" def _parse_version(raw: str) -> _Version: """Parse a version tag for comparison. Examples: v0.3.0-alpha.1 → (0,3,0) pre=(0,1) (sorts below 0.3.0) v0.3.0-rc.3 → (0,3,0) pre=(2,3) v1.0.0 → (1,0,0) pre=None """ cleaned = raw.lstrip("v").strip() m = _PRE_PATTERN.match(cleaned) if m: base = tuple(int(x) for x in m.group(1).split(".")) pre_label = m.group(2).lower() pre_num = int(m.group(3)) return _Version(base, (_PRE_ORDER[pre_label], pre_num)) release = tuple(int(x) for x in cleaned.split(".")) return _Version(release, None) class UpdateChecker: """Periodically checks for new releases using a ReleaseProvider.""" def __init__(self, provider: ReleaseProvider, current_version: str) -> None: self._provider = provider self._current_version = current_version self._current_parsed = _parse_version(current_version) self._task: Optional[asyncio.Task] = None self._cached_update: Optional[dict[str, Any]] = None @property def cached_update(self) -> Optional[dict[str, Any]]: """Return the cached update info, or None if up-to-date.""" return self._cached_update async def check_for_update(self) -> Optional[dict[str, Any]]: """Check for a newer release. Returns: Dict with current/latest/url if an update exists, None otherwise. """ release = await self._provider.get_latest_release() if release is None: return None latest_parsed = _parse_version(release.version) if latest_parsed <= self._current_parsed: return None return { "current": self._current_version, "latest": release.version, "url": release.url, } async def start(self, interval: int) -> None: """Start periodic update checking. Checks immediately on start, then every `interval` seconds. """ if self._task is not None: return self._task = asyncio.create_task(self._check_loop(interval)) logger.info("Update checker started (interval: %ds)", interval) async def stop(self) -> None: """Stop periodic update checking.""" if self._task is not None: self._task.cancel() try: await self._task except asyncio.CancelledError: pass self._task = None logger.info("Update checker stopped") async def _check_loop(self, interval: int) -> None: """Background loop that checks for updates periodically.""" # Initial check with a small delay to let the server finish starting await asyncio.sleep(5) while True: try: update = await self.check_for_update() if update and update != self._cached_update: self._cached_update = update logger.info( "New version available: %s → %s (%s)", update["current"], update["latest"], update["url"], ) await ws_manager.broadcast( {"type": "update_available", "data": update} ) elif update is None and self._cached_update is not None: # Version was updated (or release removed) — clear cache self._cached_update = None except asyncio.CancelledError: break except Exception as e: logger.warning("Update check failed: %s", e) try: await asyncio.sleep(interval) except asyncio.CancelledError: break