mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-19 03:36:30 +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
|
||||
318
code/web/services/interfaces.py
Normal file
318
code/web/services/interfaces.py
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
"""Service interfaces using Protocol for structural typing.
|
||||
|
||||
Defines contracts for different types of services without requiring inheritance.
|
||||
Use these for type hints and dependency injection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, Any, Dict, List, Optional, TypeVar, runtime_checkable
|
||||
import pandas as pd
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
K = TypeVar("K")
|
||||
V = TypeVar("V")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SessionService(Protocol):
|
||||
"""Interface for session management services."""
|
||||
|
||||
def new_session_id(self) -> str:
|
||||
"""Create a new session ID.
|
||||
|
||||
Returns:
|
||||
Unique session identifier
|
||||
"""
|
||||
...
|
||||
|
||||
def get_session(self, session_id: Optional[str]) -> Dict[str, Any]:
|
||||
"""Get or create session state.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier (creates new if None)
|
||||
|
||||
Returns:
|
||||
Session state dictionary
|
||||
"""
|
||||
...
|
||||
|
||||
def set_value(self, session_id: str, key: str, value: Any) -> None:
|
||||
"""Set a value in session state.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
key: State key
|
||||
value: Value to store
|
||||
"""
|
||||
...
|
||||
|
||||
def get_value(self, session_id: str, key: str, default: Any = None) -> Any:
|
||||
"""Get a value from session state.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
key: State key
|
||||
default: Default value if key not found
|
||||
|
||||
Returns:
|
||||
Stored value or default
|
||||
"""
|
||||
...
|
||||
|
||||
def cleanup_expired(self) -> int:
|
||||
"""Clean up expired sessions.
|
||||
|
||||
Returns:
|
||||
Number of sessions cleaned up
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CardLoaderService(Protocol):
|
||||
"""Interface for card data loading services."""
|
||||
|
||||
def get_cards(self, force_reload: bool = False) -> pd.DataFrame:
|
||||
"""Get card data.
|
||||
|
||||
Args:
|
||||
force_reload: Force reload from source
|
||||
|
||||
Returns:
|
||||
DataFrame with card data
|
||||
"""
|
||||
...
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if card data is loaded.
|
||||
|
||||
Returns:
|
||||
True if data is loaded
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CatalogService(Protocol):
|
||||
"""Interface for catalog services (commanders, themes, etc.)."""
|
||||
|
||||
def get_catalog(self, force_reload: bool = False) -> pd.DataFrame:
|
||||
"""Get catalog data.
|
||||
|
||||
Args:
|
||||
force_reload: Force reload from source
|
||||
|
||||
Returns:
|
||||
DataFrame with catalog data
|
||||
"""
|
||||
...
|
||||
|
||||
def search(self, query: str, **filters: Any) -> pd.DataFrame:
|
||||
"""Search catalog with filters.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
**filters: Additional filters
|
||||
|
||||
Returns:
|
||||
Filtered DataFrame
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class OwnedCardsService(Protocol):
|
||||
"""Interface for owned cards management."""
|
||||
|
||||
def get_owned_names(self) -> List[str]:
|
||||
"""Get list of owned card names.
|
||||
|
||||
Returns:
|
||||
List of card names
|
||||
"""
|
||||
...
|
||||
|
||||
def add_owned_names(self, names: List[str]) -> None:
|
||||
"""Add card names to owned list.
|
||||
|
||||
Args:
|
||||
names: Card names to add
|
||||
"""
|
||||
...
|
||||
|
||||
def remove_owned_name(self, name: str) -> bool:
|
||||
"""Remove a card name from owned list.
|
||||
|
||||
Args:
|
||||
name: Card name to remove
|
||||
|
||||
Returns:
|
||||
True if removed, False if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def clear_owned(self) -> None:
|
||||
"""Clear all owned cards."""
|
||||
...
|
||||
|
||||
def import_from_file(self, file_content: str, format_type: str) -> int:
|
||||
"""Import owned cards from file content.
|
||||
|
||||
Args:
|
||||
file_content: File content to parse
|
||||
format_type: Format type (csv, txt, etc.)
|
||||
|
||||
Returns:
|
||||
Number of cards imported
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CacheService(Protocol[K, V]):
|
||||
"""Interface for caching services."""
|
||||
|
||||
def get(self, key: K, default: Optional[V] = None) -> Optional[V]:
|
||||
"""Get cached value.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
default: Default value if not found
|
||||
|
||||
Returns:
|
||||
Cached value or default
|
||||
"""
|
||||
...
|
||||
|
||||
def set(self, key: K, value: V, ttl: Optional[int] = None) -> None:
|
||||
"""Set cached value.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
value: Value to cache
|
||||
ttl: Time-to-live in seconds (None = no expiration)
|
||||
"""
|
||||
...
|
||||
|
||||
def invalidate(self, key: Optional[K] = None) -> None:
|
||||
"""Invalidate cache entry or entire cache.
|
||||
|
||||
Args:
|
||||
key: Cache key (None = invalidate all)
|
||||
"""
|
||||
...
|
||||
|
||||
def cleanup_expired(self) -> int:
|
||||
"""Remove expired cache entries.
|
||||
|
||||
Returns:
|
||||
Number of entries removed
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class BuildOrchestratorService(Protocol):
|
||||
"""Interface for deck build orchestration."""
|
||||
|
||||
def orchestrate_build(
|
||||
self,
|
||||
session_id: str,
|
||||
commander_name: str,
|
||||
theme_tags: List[str],
|
||||
**options: Any
|
||||
) -> Dict[str, Any]:
|
||||
"""Orchestrate a deck build.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
commander_name: Commander card name
|
||||
theme_tags: List of theme tags
|
||||
**options: Additional build options
|
||||
|
||||
Returns:
|
||||
Build result dictionary
|
||||
"""
|
||||
...
|
||||
|
||||
def get_build_status(self, session_id: str) -> Dict[str, Any]:
|
||||
"""Get build status for a session.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
|
||||
Returns:
|
||||
Build status dictionary
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ValidationService(Protocol):
|
||||
"""Interface for validation services."""
|
||||
|
||||
def validate_commander(self, name: str) -> tuple[bool, Optional[str]]:
|
||||
"""Validate commander name.
|
||||
|
||||
Args:
|
||||
name: Card name
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message) tuple
|
||||
"""
|
||||
...
|
||||
|
||||
def validate_themes(self, themes: List[str]) -> tuple[bool, List[str]]:
|
||||
"""Validate theme tags.
|
||||
|
||||
Args:
|
||||
themes: List of theme tags
|
||||
|
||||
Returns:
|
||||
(is_valid, invalid_themes) tuple
|
||||
"""
|
||||
...
|
||||
|
||||
def normalize_card_name(self, name: str) -> str:
|
||||
"""Normalize card name for lookups.
|
||||
|
||||
Args:
|
||||
name: Raw card name
|
||||
|
||||
Returns:
|
||||
Normalized card name
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TelemetryService(Protocol):
|
||||
"""Interface for telemetry/metrics services."""
|
||||
|
||||
def record_event(self, event_type: str, properties: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Record a telemetry event.
|
||||
|
||||
Args:
|
||||
event_type: Type of event
|
||||
properties: Event properties
|
||||
"""
|
||||
...
|
||||
|
||||
def record_timing(self, operation: str, duration_ms: float) -> None:
|
||||
"""Record operation timing.
|
||||
|
||||
Args:
|
||||
operation: Operation name
|
||||
duration_ms: Duration in milliseconds
|
||||
"""
|
||||
...
|
||||
|
||||
def increment_counter(self, counter_name: str, value: int = 1) -> None:
|
||||
"""Increment a counter.
|
||||
|
||||
Args:
|
||||
counter_name: Counter name
|
||||
value: Increment value
|
||||
"""
|
||||
...
|
||||
202
code/web/services/registry.py
Normal file
202
code/web/services/registry.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"""Service registry for dependency injection.
|
||||
|
||||
Provides a centralized registry for managing service instances and dependencies.
|
||||
Supports singleton and factory patterns with thread-safe access.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, Optional, Type, TypeVar, cast
|
||||
import threading
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ServiceRegistry:
|
||||
"""Thread-safe service registry for dependency injection.
|
||||
|
||||
Manages service instances and factories with support for:
|
||||
- Singleton services (one instance per registry)
|
||||
- Factory services (new instance per request)
|
||||
- Lazy initialization
|
||||
- Thread-safe access
|
||||
|
||||
Example:
|
||||
registry = ServiceRegistry()
|
||||
registry.register_singleton(SessionService, session_service_instance)
|
||||
registry.register_factory(BuildService, lambda: BuildService(deps...))
|
||||
|
||||
# Get services
|
||||
session_svc = registry.get(SessionService)
|
||||
build_svc = registry.get(BuildService)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize empty registry."""
|
||||
self._singletons: Dict[Type[Any], Any] = {}
|
||||
self._factories: Dict[Type[Any], Callable[[], Any]] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def register_singleton(self, service_type: Type[T], instance: T) -> None:
|
||||
"""Register a singleton service instance.
|
||||
|
||||
Args:
|
||||
service_type: Service type/interface
|
||||
instance: Service instance to register
|
||||
|
||||
Raises:
|
||||
ValueError: If service already registered
|
||||
"""
|
||||
with self._lock:
|
||||
if service_type in self._singletons or service_type in self._factories:
|
||||
raise ValueError(f"Service {service_type.__name__} already registered")
|
||||
self._singletons[service_type] = instance
|
||||
|
||||
def register_factory(self, service_type: Type[T], factory: Callable[[], T]) -> None:
|
||||
"""Register a factory for creating service instances.
|
||||
|
||||
Args:
|
||||
service_type: Service type/interface
|
||||
factory: Factory function that returns service instance
|
||||
|
||||
Raises:
|
||||
ValueError: If service already registered
|
||||
"""
|
||||
with self._lock:
|
||||
if service_type in self._singletons or service_type in self._factories:
|
||||
raise ValueError(f"Service {service_type.__name__} already registered")
|
||||
self._factories[service_type] = factory
|
||||
|
||||
def register_lazy_singleton(self, service_type: Type[T], factory: Callable[[], T]) -> None:
|
||||
"""Register a lazy-initialized singleton service.
|
||||
|
||||
The factory will be called once on first access, then the instance is cached.
|
||||
|
||||
Args:
|
||||
service_type: Service type/interface
|
||||
factory: Factory function that returns service instance
|
||||
|
||||
Raises:
|
||||
ValueError: If service already registered
|
||||
"""
|
||||
with self._lock:
|
||||
if service_type in self._singletons or service_type in self._factories:
|
||||
raise ValueError(f"Service {service_type.__name__} already registered")
|
||||
|
||||
# Wrap factory to cache result
|
||||
instance_cache: Dict[str, Any] = {}
|
||||
|
||||
def lazy_factory() -> T:
|
||||
if "instance" not in instance_cache:
|
||||
instance_cache["instance"] = factory()
|
||||
return instance_cache["instance"]
|
||||
|
||||
self._factories[service_type] = lazy_factory
|
||||
|
||||
def get(self, service_type: Type[T]) -> T:
|
||||
"""Get service instance.
|
||||
|
||||
Args:
|
||||
service_type: Service type/interface
|
||||
|
||||
Returns:
|
||||
Service instance
|
||||
|
||||
Raises:
|
||||
KeyError: If service not registered
|
||||
"""
|
||||
with self._lock:
|
||||
# Check singletons first
|
||||
if service_type in self._singletons:
|
||||
return cast(T, self._singletons[service_type])
|
||||
|
||||
# Check factories
|
||||
if service_type in self._factories:
|
||||
return cast(T, self._factories[service_type]())
|
||||
|
||||
raise KeyError(f"Service {service_type.__name__} not registered")
|
||||
|
||||
def try_get(self, service_type: Type[T]) -> Optional[T]:
|
||||
"""Try to get service instance, return None if not registered.
|
||||
|
||||
Args:
|
||||
service_type: Service type/interface
|
||||
|
||||
Returns:
|
||||
Service instance or None
|
||||
"""
|
||||
try:
|
||||
return self.get(service_type)
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def is_registered(self, service_type: Type[Any]) -> bool:
|
||||
"""Check if service is registered.
|
||||
|
||||
Args:
|
||||
service_type: Service type/interface
|
||||
|
||||
Returns:
|
||||
True if registered
|
||||
"""
|
||||
with self._lock:
|
||||
return service_type in self._singletons or service_type in self._factories
|
||||
|
||||
def unregister(self, service_type: Type[Any]) -> None:
|
||||
"""Unregister a service.
|
||||
|
||||
Args:
|
||||
service_type: Service type/interface
|
||||
"""
|
||||
with self._lock:
|
||||
self._singletons.pop(service_type, None)
|
||||
self._factories.pop(service_type, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all registered services."""
|
||||
with self._lock:
|
||||
self._singletons.clear()
|
||||
self._factories.clear()
|
||||
|
||||
def get_registered_types(self) -> list[Type[Any]]:
|
||||
"""Get list of all registered service types.
|
||||
|
||||
Returns:
|
||||
List of service types
|
||||
"""
|
||||
with self._lock:
|
||||
return list(self._singletons.keys()) + list(self._factories.keys())
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_global_registry: Optional[ServiceRegistry] = None
|
||||
_global_registry_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_registry() -> ServiceRegistry:
|
||||
"""Get the global service registry instance.
|
||||
|
||||
Creates registry on first access (lazy initialization).
|
||||
|
||||
Returns:
|
||||
Global ServiceRegistry instance
|
||||
"""
|
||||
global _global_registry
|
||||
|
||||
if _global_registry is None:
|
||||
with _global_registry_lock:
|
||||
if _global_registry is None:
|
||||
_global_registry = ServiceRegistry()
|
||||
|
||||
return _global_registry
|
||||
|
||||
|
||||
def reset_registry() -> None:
|
||||
"""Reset the global registry (primarily for testing).
|
||||
|
||||
Clears all registered services and creates a new registry instance.
|
||||
"""
|
||||
global _global_registry
|
||||
|
||||
with _global_registry_lock:
|
||||
_global_registry = ServiceRegistry()
|
||||
|
|
@ -4,45 +4,194 @@ import time
|
|||
import uuid
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
# Extremely simple in-memory session/task store for MVP
|
||||
_SESSIONS: Dict[str, Dict[str, Any]] = {}
|
||||
_TTL_SECONDS = 60 * 60 * 8 # 8 hours
|
||||
from .base import StateService
|
||||
from .interfaces import SessionService
|
||||
|
||||
|
||||
# Session TTL: 8 hours
|
||||
SESSION_TTL_SECONDS = 60 * 60 * 8
|
||||
|
||||
|
||||
class SessionManager(StateService):
|
||||
"""Session management service.
|
||||
|
||||
Manages user sessions with automatic TTL-based cleanup.
|
||||
Thread-safe with in-memory storage.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_seconds: int = SESSION_TTL_SECONDS) -> None:
|
||||
"""Initialize session manager.
|
||||
|
||||
Args:
|
||||
ttl_seconds: Session time-to-live in seconds
|
||||
"""
|
||||
super().__init__()
|
||||
self._ttl_seconds = ttl_seconds
|
||||
|
||||
def new_session_id(self) -> str:
|
||||
"""Create a new session ID.
|
||||
|
||||
Returns:
|
||||
Unique session identifier
|
||||
"""
|
||||
return uuid.uuid4().hex
|
||||
|
||||
def touch_session(self, session_id: str) -> Dict[str, Any]:
|
||||
"""Update session last access time.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
|
||||
Returns:
|
||||
Session state dictionary
|
||||
"""
|
||||
now = time.time()
|
||||
state = self.get_state(session_id)
|
||||
state["updated"] = now
|
||||
return state
|
||||
|
||||
def get_session(self, session_id: Optional[str]) -> Dict[str, Any]:
|
||||
"""Get or create session state.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier (creates new if None)
|
||||
|
||||
Returns:
|
||||
Session state dictionary
|
||||
"""
|
||||
if not session_id:
|
||||
session_id = self.new_session_id()
|
||||
return self.touch_session(session_id)
|
||||
|
||||
def set_value(self, session_id: str, key: str, value: Any) -> None:
|
||||
"""Set a value in session state.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
key: State key
|
||||
value: Value to store
|
||||
"""
|
||||
self.touch_session(session_id)[key] = value
|
||||
|
||||
def get_value(self, session_id: str, key: str, default: Any = None) -> Any:
|
||||
"""Get a value from session state.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
key: State key
|
||||
default: Default value if key not found
|
||||
|
||||
Returns:
|
||||
Stored value or default
|
||||
"""
|
||||
return self.touch_session(session_id).get(key, default)
|
||||
|
||||
def _initialize_state(self, key: str) -> Dict[str, Any]:
|
||||
"""Initialize state for a new session.
|
||||
|
||||
Args:
|
||||
key: Session ID
|
||||
|
||||
Returns:
|
||||
Initial session state
|
||||
"""
|
||||
now = time.time()
|
||||
return {"created": now, "updated": now}
|
||||
|
||||
def _should_cleanup(self, key: str, state: Dict[str, Any]) -> bool:
|
||||
"""Check if session should be cleaned up.
|
||||
|
||||
Args:
|
||||
key: Session ID
|
||||
state: Session state
|
||||
|
||||
Returns:
|
||||
True if session is expired
|
||||
"""
|
||||
now = time.time()
|
||||
updated = state.get("updated", 0)
|
||||
return (now - updated) > self._ttl_seconds
|
||||
|
||||
|
||||
# Global session manager instance
|
||||
_session_manager: Optional[SessionManager] = None
|
||||
|
||||
|
||||
def _get_manager() -> SessionManager:
|
||||
"""Get or create global session manager instance.
|
||||
|
||||
Returns:
|
||||
SessionManager instance
|
||||
"""
|
||||
global _session_manager
|
||||
if _session_manager is None:
|
||||
_session_manager = SessionManager()
|
||||
return _session_manager
|
||||
|
||||
|
||||
# Backward-compatible function API
|
||||
def new_sid() -> str:
|
||||
return uuid.uuid4().hex
|
||||
"""Create a new session ID.
|
||||
|
||||
Returns:
|
||||
Unique session identifier
|
||||
"""
|
||||
return _get_manager().new_session_id()
|
||||
|
||||
|
||||
def touch_session(sid: str) -> Dict[str, Any]:
|
||||
now = time.time()
|
||||
s = _SESSIONS.get(sid)
|
||||
if not s:
|
||||
s = {"created": now, "updated": now}
|
||||
_SESSIONS[sid] = s
|
||||
else:
|
||||
s["updated"] = now
|
||||
return s
|
||||
"""Update session last access time.
|
||||
|
||||
Args:
|
||||
sid: Session identifier
|
||||
|
||||
Returns:
|
||||
Session state dictionary
|
||||
"""
|
||||
return _get_manager().touch_session(sid)
|
||||
|
||||
|
||||
def get_session(sid: Optional[str]) -> Dict[str, Any]:
|
||||
if not sid:
|
||||
sid = new_sid()
|
||||
return touch_session(sid)
|
||||
"""Get or create session state.
|
||||
|
||||
Args:
|
||||
sid: Session identifier (creates new if None)
|
||||
|
||||
Returns:
|
||||
Session state dictionary
|
||||
"""
|
||||
return _get_manager().get_session(sid)
|
||||
|
||||
|
||||
def set_session_value(sid: str, key: str, value: Any) -> None:
|
||||
touch_session(sid)[key] = value
|
||||
"""Set a value in session state.
|
||||
|
||||
Args:
|
||||
sid: Session identifier
|
||||
key: State key
|
||||
value: Value to store
|
||||
"""
|
||||
_get_manager().set_value(sid, key, value)
|
||||
|
||||
|
||||
def get_session_value(sid: str, key: str, default: Any = None) -> Any:
|
||||
return touch_session(sid).get(key, default)
|
||||
"""Get a value from session state.
|
||||
|
||||
Args:
|
||||
sid: Session identifier
|
||||
key: State key
|
||||
default: Default value if key not found
|
||||
|
||||
Returns:
|
||||
Stored value or default
|
||||
"""
|
||||
return _get_manager().get_value(sid, key, default)
|
||||
|
||||
|
||||
def cleanup_expired() -> None:
|
||||
now = time.time()
|
||||
expired = [sid for sid, s in _SESSIONS.items() if now - s.get("updated", 0) > _TTL_SECONDS]
|
||||
for sid in expired:
|
||||
try:
|
||||
del _SESSIONS[sid]
|
||||
except Exception:
|
||||
pass
|
||||
def cleanup_expired() -> int:
|
||||
"""Clean up expired sessions.
|
||||
|
||||
Returns:
|
||||
Number of sessions cleaned up
|
||||
"""
|
||||
return _get_manager().cleanup_state()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue