mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-18 19:26:31 +01:00
refactor: backend standardization (service layer, validation, route splitting) + image cache and Scryfall API fixes
This commit is contained in:
parent
e81b47bccf
commit
f784741416
35 changed files with 7054 additions and 4344 deletions
306
code/web/services/base.py
Normal file
306
code/web/services/base.py
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
"""Base classes for web services.
|
||||
|
||||
Provides standardized patterns for service layer implementation including
|
||||
state management, data loading, and caching.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Generic, Optional, TypeVar
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
K = TypeVar("K")
|
||||
V = TypeVar("V")
|
||||
|
||||
|
||||
class ServiceError(Exception):
|
||||
"""Base exception for service layer errors."""
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(ServiceError):
|
||||
"""Validation failed."""
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundError(ServiceError):
|
||||
"""Resource not found."""
|
||||
pass
|
||||
|
||||
|
||||
class BaseService(ABC):
|
||||
"""Abstract base class for all services.
|
||||
|
||||
Provides common patterns for initialization, validation, and error handling.
|
||||
Services should be stateless where possible and inject dependencies via __init__.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize service. Override in subclasses to inject dependencies."""
|
||||
pass
|
||||
|
||||
def _validate(self, condition: bool, message: str) -> None:
|
||||
"""Validate a condition, raise ValidationError if false.
|
||||
|
||||
Args:
|
||||
condition: Condition to check
|
||||
message: Error message if validation fails
|
||||
|
||||
Raises:
|
||||
ValidationError: If condition is False
|
||||
"""
|
||||
if not condition:
|
||||
raise ValidationError(message)
|
||||
|
||||
|
||||
class StateService(BaseService):
|
||||
"""Base class for services that manage mutable state.
|
||||
|
||||
Provides thread-safe state management with automatic cleanup.
|
||||
Subclasses should implement _initialize_state and _should_cleanup.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._state: Dict[str, Dict[str, Any]] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def get_state(self, key: str) -> Dict[str, Any]:
|
||||
"""Get or create state for a key.
|
||||
|
||||
Args:
|
||||
key: State key (e.g., session ID)
|
||||
|
||||
Returns:
|
||||
State dictionary
|
||||
"""
|
||||
with self._lock:
|
||||
if key not in self._state:
|
||||
self._state[key] = self._initialize_state(key)
|
||||
return self._state[key]
|
||||
|
||||
def set_state_value(self, key: str, field: str, value: Any) -> None:
|
||||
"""Set a field in state.
|
||||
|
||||
Args:
|
||||
key: State key
|
||||
field: Field name
|
||||
value: Value to set
|
||||
"""
|
||||
with self._lock:
|
||||
state = self.get_state(key)
|
||||
state[field] = value
|
||||
|
||||
def get_state_value(self, key: str, field: str, default: Any = None) -> Any:
|
||||
"""Get a field from state.
|
||||
|
||||
Args:
|
||||
key: State key
|
||||
field: Field name
|
||||
default: Default value if field not found
|
||||
|
||||
Returns:
|
||||
Field value or default
|
||||
"""
|
||||
with self._lock:
|
||||
state = self.get_state(key)
|
||||
return state.get(field, default)
|
||||
|
||||
def cleanup_state(self) -> int:
|
||||
"""Clean up expired or invalid state.
|
||||
|
||||
Returns:
|
||||
Number of entries cleaned up
|
||||
"""
|
||||
with self._lock:
|
||||
to_remove = [k for k, v in self._state.items() if self._should_cleanup(k, v)]
|
||||
for key in to_remove:
|
||||
del self._state[key]
|
||||
return len(to_remove)
|
||||
|
||||
@abstractmethod
|
||||
def _initialize_state(self, key: str) -> Dict[str, Any]:
|
||||
"""Initialize state for a new key.
|
||||
|
||||
Args:
|
||||
key: State key
|
||||
|
||||
Returns:
|
||||
Initial state dictionary
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _should_cleanup(self, key: str, state: Dict[str, Any]) -> bool:
|
||||
"""Check if state should be cleaned up.
|
||||
|
||||
Args:
|
||||
key: State key
|
||||
state: State dictionary
|
||||
|
||||
Returns:
|
||||
True if state should be removed
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class DataService(BaseService, Generic[T]):
|
||||
"""Base class for services that load and manage data.
|
||||
|
||||
Provides patterns for lazy loading, validation, and refresh.
|
||||
Subclasses should implement _load_data.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._data: Optional[T] = None
|
||||
self._loaded = False
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def get_data(self, force_reload: bool = False) -> T:
|
||||
"""Get data, loading if necessary.
|
||||
|
||||
Args:
|
||||
force_reload: Force reload even if already loaded
|
||||
|
||||
Returns:
|
||||
Loaded data
|
||||
|
||||
Raises:
|
||||
ServiceError: If data loading fails
|
||||
"""
|
||||
with self._lock:
|
||||
if force_reload or not self._loaded:
|
||||
self._data = self._load_data()
|
||||
self._loaded = True
|
||||
if self._data is None:
|
||||
raise ServiceError("Failed to load data")
|
||||
return self._data
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if data is loaded.
|
||||
|
||||
Returns:
|
||||
True if data is loaded
|
||||
"""
|
||||
with self._lock:
|
||||
return self._loaded
|
||||
|
||||
def reload(self) -> T:
|
||||
"""Force reload data.
|
||||
|
||||
Returns:
|
||||
Reloaded data
|
||||
"""
|
||||
return self.get_data(force_reload=True)
|
||||
|
||||
@abstractmethod
|
||||
def _load_data(self) -> T:
|
||||
"""Load data from source.
|
||||
|
||||
Returns:
|
||||
Loaded data
|
||||
|
||||
Raises:
|
||||
ServiceError: If loading fails
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class CachedService(BaseService, Generic[K, V]):
|
||||
"""Base class for services with caching behavior.
|
||||
|
||||
Provides thread-safe caching with TTL and size limits.
|
||||
Subclasses should implement _compute_value.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_seconds: Optional[int] = None, max_size: Optional[int] = None) -> None:
|
||||
"""Initialize cached service.
|
||||
|
||||
Args:
|
||||
ttl_seconds: Time-to-live for cache entries (None = no expiration)
|
||||
max_size: Maximum cache size (None = no limit)
|
||||
"""
|
||||
super().__init__()
|
||||
self._cache: Dict[K, tuple[V, float]] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._ttl_seconds = ttl_seconds
|
||||
self._max_size = max_size
|
||||
|
||||
def get(self, key: K, force_recompute: bool = False) -> V:
|
||||
"""Get cached value or compute it.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
force_recompute: Force recompute even if cached
|
||||
|
||||
Returns:
|
||||
Cached or computed value
|
||||
"""
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
|
||||
# Check cache
|
||||
if not force_recompute and key in self._cache:
|
||||
value, timestamp = self._cache[key]
|
||||
if self._ttl_seconds is None or (now - timestamp) < self._ttl_seconds:
|
||||
return value
|
||||
|
||||
# Compute new value
|
||||
value = self._compute_value(key)
|
||||
|
||||
# Store in cache
|
||||
self._cache[key] = (value, now)
|
||||
|
||||
# Enforce size limit (simple LRU: remove oldest)
|
||||
if self._max_size is not None and len(self._cache) > self._max_size:
|
||||
oldest_key = min(self._cache.keys(), key=lambda k: self._cache[k][1])
|
||||
del self._cache[oldest_key]
|
||||
|
||||
return value
|
||||
|
||||
def invalidate(self, key: Optional[K] = None) -> None:
|
||||
"""Invalidate cache entry or entire cache.
|
||||
|
||||
Args:
|
||||
key: Cache key to invalidate (None = invalidate all)
|
||||
"""
|
||||
with self._lock:
|
||||
if key is None:
|
||||
self._cache.clear()
|
||||
elif key in self._cache:
|
||||
del self._cache[key]
|
||||
|
||||
def cleanup_expired(self) -> int:
|
||||
"""Remove expired cache entries.
|
||||
|
||||
Returns:
|
||||
Number of entries removed
|
||||
"""
|
||||
if self._ttl_seconds is None:
|
||||
return 0
|
||||
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
expired = [k for k, (_, ts) in self._cache.items() if (now - ts) >= self._ttl_seconds]
|
||||
for key in expired:
|
||||
del self._cache[key]
|
||||
return len(expired)
|
||||
|
||||
@abstractmethod
|
||||
def _compute_value(self, key: K) -> V:
|
||||
"""Compute value for a cache key.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
Returns:
|
||||
Computed value
|
||||
|
||||
Raises:
|
||||
ServiceError: If computation fails
|
||||
"""
|
||||
pass
|
||||
Loading…
Add table
Add a link
Reference in a new issue