""" Cache Service for AniWorld. This module provides caching functionality with support for both in-memory and Redis backends to improve application performance. """ import asyncio import hashlib import logging import pickle from abc import ABC, abstractmethod from datetime import datetime, timedelta from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) class CacheBackend(ABC): """Abstract base class for cache backends.""" @abstractmethod async def get(self, key: str) -> Optional[Any]: """ Get value from cache. Args: key: Cache key Returns: Cached value or None if not found """ pass @abstractmethod async def set( self, key: str, value: Any, ttl: Optional[int] = None ) -> bool: """ Set value in cache. Args: key: Cache key value: Value to cache ttl: Time to live in seconds Returns: True if successful """ pass @abstractmethod async def delete(self, key: str) -> bool: """ Delete value from cache. Args: key: Cache key Returns: True if key was deleted """ pass @abstractmethod async def exists(self, key: str) -> bool: """ Check if key exists in cache. Args: key: Cache key Returns: True if key exists """ pass @abstractmethod async def clear(self) -> bool: """ Clear all cached values. Returns: True if successful """ pass @abstractmethod async def get_many(self, keys: List[str]) -> Dict[str, Any]: """ Get multiple values from cache. Args: keys: List of cache keys Returns: Dictionary mapping keys to values """ pass @abstractmethod async def set_many( self, items: Dict[str, Any], ttl: Optional[int] = None ) -> bool: """ Set multiple values in cache. Args: items: Dictionary of key-value pairs ttl: Time to live in seconds Returns: True if successful """ pass @abstractmethod async def delete_pattern(self, pattern: str) -> int: """ Delete all keys matching pattern. Args: pattern: Pattern to match (supports wildcards) Returns: Number of keys deleted """ pass class InMemoryCacheBackend(CacheBackend): """In-memory cache backend using dictionary.""" def __init__(self, max_size: int = 1000): """ Initialize in-memory cache. Args: max_size: Maximum number of items to cache """ self.cache: Dict[str, Dict[str, Any]] = {} self.max_size = max_size self._lock = asyncio.Lock() def _is_expired(self, item: Dict[str, Any]) -> bool: """ Check if cache item is expired. Args: item: Cache item with expiry Returns: True if expired """ if item.get("expiry") is None: return False return datetime.utcnow() > item["expiry"] def _evict_oldest(self) -> None: """Evict oldest cache item when cache is full.""" if len(self.cache) >= self.max_size: # Remove oldest item oldest_key = min( self.cache.keys(), key=lambda k: self.cache[k].get("created", datetime.utcnow()), ) del self.cache[oldest_key] async def get(self, key: str) -> Optional[Any]: """Get value from cache.""" async with self._lock: if key not in self.cache: logger.debug("Cache miss for key: %s", key) return None item = self.cache[key] if self._is_expired(item): logger.debug("Cache expired for key: %s", key) del self.cache[key] return None logger.debug("Cache hit for key: %s", key) return item["value"] async def set( self, key: str, value: Any, ttl: Optional[int] = None ) -> bool: """Set value in cache.""" async with self._lock: self._evict_oldest() expiry = None if ttl: expiry = datetime.utcnow() + timedelta(seconds=ttl) self.cache[key] = { "value": value, "expiry": expiry, "created": datetime.utcnow(), } logger.debug("Cached key: %s (ttl=%s)", key, ttl) return True async def delete(self, key: str) -> bool: """Delete value from cache.""" async with self._lock: if key in self.cache: del self.cache[key] logger.debug("Deleted cache key: %s", key) return True logger.debug("Cache delete skipped; key not found: %s", key) return False async def exists(self, key: str) -> bool: """Check if key exists in cache.""" async with self._lock: if key not in self.cache: return False item = self.cache[key] if self._is_expired(item): del self.cache[key] return False return True async def clear(self) -> bool: """Clear all cached values.""" async with self._lock: self.cache.clear() logger.debug("Cleared in-memory cache") return True async def get_many(self, keys: List[str]) -> Dict[str, Any]: """Get multiple values from cache.""" result = {} for key in keys: value = await self.get(key) if value is not None: result[key] = value return result async def set_many( self, items: Dict[str, Any], ttl: Optional[int] = None ) -> bool: """Set multiple values in cache.""" for key, value in items.items(): await self.set(key, value, ttl) return True async def delete_pattern(self, pattern: str) -> int: """Delete all keys matching pattern.""" import fnmatch async with self._lock: keys_to_delete = [ key for key in self.cache.keys() if fnmatch.fnmatch(key, pattern) ] for key in keys_to_delete: del self.cache[key] return len(keys_to_delete) class RedisCacheBackend(CacheBackend): """Redis cache backend.""" def __init__( self, redis_url: str = "redis://localhost:6379", prefix: str = "aniworld:", ): """ Initialize Redis cache. Args: redis_url: Redis connection URL prefix: Key prefix for namespacing """ self.redis_url = redis_url self.prefix = prefix self._redis = None async def _get_redis(self): """Get Redis connection.""" if self._redis is None: try: import aioredis self._redis = await aioredis.create_redis_pool(self.redis_url) logger.debug("Connected to Redis at %s", self.redis_url) except ImportError: logger.error( "aioredis not installed. Install with: pip install aioredis" ) raise except Exception as e: logger.error("Failed to connect to Redis: %s", e) raise return self._redis def _make_key(self, key: str) -> str: """Add prefix to key.""" return f"{self.prefix}{key}" async def get(self, key: str) -> Optional[Any]: """Get value from cache.""" try: redis = await self._get_redis() data = await redis.get(self._make_key(key)) if data is None: return None return pickle.loads(data) except Exception as e: logger.error("Redis get error: %s", e) return None async def set( self, key: str, value: Any, ttl: Optional[int] = None ) -> bool: """Set value in cache.""" try: redis = await self._get_redis() data = pickle.dumps(value) if ttl: await redis.setex(self._make_key(key), ttl, data) else: await redis.set(self._make_key(key), data) return True except Exception as e: logger.error("Redis set error: %s", e) return False async def delete(self, key: str) -> bool: """Delete value from cache.""" try: redis = await self._get_redis() result = await redis.delete(self._make_key(key)) return result > 0 except Exception as e: logger.error("Redis delete error: %s", e) return False async def exists(self, key: str) -> bool: """Check if key exists in cache.""" try: redis = await self._get_redis() return await redis.exists(self._make_key(key)) except Exception as e: logger.error("Redis exists error: %s", e) return False async def clear(self) -> bool: """Clear all cached values with prefix.""" try: redis = await self._get_redis() keys = await redis.keys(f"{self.prefix}*") if keys: await redis.delete(*keys) return True except Exception as e: logger.error("Redis clear error: %s", e) return False async def get_many(self, keys: List[str]) -> Dict[str, Any]: """Get multiple values from cache.""" try: redis = await self._get_redis() prefixed_keys = [self._make_key(k) for k in keys] values = await redis.mget(*prefixed_keys) result = {} for key, value in zip(keys, values): if value is not None: result[key] = pickle.loads(value) return result except Exception as e: logger.error("Redis get_many error: %s", e) return {} async def set_many( self, items: Dict[str, Any], ttl: Optional[int] = None ) -> bool: """Set multiple values in cache.""" try: for key, value in items.items(): await self.set(key, value, ttl) return True except Exception as e: logger.error("Redis set_many error: %s", e) return False async def delete_pattern(self, pattern: str) -> int: """Delete all keys matching pattern.""" try: redis = await self._get_redis() full_pattern = f"{self.prefix}{pattern}" keys = await redis.keys(full_pattern) if keys: await redis.delete(*keys) return len(keys) return 0 except Exception as e: logger.error("Redis delete_pattern error: %s", e) return 0 async def close(self) -> None: """Close Redis connection.""" if self._redis: self._redis.close() await self._redis.wait_closed() class CacheService: """Main cache service with automatic key generation and TTL management.""" def __init__( self, backend: Optional[CacheBackend] = None, default_ttl: int = 3600, key_prefix: str = "", ): """ Initialize cache service. Args: backend: Cache backend to use default_ttl: Default time to live in seconds key_prefix: Prefix for all cache keys """ self.backend = backend or InMemoryCacheBackend() self.default_ttl = default_ttl self.key_prefix = key_prefix def _make_key(self, *args: Any, **kwargs: Any) -> str: """ Generate cache key from arguments. Args: *args: Positional arguments **kwargs: Keyword arguments Returns: Cache key string """ # Create a stable key from arguments key_parts = [str(arg) for arg in args] key_parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items())) key_str = ":".join(key_parts) # Hash long keys if len(key_str) > 200: key_hash = hashlib.md5(key_str.encode()).hexdigest() return f"{self.key_prefix}{key_hash}" return f"{self.key_prefix}{key_str}" async def get( self, key: str, default: Optional[Any] = None ) -> Optional[Any]: """ Get value from cache. Args: key: Cache key default: Default value if not found Returns: Cached value or default """ value = await self.backend.get(key) return value if value is not None else default async def set( self, key: str, value: Any, ttl: Optional[int] = None ) -> bool: """ Set value in cache. Args: key: Cache key value: Value to cache ttl: Time to live in seconds (uses default if None) Returns: True if successful """ if ttl is None: ttl = self.default_ttl return await self.backend.set(key, value, ttl) async def delete(self, key: str) -> bool: """ Delete value from cache. Args: key: Cache key Returns: True if deleted """ return await self.backend.delete(key) async def exists(self, key: str) -> bool: """ Check if key exists in cache. Args: key: Cache key Returns: True if exists """ return await self.backend.exists(key) async def clear(self) -> bool: """ Clear all cached values. Returns: True if successful """ return await self.backend.clear() async def get_or_set( self, key: str, factory, ttl: Optional[int] = None, ) -> Any: """ Get value from cache or compute and cache it. Args: key: Cache key factory: Callable to compute value if not cached ttl: Time to live in seconds Returns: Cached or computed value """ value = await self.get(key) if value is None: # Compute value if asyncio.iscoroutinefunction(factory): value = await factory() else: value = factory() # Cache it await self.set(key, value, ttl) return value async def invalidate_pattern(self, pattern: str) -> int: """ Invalidate all keys matching pattern. Args: pattern: Pattern to match Returns: Number of keys invalidated """ return await self.backend.delete_pattern(pattern) async def cache_anime_list( self, anime_list: List[Dict[str, Any]], ttl: Optional[int] = None ) -> bool: """ Cache anime list. Args: anime_list: List of anime data ttl: Time to live in seconds Returns: True if successful """ key = self._make_key("anime", "list") return await self.set(key, anime_list, ttl) async def get_anime_list(self) -> Optional[List[Dict[str, Any]]]: """ Get cached anime list. Returns: Cached anime list or None """ key = self._make_key("anime", "list") return await self.get(key) async def cache_anime_detail( self, anime_id: str, data: Dict[str, Any], ttl: Optional[int] = None ) -> bool: """ Cache anime detail. Args: anime_id: Anime identifier data: Anime data ttl: Time to live in seconds Returns: True if successful """ key = self._make_key("anime", "detail", anime_id) return await self.set(key, data, ttl) async def get_anime_detail(self, anime_id: str) -> Optional[Dict[str, Any]]: """ Get cached anime detail. Args: anime_id: Anime identifier Returns: Cached anime data or None """ key = self._make_key("anime", "detail", anime_id) return await self.get(key) async def invalidate_anime_cache(self) -> int: """ Invalidate all anime-related cache. Returns: Number of keys invalidated """ return await self.invalidate_pattern(f"{self.key_prefix}anime*") async def cache_config( self, config: Dict[str, Any], ttl: Optional[int] = None ) -> bool: """ Cache configuration. Args: config: Configuration data ttl: Time to live in seconds Returns: True if successful """ key = self._make_key("config") return await self.set(key, config, ttl) async def get_config(self) -> Optional[Dict[str, Any]]: """ Get cached configuration. Returns: Cached configuration or None """ key = self._make_key("config") return await self.get(key) async def invalidate_config_cache(self) -> bool: """ Invalidate configuration cache. Returns: True if successful """ key = self._make_key("config") return await self.delete(key) # Global cache service instance _cache_service: Optional[CacheService] = None def get_cache_service() -> CacheService: """ Get the global cache service instance. Returns: CacheService instance """ global _cache_service if _cache_service is None: _cache_service = CacheService() return _cache_service def configure_cache_service( backend_type: str = "memory", redis_url: str = "redis://localhost:6379", default_ttl: int = 3600, max_size: int = 1000, ) -> CacheService: """ Configure the global cache service. Args: backend_type: Type of backend ("memory" or "redis") redis_url: Redis connection URL (for redis backend) default_ttl: Default time to live in seconds max_size: Maximum cache size (for memory backend) Returns: Configured CacheService instance """ global _cache_service if backend_type == "redis": backend = RedisCacheBackend(redis_url=redis_url) else: backend = InMemoryCacheBackend(max_size=max_size) _cache_service = CacheService( backend=backend, default_ttl=default_ttl, key_prefix="aniworld:" ) return _cache_service