"""Services package.""" from __future__ import annotations import json import os import logging from typing import Any, Optional, Dict try: import redis # type: ignore except Exception: # pragma: no cover redis = None # type: ignore logger = logging.getLogger(__name__) class RedisCache: """Lightweight Redis cache wrapper with graceful fallback.""" def __init__(self, url_env: str = "REDIS_URL", default_ttl_seconds: Optional[int] = None) -> None: # Allow TTL to be configurable via env var (default 300s = 5 min, or 86400 = 24h) ttl_env = os.getenv("REDIS_CACHE_TTL_SECONDS") if default_ttl_seconds is None: default_ttl_seconds = int(ttl_env) if ttl_env else 300 self.default_ttl_seconds = default_ttl_seconds self._enabled = False self._client = None self._stats = {"hits": 0, "misses": 0, "sets": 0} url = os.getenv(url_env) if not url or redis is None: logger.warning("Redis not configured or client unavailable; caching disabled") return try: self._client = redis.Redis.from_url(url, decode_responses=True) self._client.ping() self._enabled = True logger.info(f"Redis cache connected (TTL: {self.default_ttl_seconds}s)") except Exception as exc: logger.warning(f"Redis connection failed: {exc}; caching disabled") self._enabled = False self._client = None @property def enabled(self) -> bool: return self._enabled and self._client is not None def get_json(self, key: str) -> Optional[Any]: if not self.enabled: self._stats["misses"] += 1 return None try: raw = self._client.get(key) # type: ignore[union-attr] if raw: self._stats["hits"] += 1 return json.loads(raw) else: self._stats["misses"] += 1 return None except Exception as exc: logger.debug(f"Redis get_json error for key={key}: {exc}") self._stats["misses"] += 1 return None def set_json(self, key: str, value: Any, ttl_seconds: Optional[int] = None) -> None: if not self.enabled: return try: payload = json.dumps(value, default=lambda o: getattr(o, "model_dump", lambda: o)()) ttl = ttl_seconds if ttl_seconds is not None else self.default_ttl_seconds # Use -1 for no expiration, otherwise use setex if ttl > 0: self._client.setex(key, ttl, payload) # type: ignore[union-attr] else: self._client.set(key, payload) # type: ignore[union-attr] self._stats["sets"] += 1 except Exception as exc: logger.debug(f"Redis set_json error for key={key}: {exc}") def delete(self, pattern: str) -> int: """Delete keys matching pattern (e.g., 'routes:*'). Returns count deleted.""" if not self.enabled: return 0 try: keys = list(self._client.scan_iter(match=pattern)) # type: ignore[union-attr] if keys: return self._client.delete(*keys) # type: ignore[union-attr] return 0 except Exception as exc: logger.error(f"Redis delete error for pattern={pattern}: {exc}") return 0 def get_stats(self) -> Dict[str, Any]: """Get cache statistics.""" stats = self._stats.copy() if self.enabled: try: # Count cache keys route_keys = list(self._client.scan_iter(match="routes:*")) # type: ignore[union-attr] stats["total_keys"] = len(route_keys) stats["enabled"] = True except Exception: stats["total_keys"] = 0 stats["enabled"] = True else: stats["total_keys"] = 0 stats["enabled"] = False return stats def get_keys(self, pattern: str = "routes:*") -> list[str]: """Get list of cache keys matching pattern.""" if not self.enabled: return [] try: return list(self._client.scan_iter(match=pattern)) # type: ignore[union-attr] except Exception as exc: logger.error(f"Redis get_keys error for pattern={pattern}: {exc}") return [] # Singleton cache instance for app cache = RedisCache()