""" SBA API client for fetching player and team data. Integrates with SBA REST API to retrieve player and team information for use in game lineup display and game creation. Author: Claude Date: 2025-01-10 """ import logging from typing import Any import httpx import pendulum from app.config import get_settings from app.models.player_models import SbaPlayer logger = logging.getLogger(f"{__name__}.SbaApiClient") settings = get_settings() # Module-level team cache (team_id -> team_data) _team_cache: dict[int, dict[str, Any]] = {} _team_cache_expiry: pendulum.DateTime | None = None _CACHE_TTL_HOURS = 1 class SbaApiClient: """Client for SBA API player and team data lookups.""" def __init__( self, base_url: str = "https://api.sba.manticorum.com", api_key: str | None = None, ): """ Initialize SBA API client. Args: base_url: Base URL for SBA API (default: production) api_key: Bearer token for API authentication """ self.base_url = base_url self.api_key = api_key or settings.sba_api_key self.timeout = httpx.Timeout(10.0, connect=5.0) def _get_headers(self) -> dict[str, str]: """Get headers with auth token.""" headers = {} if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" return headers async def get_teams(self, season: int, active_only: bool = True) -> list[dict[str, Any]]: """ Fetch teams from SBA API for a specific season. Args: season: Season number (e.g., 3 for Season 3) active_only: If True, filter out IL teams and teams without divisions Returns: List of team dictionaries with id, name, abbreviation, etc. Example: teams = await client.get_teams(season=3) for team in teams: print(f"{team['abbrev']}: {team['lname']}") """ url = f"{self.base_url}/teams" params = {"season": season} try: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.get( url, headers=self._get_headers(), params=params ) response.raise_for_status() data = response.json() teams = data.get("teams", []) if active_only: # Filter out injured list teams (IL suffix) and teams without divisions teams = [ t for t in teams if not t["abbrev"].endswith("IL") and t.get("division") ] logger.info(f"Loaded {len(teams)} teams for season {season}") return teams except httpx.HTTPError as e: logger.error(f"Failed to fetch teams: {e}") raise except Exception as e: logger.error(f"Unexpected error fetching teams: {e}") raise async def get_teams_by_owner(self, discord_id: str, season: int) -> list[dict[str, Any]]: """ Fetch teams owned by a specific Discord user. Args: discord_id: Discord user ID (gmid or gmid2) season: Season number Returns: List of team dictionaries owned by this user Example: teams = await client.get_teams_by_owner("485217045408120833", season=13) """ url = f"{self.base_url}/teams" params = {"season": season, "owner_id": discord_id} try: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.get( url, headers=self._get_headers(), params=params ) response.raise_for_status() data = response.json() teams = data.get("teams", []) logger.info(f"Found {len(teams)} teams for owner {discord_id[:8]}...") return teams except httpx.HTTPError as e: logger.error(f"Failed to fetch teams for owner {discord_id[:8]}...: {e}") raise except Exception as e: logger.error(f"Unexpected error fetching teams by owner: {e}") raise async def get_team_by_id(self, team_id: int, season: int = 3) -> dict[str, Any] | None: """ Get a single team by ID, using cache when available. Args: team_id: Team ID to look up season: Season number (default: 3) Returns: Team dictionary with id, lname, abbrev, etc. or None if not found Example: team = await client.get_team_by_id(35) print(team['lname']) # "Cardinals" """ global _team_cache, _team_cache_expiry now = pendulum.now("UTC") # Check if cache is valid if _team_cache_expiry is None or now > _team_cache_expiry: # Refresh cache try: teams = await self.get_teams(season=season, active_only=False) _team_cache = {t["id"]: t for t in teams} _team_cache_expiry = now.add(hours=_CACHE_TTL_HOURS) logger.info(f"Refreshed team cache with {len(_team_cache)} teams") except Exception as e: logger.warning(f"Failed to refresh team cache: {e}") # Continue with stale cache if available return _team_cache.get(team_id) async def get_teams_by_ids(self, team_ids: list[int], season: int = 3) -> dict[int, dict[str, Any]]: """ Get multiple teams by ID, using cache. Args: team_ids: List of team IDs to look up season: Season number (default: 3) Returns: Dictionary mapping team_id to team data """ result = {} for team_id in team_ids: team = await self.get_team_by_id(team_id, season) if team: result[team_id] = team else: logger.warning(f"Team {team_id} not found in cache for season {season}") # Log if we couldn't find all requested teams missing = [tid for tid in team_ids if tid not in result] if missing: logger.warning( f"get_teams_by_ids: {len(missing)}/{len(team_ids)} teams not found: {missing}" ) return result async def get_player(self, player_id: int) -> SbaPlayer: """ Fetch player data from SBA API. Args: player_id: SBA player ID Returns: SbaPlayer instance with all player data Raises: httpx.HTTPError: If API request fails Example: player = await client.get_player(12288) print(f"Name: {player.name}") # "Ronald Acuna Jr" print(f"Positions: {player.get_positions()}") # ['RF'] """ url = f"{self.base_url}/players/{player_id}" try: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.get(url, headers=self._get_headers()) response.raise_for_status() data = response.json() player = SbaPlayer.from_api_response(data) logger.info(f"Loaded player {player_id}: {player.name}") return player except httpx.HTTPError as e: logger.error(f"Failed to fetch player {player_id}: {e}") raise except Exception as e: logger.error(f"Unexpected error fetching player {player_id}: {e}") raise async def get_roster(self, team_id: int, season: int) -> list[dict[str, Any]]: """ Fetch roster for a specific team from SBA API. Args: team_id: Team ID season: Season number (e.g., 3 for Season 3) Returns: List of player dictionaries from the SBA API Example: roster = await client.get_roster(team_id=35, season=3) for player in roster: print(f"{player['name']}: {player['position']}") """ url = f"{self.base_url}/players" params = {"season": season, "team_id": team_id} try: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.get( url, headers=self._get_headers(), params=params ) response.raise_for_status() data = response.json() players = data.get("players", []) count = data.get("count", len(players)) logger.info(f"Loaded {count} players for team {team_id}, season {season}") return players except httpx.HTTPError as e: logger.error(f"Failed to fetch roster for team {team_id}: {e}") raise except Exception as e: logger.error(f"Unexpected error fetching roster: {e}") raise async def get_players_batch(self, player_ids: list[int]) -> dict[int, SbaPlayer]: """ Fetch multiple players in parallel. Args: player_ids: List of SBA player IDs to fetch Returns: Dictionary mapping player_id to SbaPlayer instance Players that fail to load will be omitted from the result Example: players = await client.get_players_batch([12288, 12289, 12290]) for player_id, player in players.items(): print(f"{player.name}: {player.get_positions()}") """ if not player_ids: return {} results: dict[int, SbaPlayer] = {} async with httpx.AsyncClient(timeout=self.timeout) as client: for player_id in player_ids: try: url = f"{self.base_url}/players/{player_id}" response = await client.get(url) response.raise_for_status() data = response.json() player = SbaPlayer.from_api_response(data) results[player_id] = player except httpx.HTTPError as e: logger.warning(f"Failed to fetch player {player_id}: {e}") # Continue with other players except Exception as e: logger.warning(f"Unexpected error fetching player {player_id}: {e}") # Continue with other players logger.info(f"Loaded {len(results)}/{len(player_ids)} players") return results async def get_current(self) -> dict[str, Any]: """ Get current season and week from SBA API. Returns: Dictionary with 'season' and 'week' keys Example: current = await client.get_current() print(f"Season {current['season']}, Week {current['week']}") """ url = f"{self.base_url}/current" try: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.get(url, headers=self._get_headers()) response.raise_for_status() data = response.json() logger.info(f"Current: Season {data.get('season')}, Week {data.get('week')}") return data except httpx.HTTPError as e: logger.error(f"Failed to fetch current season/week: {e}") raise except Exception as e: logger.error(f"Unexpected error fetching current: {e}") raise async def get_schedule_games( self, season: int, week_start: int, week_end: int ) -> list[dict[str, Any]]: """ Get scheduled games for a week range from SBA API. Args: season: Season number (e.g., 13) week_start: Starting week number week_end: Ending week number (inclusive) Returns: List of game dictionaries with team info Example: games = await client.get_schedule_games(season=13, week_start=12, week_end=12) for game in games: print(f"{game['away_abbrev']} @ {game['home_abbrev']}") """ url = f"{self.base_url}/games" params = {"season": season, "week_start": week_start, "week_end": week_end} try: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.get( url, headers=self._get_headers(), params=params ) response.raise_for_status() data = response.json() games = data.get("games", data) if isinstance(data, dict) else data logger.info( f"Loaded {len(games)} scheduled games for S{season} W{week_start}-{week_end}" ) return games except httpx.HTTPError as e: logger.error(f"Failed to fetch schedule games: {e}") raise except Exception as e: logger.error(f"Unexpected error fetching schedule games: {e}") raise # Singleton instance sba_api_client = SbaApiClient()