# steward/core/plugin_index.py """Remote plugin catalog: fetch, parse, and cache the plugin index.yaml.""" from __future__ import annotations import logging import time from typing import Any logger = logging.getLogger(__name__) # The catalog is fetched by the scheduled plugin_catalog_refresh task, never by # a page load (Background Jobs ruling, 2026-10-08): a repository's copy is # fetched when it has none or it is older than CATALOG_MAX_AGE, and a repository # that failed is tried again after RETRY_AFTER. Refresh on Settings → Plugins and # installing a plugin fetch at once, because the operator asked. CATALOG_MAX_AGE = 3600 # seconds RETRY_AFTER = 300 # seconds # url → (raw index, fetched_at monotonic); url → failed_at monotonic _url_cache: dict[str, tuple[dict, float]] = {} _failed_at: dict[str, float] = {} class CatalogPlugin: """One entry from the remote plugin index.""" __slots__ = ( "name", "version", "description", "author", "min_app_version", "download_url", "checksum_sha256", "repository_url", "homepage", "license", "tags", ) def __init__(self, data: dict[str, Any]) -> None: self.name: str = data.get("name", "") self.version: str = data.get("version", "0.0.0") self.description: str = data.get("description", "") self.author: str = data.get("author", "") self.min_app_version: str | None = data.get("min_app_version") self.download_url: str = data.get("download_url", "") self.checksum_sha256: str = data.get("checksum_sha256", "") self.repository_url: str = data.get("repository_url", "") self.homepage: str = data.get("homepage", "") self.license: str = data.get("license", "") self.tags: list[str] = data.get("tags", []) def plugin_repos(settings: dict) -> list[dict]: """The configured plugin repositories, [{"name", "url"}], from settings. Migrates the legacy plugins.index_url single-URL field to the list format on first access if plugins.repositories is not yet set. """ repos = settings.get("plugins.repositories") if repos and isinstance(repos, list): return repos legacy = settings.get("plugins.index_url", "").strip() if legacy: return [{"name": "Default", "url": legacy}] return [] def repo_urls(settings: dict) -> list[str]: return [r["url"].strip() for r in plugin_repos(settings) if (r.get("url") or "").strip()] def _plugins(raw: dict) -> list[CatalogPlugin]: return [CatalogPlugin(p) for p in raw.get("plugins", [])] async def _fetch_one(url: str) -> bool: """Fetch one index URL into the cache. True when it was fetched.""" import httpx import yaml try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.get(url) resp.raise_for_status() raw = yaml.safe_load(resp.text) or {} except Exception as exc: # The tick retries every RETRY_AFTER seconds, so a repository that # answers 404 or is unreachable is one line each time, not a traceback. logger.warning("Plugin catalog not fetched from %s: %s", url, exc) _failed_at[url] = time.monotonic() return False _url_cache[url] = (raw, time.monotonic()) _failed_at.pop(url, None) logger.info("Plugin catalog fetched from %s: %d plugin(s)", url, len(raw.get("plugins", []))) return True def _merge(urls: list[str]) -> list[CatalogPlugin]: """Plugins from the cached copies, deduplicated by name: the first repository in the list wins.""" seen: dict[str, CatalogPlugin] = {} for url in urls: if url in _url_cache: for plugin in _plugins(_url_cache[url][0]): seen.setdefault(plugin.name, plugin) return list(seen.values()) def cached_catalog(urls: list[str]) -> tuple[list[CatalogPlugin], list[str]]: """(the merged catalog from what is cached, the URLs with no copy yet). Reads the cache only: this is what a page shows.""" return _merge(urls), [u for u in urls if u not in _url_cache] async def fetch_catalog(urls: list[str]) -> list[CatalogPlugin]: """Fetch every URL now, then the merged catalog (a failed URL keeps its last copy). For an operator's Refresh or install, not for a page load.""" for url in urls: await _fetch_one(url) return _merge(urls) async def refresh_due(urls: list[str], now: float | None = None) -> int: """The scheduled task's tick: fetch each URL with no copy, or one older than CATALOG_MAX_AGE, unless it failed within RETRY_AFTER. Returns how many were fetched.""" now = time.monotonic() if now is None else now fetched = 0 for url in urls: cached = _url_cache.get(url) if cached and now - cached[1] < CATALOG_MAX_AGE: continue if url in _failed_at and now - _failed_at[url] < RETRY_AFTER: continue fetched += await _fetch_one(url) return fetched def clear_catalog_cache() -> None: """Forget every cached copy and failure, so the next tick of the scheduled task fetches the repositories again (after they are edited).""" _url_cache.clear() _failed_at.clear()