mirror of
https://github.com/shadoll/moma.git
synced 2026-08-28 11:33:25 +00:00
feat(cache): Implement unified caching subsystem with decorators, strategies, and management
- Added core caching functionality with `Cache` class supporting in-memory and file-based caching. - Introduced `CacheManager` for high-level cache operations and statistics. - Created various cache key generation strategies: `FilepathMethodStrategy`, `APIRequestStrategy`, `SimpleKeyStrategy`, and `CustomStrategy`. - Developed decorators for easy method caching: `cached`, `cached_method`, `cached_api`, and `cached_property`. - Implemented type definitions for cache entries and statistics. - Added comprehensive tests for cache operations, strategies, and decorators to ensure functionality and backward compatibility.
This commit is contained in:
Vendored
+302
@@ -0,0 +1,302 @@
|
||||
"""Cache decorators for easy method caching.
|
||||
|
||||
Provides decorators that can be applied to methods for automatic caching
|
||||
with different strategies.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Any
|
||||
import logging
|
||||
import json
|
||||
|
||||
from .strategies import (
|
||||
CacheKeyStrategy,
|
||||
FilepathMethodStrategy,
|
||||
APIRequestStrategy,
|
||||
SimpleKeyStrategy
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cached(
|
||||
strategy: Optional[CacheKeyStrategy] = None,
|
||||
ttl: Optional[int] = None,
|
||||
key_prefix: Optional[str] = None
|
||||
):
|
||||
"""Generic cache decorator with strategy pattern.
|
||||
|
||||
This is the main caching decorator that supports different strategies
|
||||
for generating cache keys based on the use case.
|
||||
|
||||
Args:
|
||||
strategy: Cache key generation strategy (defaults to FilepathMethodStrategy)
|
||||
ttl: Time-to-live in seconds (defaults to settings value or 21600)
|
||||
key_prefix: Optional prefix for cache key
|
||||
|
||||
Returns:
|
||||
Decorated function with caching
|
||||
|
||||
Usage:
|
||||
@cached(strategy=FilepathMethodStrategy(), ttl=3600)
|
||||
def extract_title(self):
|
||||
# Expensive operation
|
||||
return title
|
||||
|
||||
@cached(strategy=APIRequestStrategy(), ttl=21600)
|
||||
def fetch_tmdb_data(self, movie_id):
|
||||
# API call
|
||||
return data
|
||||
|
||||
@cached(ttl=7200) # Uses FilepathMethodStrategy by default
|
||||
def extract_year(self):
|
||||
return year
|
||||
|
||||
Note:
|
||||
The instance must have a `cache` attribute for caching to work.
|
||||
If no cache is found, the function executes without caching.
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
# Get cache from instance
|
||||
cache = getattr(self, 'cache', None)
|
||||
if not cache:
|
||||
logger.debug(f"No cache found on {self.__class__.__name__}, executing uncached")
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
# Determine strategy
|
||||
actual_strategy = strategy or FilepathMethodStrategy()
|
||||
|
||||
# Generate cache key based on strategy type
|
||||
try:
|
||||
cache_key = _generate_cache_key(
|
||||
actual_strategy, self, func, args, kwargs, key_prefix
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to generate cache key: {e}, executing uncached")
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
# Check cache
|
||||
cached_value = cache.get(cache_key)
|
||||
if cached_value is not None:
|
||||
logger.debug(f"Cache hit for {func.__name__}: {cache_key}")
|
||||
return cached_value
|
||||
|
||||
# Execute function
|
||||
logger.debug(f"Cache miss for {func.__name__}: {cache_key}")
|
||||
result = func(self, *args, **kwargs)
|
||||
|
||||
# Determine TTL
|
||||
actual_ttl = _determine_ttl(self, ttl)
|
||||
|
||||
# Cache result (only if not None)
|
||||
if result is not None:
|
||||
cache.set(cache_key, result, actual_ttl)
|
||||
logger.debug(f"Cached {func.__name__}: {cache_key} (TTL: {actual_ttl}s)")
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def _generate_cache_key(
|
||||
strategy: CacheKeyStrategy,
|
||||
instance: Any,
|
||||
func: Callable,
|
||||
args: tuple,
|
||||
kwargs: dict,
|
||||
key_prefix: Optional[str]
|
||||
) -> str:
|
||||
"""Generate cache key based on strategy type.
|
||||
|
||||
Args:
|
||||
strategy: Cache key strategy
|
||||
instance: Instance the method is called on
|
||||
func: Function being cached
|
||||
args: Positional arguments
|
||||
kwargs: Keyword arguments
|
||||
key_prefix: Optional key prefix
|
||||
|
||||
Returns:
|
||||
Generated cache key
|
||||
"""
|
||||
if isinstance(strategy, FilepathMethodStrategy):
|
||||
# Extractor pattern: needs file_path attribute
|
||||
file_path = getattr(instance, 'file_path', None)
|
||||
if not file_path:
|
||||
raise ValueError(f"{instance.__class__.__name__} missing file_path attribute")
|
||||
|
||||
instance_id = str(id(instance))
|
||||
return strategy.generate_key(file_path, func.__name__, instance_id)
|
||||
|
||||
elif isinstance(strategy, APIRequestStrategy):
|
||||
# API pattern: expects service name in args or uses function name
|
||||
if args:
|
||||
service = str(args[0]) if len(args) >= 1 else func.__name__
|
||||
url = str(args[1]) if len(args) >= 2 else ""
|
||||
params = args[2] if len(args) >= 3 else kwargs
|
||||
else:
|
||||
service = func.__name__
|
||||
url = ""
|
||||
params = kwargs
|
||||
|
||||
return strategy.generate_key(service, url, params)
|
||||
|
||||
elif isinstance(strategy, SimpleKeyStrategy):
|
||||
# Simple pattern: uses prefix and first arg as identifier
|
||||
prefix = key_prefix or func.__name__
|
||||
identifier = str(args[0]) if args else str(kwargs.get('id', 'default'))
|
||||
return strategy.generate_key(prefix, identifier)
|
||||
|
||||
else:
|
||||
# Custom strategy: pass instance and all args
|
||||
return strategy.generate_key(instance, *args, **kwargs)
|
||||
|
||||
|
||||
def _determine_ttl(instance: Any, ttl: Optional[int]) -> int:
|
||||
"""Determine TTL from explicit value or instance settings.
|
||||
|
||||
Args:
|
||||
instance: Instance the method is called on
|
||||
ttl: Explicit TTL value (takes precedence)
|
||||
|
||||
Returns:
|
||||
TTL in seconds
|
||||
"""
|
||||
if ttl is not None:
|
||||
return ttl
|
||||
|
||||
# Try to get from settings
|
||||
settings = getattr(instance, 'settings', None)
|
||||
if settings:
|
||||
return settings.get('cache_ttl_extractors', 21600)
|
||||
|
||||
# Default to 6 hours
|
||||
return 21600
|
||||
|
||||
|
||||
def cached_method(ttl: Optional[int] = None):
|
||||
"""Decorator for extractor methods (legacy/convenience).
|
||||
|
||||
This is an alias for cached() with FilepathMethodStrategy.
|
||||
Provides backward compatibility with existing code.
|
||||
|
||||
Args:
|
||||
ttl: Time-to-live in seconds
|
||||
|
||||
Returns:
|
||||
Decorated function
|
||||
|
||||
Usage:
|
||||
@cached_method(ttl=3600)
|
||||
def extract_title(self):
|
||||
return title
|
||||
|
||||
Note:
|
||||
This is equivalent to:
|
||||
@cached(strategy=FilepathMethodStrategy(), ttl=3600)
|
||||
"""
|
||||
return cached(strategy=FilepathMethodStrategy(), ttl=ttl)
|
||||
|
||||
|
||||
def cached_api(service: str, ttl: Optional[int] = None):
|
||||
"""Decorator for API response caching.
|
||||
|
||||
Specialized decorator for caching API responses. Generates keys
|
||||
based on service name and request parameters.
|
||||
|
||||
Args:
|
||||
service: Service name (e.g., "tmdb", "imdb", "omdb")
|
||||
ttl: Time-to-live in seconds (defaults to cache_ttl_{service})
|
||||
|
||||
Returns:
|
||||
Decorated function
|
||||
|
||||
Usage:
|
||||
@cached_api("tmdb", ttl=21600)
|
||||
def search_movie(self, title, year=None):
|
||||
# Make API request
|
||||
response = requests.get(...)
|
||||
return response.json()
|
||||
|
||||
@cached_api("imdb")
|
||||
def get_movie_details(self, movie_id):
|
||||
return api_response
|
||||
|
||||
Note:
|
||||
The function args/kwargs are automatically included in the cache key.
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
cache = getattr(self, 'cache', None)
|
||||
if not cache:
|
||||
logger.debug(f"No cache on {self.__class__.__name__}, executing uncached")
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
# Build cache key from service + function name + args/kwargs
|
||||
args_repr = json.dumps({
|
||||
'args': [str(a) for a in args],
|
||||
'kwargs': {k: str(v) for k, v in sorted(kwargs.items())}
|
||||
}, sort_keys=True)
|
||||
|
||||
strategy = APIRequestStrategy()
|
||||
cache_key = strategy.generate_key(service, func.__name__, {'params': args_repr})
|
||||
|
||||
# Check cache
|
||||
cached_value = cache.get(cache_key)
|
||||
if cached_value is not None:
|
||||
logger.debug(f"API cache hit for {service}.{func.__name__}")
|
||||
return cached_value
|
||||
|
||||
# Execute function
|
||||
logger.debug(f"API cache miss for {service}.{func.__name__}")
|
||||
result = func(self, *args, **kwargs)
|
||||
|
||||
# Determine TTL (service-specific or default)
|
||||
actual_ttl = ttl
|
||||
if actual_ttl is None:
|
||||
settings = getattr(self, 'settings', None)
|
||||
if settings:
|
||||
# Try service-specific TTL first
|
||||
actual_ttl = settings.get(f'cache_ttl_{service}',
|
||||
settings.get('cache_ttl_api', 21600))
|
||||
else:
|
||||
actual_ttl = 21600 # Default 6 hours
|
||||
|
||||
# Cache result (only if not None)
|
||||
if result is not None:
|
||||
cache.set(cache_key, result, actual_ttl)
|
||||
logger.debug(f"API cached {service}.{func.__name__} (TTL: {actual_ttl}s)")
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def cached_property(ttl: Optional[int] = None):
|
||||
"""Decorator for caching property-like methods.
|
||||
|
||||
Similar to @property but with caching support.
|
||||
|
||||
Args:
|
||||
ttl: Time-to-live in seconds
|
||||
|
||||
Returns:
|
||||
Decorated function
|
||||
|
||||
Usage:
|
||||
@cached_property(ttl=3600)
|
||||
def metadata(self):
|
||||
# Expensive computation
|
||||
return complex_metadata
|
||||
|
||||
Note:
|
||||
Unlike @property, this still requires parentheses: obj.metadata()
|
||||
For true property behavior, use @property with manual caching.
|
||||
"""
|
||||
return cached(strategy=FilepathMethodStrategy(), ttl=ttl)
|
||||
Reference in New Issue
Block a user