"""Fetch the public petdex manifest. ``https://petdex.dev/api/manifest`` 307-redirects to a JSON document on R2: { "generatedAt": "...", "total": 2926, "pets": [ {"slug": "boba", "displayName": "Boba", "kind": "creature", "submittedBy": "railly", "spritesheetUrl": "https://assets.petdex.dev/.../spritesheet.webp", "petJsonUrl": "https://assets.petdex.dev/.../pet.json", "zipUrl": "https://assets.petdex.dev/.../boba.zip"}, ... ] } Read-only and unauthenticated; no credentials involved. """ from __future__ import annotations import logging import threading import time from dataclasses import dataclass logger = logging.getLogger(__name__) MANIFEST_URL = "https://petdex.dev/api/manifest" _DEFAULT_TIMEOUT = 10.0 # In-process cache for the (large, slow, identical-per-call) manifest. The list # is a static CDN object that barely changes, yet a single session can ask for # it many times — every gallery open, plus a full re-fetch per install/select # (``find_entry``). A short TTL collapses those into one network hit without # going stale for long. Cleared by :func:`clear_cache` (tests). _MANIFEST_TTL = 300.0 _cache: tuple[float, list[ManifestEntry]] | None = None _prefetch_lock = threading.Lock() _prefetching = False def clear_cache() -> None: """Drop the cached manifest (forces the next fetch to hit the network).""" global _cache _cache = None def _cache_is_warm() -> bool: return _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL def prefetch(*, timeout: float = _DEFAULT_TIMEOUT) -> None: """Warm the manifest cache in a daemon thread — idempotent, never blocks. The desktop picker calls this when it loads the (instant) local-only gallery so the full petdex catalog is usually cached by the time it's requested, without ever holding up the user's own pets on a network round-trip. """ global _prefetching if _cache_is_warm(): return with _prefetch_lock: if _prefetching: return _prefetching = True def _run() -> None: global _prefetching try: fetch_manifest(timeout=timeout) except Exception as exc: # noqa: BLE001 - best-effort warm logger.debug("petdex manifest prefetch failed: %s", exc) finally: _prefetching = False threading.Thread(target=_run, name="petdex-prefetch", daemon=True).start() @dataclass(frozen=True) class ManifestEntry: """A single pet's row in the manifest.""" slug: str display_name: str kind: str submitted_by: str spritesheet_url: str pet_json_url: str zip_url: str @classmethod def from_dict(cls, data: dict) -> "ManifestEntry": return cls( slug=str(data.get("slug", "")).strip(), display_name=str(data.get("displayName", "") or data.get("slug", "")), kind=str(data.get("kind", "") or "pet"), submitted_by=str(data.get("submittedBy", "") or ""), spritesheet_url=str(data.get("spritesheetUrl", "") or ""), pet_json_url=str(data.get("petJsonUrl", "") or ""), zip_url=str(data.get("zipUrl", "") or ""), ) class ManifestError(RuntimeError): """Raised when the manifest can't be fetched or parsed.""" def fetch_manifest(*, timeout: float = _DEFAULT_TIMEOUT, force: bool = False) -> list[ManifestEntry]: """Return every approved pet from the public manifest. Cached in-process for ``_MANIFEST_TTL`` seconds (pass ``force=True`` to bypass). Follows the 307 redirect to R2. Raises :class:`ManifestError` on any network/parse failure so callers can surface a clean message. """ global _cache if not force and _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL: return _cache[1] try: import httpx except ImportError as exc: # pragma: no cover - httpx is a core dep raise ManifestError("httpx is required to fetch the petdex manifest") from exc try: resp = httpx.get( MANIFEST_URL, timeout=timeout, follow_redirects=True, headers={"User-Agent": "hermes-agent-petdex"}, ) resp.raise_for_status() payload = resp.json() except Exception as exc: # noqa: BLE001 - normalize to one error type raise ManifestError(f"could not fetch petdex manifest: {exc}") from exc pets = payload.get("pets") if isinstance(payload, dict) else None if not isinstance(pets, list): raise ManifestError("petdex manifest had no 'pets' array") entries: list[ManifestEntry] = [] for raw in pets: if not isinstance(raw, dict): continue entry = ManifestEntry.from_dict(raw) if entry.slug and entry.spritesheet_url: entries.append(entry) _cache = (time.monotonic(), entries) return entries def find_entry(slug: str, *, timeout: float = _DEFAULT_TIMEOUT) -> ManifestEntry | None: """Return the manifest entry for *slug*, or ``None`` if not listed.""" slug = slug.strip().lower() for entry in fetch_manifest(timeout=timeout): if entry.slug.lower() == slug: return entry return None