""" League API client for syncing data. Provides async HTTP client for the SBA Major Domo API. """ import logging from typing import Any, Optional import httpx from ..config import get_settings logger = logging.getLogger(__name__) class LeagueAPIError(Exception): """Raised when the league API returns an error.""" def __init__(self, message: str, status_code: Optional[int] = None): self.message = message self.status_code = status_code super().__init__(message) class LeagueAPIClient: """ Async client for the SBA League API. Usage: async with LeagueAPIClient() as client: players = await client.get_players(season=13) """ def __init__( self, base_url: Optional[str] = None, api_key: Optional[str] = None, timeout: int = 30, ): """ Initialize the API client. Args: base_url: API base URL (uses settings if not provided) api_key: API key for authentication (uses settings if not provided) timeout: Request timeout in seconds """ settings = get_settings() self.base_url = (base_url or settings.api.base_url).rstrip("/") self.api_key = api_key or settings.api.api_key self.timeout = timeout self._client: Optional[httpx.AsyncClient] = None async def __aenter__(self): """Enter async context manager.""" self._client = httpx.AsyncClient( base_url=self.base_url, timeout=self.timeout, headers=self._get_headers(), ) return self async def __aexit__(self, exc_type, exc_val, exc_tb): """Exit async context manager.""" if self._client: await self._client.aclose() self._client = None def _get_headers(self) -> dict[str, str]: """Get request headers including auth if configured.""" headers = {"Accept": "application/json"} if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" return headers async def _request( self, method: str, endpoint: str, params: Optional[dict] = None, json: Optional[dict] = None, ) -> Any: """ Make an API request. Args: method: HTTP method endpoint: API endpoint (without base URL) params: Query parameters json: JSON body for POST/PUT Returns: Parsed JSON response Raises: LeagueAPIError: If request fails """ if not self._client: raise LeagueAPIError("Client not initialized. Use 'async with' context manager.") url = f"/api/v3{endpoint}" logger.debug(f"API request: {method} {url} params={params}") try: response = await self._client.request( method=method, url=url, params=params, json=json, ) response.raise_for_status() return response.json() except httpx.HTTPStatusError as e: # Check if this is a Cloudflare HTML error page response_text = e.response.text if "cloudflare" in response_text.lower() and e.response.status_code == 403: error_msg = "API blocked by Cloudflare (missing or invalid API key)" logger.error(f"API error: {e.response.status_code} - Cloudflare block") else: error_msg = f"API request failed (HTTP {e.response.status_code})" logger.error(f"API error: {e.response.status_code} - {response_text[:200]}") raise LeagueAPIError(error_msg, status_code=e.response.status_code) except httpx.RequestError as e: logger.error(f"Request error: {e}") raise LeagueAPIError(f"Request failed: {str(e)}") # ========================================================================= # Players # ========================================================================= async def get_players( self, season: int, team_id: Optional[list[int]] = None, pos: Optional[list[str]] = None, name: Optional[str] = None, short_output: bool = False, ) -> dict: """ Get players for a season. Args: season: Season number team_id: Filter by team IDs pos: Filter by positions name: Filter by exact name short_output: If True, exclude nested relations Returns: Dict with 'count' and 'players' keys """ params: dict[str, Any] = {"season": season, "short_output": short_output} if team_id: params["team_id"] = team_id if pos: params["pos"] = pos if name: params["name"] = name return await self._request("GET", "/players", params=params) async def get_player(self, player_id: int, short_output: bool = False) -> Optional[dict]: """Get a single player by ID.""" params = {"short_output": short_output} return await self._request("GET", f"/players/{player_id}", params=params) async def search_players( self, query: str, season: Optional[int] = None, limit: int = 10, ) -> dict: """ Search players by name. Args: query: Search string season: Limit to specific season (0 or None for all) limit: Maximum results Returns: Dict with search results """ params: dict[str, Any] = {"q": query, "limit": limit} if season: params["season"] = season return await self._request("GET", "/players/search", params=params) # ========================================================================= # Teams # ========================================================================= async def get_teams( self, season: Optional[int] = None, team_abbrev: Optional[list[str]] = None, active_only: bool = False, short_output: bool = False, ) -> dict: """ Get teams. Args: season: Filter by season team_abbrev: Filter by abbreviations active_only: Exclude IL and MiL teams short_output: Exclude nested relations Returns: Dict with 'count' and 'teams' keys """ params: dict[str, Any] = { "active_only": active_only, "short_output": short_output, } if season: params["season"] = season if team_abbrev: params["team_abbrev"] = team_abbrev return await self._request("GET", "/teams", params=params) async def get_team(self, team_id: int) -> Optional[dict]: """Get a single team by ID.""" return await self._request("GET", f"/teams/{team_id}") async def get_team_roster( self, team_id: int, which: str = "current", ) -> dict: """ Get team roster. Args: team_id: Team ID which: "current" or "next" week Returns: Dict with 'active', 'shortil', 'longil' rosters """ return await self._request("GET", f"/teams/{team_id}/roster/{which}") # ========================================================================= # Transactions # ========================================================================= async def get_transactions( self, season: int, team_abbrev: Optional[list[str]] = None, week_start: int = 0, week_end: Optional[int] = None, cancelled: Optional[bool] = False, frozen: Optional[bool] = False, short_output: bool = True, ) -> dict: """ Get transactions. Args: season: Season number team_abbrev: Filter by team abbreviations week_start: Start week week_end: End week cancelled: Include cancelled transactions frozen: Include frozen transactions short_output: Exclude nested relations Returns: Dict with 'count' and 'transactions' keys """ params: dict[str, Any] = { "season": season, "week_start": week_start, "cancelled": cancelled, "frozen": frozen, "short_output": short_output, } if team_abbrev: params["team_abbrev"] = team_abbrev if week_end: params["week_end"] = week_end return await self._request("GET", "/transactions", params=params) # ========================================================================= # Current Season Info # ========================================================================= async def get_current(self) -> dict: """Get current season/week info.""" return await self._request("GET", "/current") # ========================================================================= # Schedules # ========================================================================= async def get_schedule( self, season: int, week: Optional[int] = None, team_id: Optional[int] = None, ) -> dict: """ Get schedule. Args: season: Season number week: Specific week team_id: Filter by team Returns: Schedule data """ params: dict[str, Any] = {"season": season} if week: params["week"] = week if team_id: params["team_id"] = team_id return await self._request("GET", "/schedules", params=params) # ========================================================================= # Standings # ========================================================================= async def get_standings(self, season: int) -> dict: """Get standings for a season.""" params = {"season": season} return await self._request("GET", "/standings", params=params)