import json import logging import os import time from pathlib import Path from django.conf import settings from apps.providers.contracts import ( CompetitionPayload, NormalizedSyncPayload, PlayerCareerPayload, PlayerPayload, PlayerStatsPayload, SeasonPayload, TeamPayload, ) from apps.providers.exceptions import ProviderRateLimitError, ProviderTransientError from apps.providers.interfaces import BaseProviderAdapter logger = logging.getLogger(__name__) class MvpDemoProviderAdapter(BaseProviderAdapter): """MVP provider backed by a local JSON payload for deterministic development syncs.""" namespace = "mvp_demo" def __init__(self): default_path = Path(settings.BASE_DIR) / "apps" / "providers" / "data" / "mvp_provider.json" self.data_file = Path(os.getenv("PROVIDER_MVP_DATA_FILE", str(default_path))) self.max_retries = int(os.getenv("PROVIDER_REQUEST_RETRIES", "3")) self.retry_sleep_seconds = float(os.getenv("PROVIDER_REQUEST_RETRY_SLEEP", "1")) def _load_payload(self) -> dict: for attempt in range(1, self.max_retries + 1): try: if os.getenv("PROVIDER_MVP_FORCE_RATE_LIMIT", "0") == "1": raise ProviderRateLimitError("Simulated provider rate limit", retry_after_seconds=15) with self.data_file.open("r", encoding="utf-8") as handle: return json.load(handle) except ProviderRateLimitError: raise except FileNotFoundError as exc: logger.exception("Provider data file not found: %s", self.data_file) raise ProviderTransientError(str(exc)) from exc except json.JSONDecodeError as exc: logger.exception("Invalid provider payload JSON in %s", self.data_file) raise ProviderTransientError(str(exc)) from exc except OSError as exc: if attempt >= self.max_retries: logger.exception("Provider payload read failed after retries") raise ProviderTransientError(str(exc)) from exc time.sleep(self.retry_sleep_seconds * attempt) raise ProviderTransientError("Unable to read provider payload") def _payload_list(self, key: str) -> list[dict]: payload = self._load_payload() value = payload.get(key, []) return value if isinstance(value, list) else [] def search_players(self, *, query: str = "", limit: int = 50, offset: int = 0) -> list[PlayerPayload]: players = self.fetch_players() if query: query_lower = query.lower() players = [p for p in players if query_lower in p.get("full_name", "").lower()] return players[offset : offset + limit] def fetch_player(self, *, external_player_id: str) -> PlayerPayload | None: for payload in self.fetch_players(): if payload.get("external_id") == external_player_id: return payload return None def fetch_players(self) -> list[PlayerPayload]: return self._payload_list("players") # type: ignore[return-value] def fetch_competitions(self) -> list[CompetitionPayload]: return self._payload_list("competitions") # type: ignore[return-value] def fetch_teams(self) -> list[TeamPayload]: return self._payload_list("teams") # type: ignore[return-value] def fetch_seasons(self) -> list[SeasonPayload]: return self._payload_list("seasons") # type: ignore[return-value] def fetch_player_stats(self) -> list[PlayerStatsPayload]: return self._payload_list("player_stats") # type: ignore[return-value] def fetch_player_careers(self) -> list[PlayerCareerPayload]: return self._payload_list("player_careers") # type: ignore[return-value] def sync_all(self) -> NormalizedSyncPayload: return { "players": self.fetch_players(), "competitions": self.fetch_competitions(), "teams": self.fetch_teams(), "seasons": self.fetch_seasons(), "player_stats": self.fetch_player_stats(), "player_careers": self.fetch_player_careers(), "cursor": None, } def sync_incremental(self, *, cursor: str | None = None) -> NormalizedSyncPayload: payload = self.sync_all() # MVP source has no change feed yet; returns full snapshot. payload["cursor"] = cursor return payload