Source code for gamesheet_sdk.teams.seasons

# Copyright (c) 2026 bdperkin
# SPDX-License-Identifier: MIT

"""Seasons data from the teams API.

The ``GET /api/seasons`` endpoint returns season metadata, configurations, penalty codes, and assigned teams
for the authenticated user.
"""

from __future__ import annotations

from http import HTTPStatus
from typing import TYPE_CHECKING, Any

from pydantic import BaseModel, ConfigDict, Field

from gamesheet_sdk.common.auth.constants import DEFAULT_TIMEOUT_S
from gamesheet_sdk.common.exceptions import AuthenticationError, GameSheetError
from gamesheet_sdk.teams.shared.constants import TEAMS_API_GATEWAY, TEAMS_SEASONS_PATH

if TYPE_CHECKING:
    from gamesheet_sdk.common.auth.session import BaseAuthenticatedSession


[docs] class SeasonSummary(BaseModel): """Summary of a season for list views. Attributes: association_id (str): Parent association identifier. association_title (str): Parent association display name. id (str): Season identifier. league_id (str): League ID from nested league object. league_title (str): League display name. leagueId (str): Parent league identifier. stats_year (str): Statistics year label. title (str): Display name of the season. """ model_config = ConfigDict(extra="allow") association_id: str = Field(default="", description="Parent association identifier.") association_title: str = Field(default="", description="Parent association display name.") id: str = Field(description="Season identifier.") league_id: str = Field(default="", description="League ID from league object.") league_title: str = Field(default="", description="League display name.") leagueId: str = Field(default="", description="Parent league identifier.") # noqa: N815 stats_year: str = Field(default="", description="Statistics year label.") title: str = Field(description="Display name of the season.")
[docs] class SeasonDetail(BaseModel): """Detailed season metadata with penaltyCodes and teams excluded. Attributes: id (str | int | None): Season identifier. title (str | None): Display name of the season. stats_year (str | int | None): Statistics year label. leagueId (str | int | None): Parent league identifier. """ model_config = ConfigDict(extra="allow") id: str | int | None = Field(default=None, description="Season identifier.") title: str | None = Field(default=None, description="Display name of the season.") stats_year: str | int | None = Field(default=None, description="Statistics year label.") leagueId: str | int | None = Field(default=None, description="Parent league identifier.") # noqa: N815
[docs] class PenaltyCode(BaseModel): """A penalty code configured for a season. Attributes: code (str): Penalty code identifier. name (str): Penalty name or description. """ model_config = ConfigDict(extra="allow") code: str = Field(default="", description="Penalty code identifier.") name: str = Field(default="", description="Penalty name or description.")
[docs] class SeasonTeam(BaseModel): """A team participating in a season. Attributes: id (str): Team identifier. title (str): Team display name. """ model_config = ConfigDict(extra="allow") id: str = Field(default="", description="Team identifier.") title: str = Field(default="", description="Team display name.")
def _parse_season_summary(raw: dict[str, Any]) -> SeasonSummary: """Parse raw season dictionary into a :class:`SeasonSummary`. Args: raw (dict[str, Any]): Raw season dictionary. Returns: SeasonSummary: Parsed season summary model instance. """ assoc = raw.get("association") or {} league = raw.get("league") or {} assoc_id = assoc.get("id") if isinstance(assoc, dict) else "" assoc_title = assoc.get("title", "") if isinstance(assoc, dict) else "" league_id = league.get("id") if isinstance(league, dict) else "" league_title = league.get("title", "") if isinstance(league, dict) else "" league_id_attr = raw.get("leagueId") return SeasonSummary( association_id=str(assoc_id) if assoc_id is not None and assoc_id else "", association_title=str(assoc_title) if assoc_title is not None else "", id=str(raw.get("id", "")), league_id=str(league_id) if league_id is not None and league_id else "", league_title=str(league_title) if league_title is not None else "", leagueId=str(league_id_attr) if league_id_attr is not None and league_id_attr else "", stats_year=str(raw.get("stats_year", "") or ""), title=str(raw.get("title", "") or ""), )
[docs] def fetch_seasons_raw( session: BaseAuthenticatedSession, *, timeout: float = DEFAULT_TIMEOUT_S, ) -> list[dict[str, Any]]: """Fetch raw seasons data from the teams API gateway. Args: session (BaseAuthenticatedSession): Authenticated HTTP session. timeout (float): HTTP request timeout in seconds. Returns: list[dict[str, Any]]: List of raw season dictionaries from the API response. Raises: AuthenticationError: If the server returns a 401 Unauthorized status. GameSheetError: If the server returns any other non-2xx status code. """ url = f"{TEAMS_API_GATEWAY}{TEAMS_SEASONS_PATH}" response = session.get(url, timeout=timeout) if response.status_code == HTTPStatus.UNAUTHORIZED: msg = "Authentication required: token is invalid or expired. Run `gamesheet-teams login`." raise AuthenticationError(msg) if response.status_code >= HTTPStatus.BAD_REQUEST: msg = f"GET {TEAMS_SEASONS_PATH} returned HTTP {response.status_code}: {response.text}" raise GameSheetError(msg) body = response.json() if isinstance(body, dict): if "seasons" in body and isinstance(body["seasons"], list): return body["seasons"] data = body.get("data") if isinstance(data, dict) and "seasons" in data and isinstance(data["seasons"], list): return data["seasons"] if isinstance(data, list): return data elif isinstance(body, list): return body return []
def _find_season(seasons: list[dict[str, Any]], season_id: str | int) -> dict[str, Any]: """Find a season by ID within the seasons list. Args: seasons (list[dict[str, Any]]): List of season dicts. season_id (str | int): Season identifier to match. Returns: dict[str, Any]: Matching season dictionary. Raises: GameSheetError: If the season is not found. """ target_id = str(season_id) for s in seasons: if str(s.get("id", "")) == target_id: return s msg = f"Season '{season_id}' not found." raise GameSheetError(msg)
[docs] def list_seasons( session: BaseAuthenticatedSession, *, timeout: float = DEFAULT_TIMEOUT_S, ) -> list[SeasonSummary]: """Fetch and summarize all seasons available to the authenticated user. Args: session (BaseAuthenticatedSession): Authenticated HTTP session. timeout (float): HTTP request timeout in seconds. Returns: list[SeasonSummary]: List of :class:`SeasonSummary` objects. """ raw_seasons = fetch_seasons_raw(session, timeout=timeout) return [_parse_season_summary(item) for item in raw_seasons]
[docs] def get_season( session: BaseAuthenticatedSession, season_id: str | int, *, timeout: float = DEFAULT_TIMEOUT_S, ) -> SeasonDetail: """Retrieve detailed information for a specific season, excluding penaltyCodes and teams. Args: session (BaseAuthenticatedSession): Authenticated HTTP session. season_id (str | int): Identifier of the season to retrieve. timeout (float): HTTP request timeout in seconds. Returns: SeasonDetail: :class:`SeasonDetail` with season attributes (excluding penaltyCodes and teams). """ raw_seasons = fetch_seasons_raw(session, timeout=timeout) season = _find_season(raw_seasons, season_id) filtered = {k: v for k, v in season.items() if k not in {"penaltyCodes", "teams"}} if "id" in filtered: filtered["id"] = str(filtered["id"]) if filtered["id"] is not None else "" if "leagueId" in filtered: filtered["leagueId"] = str(filtered["leagueId"]) if filtered["leagueId"] is not None else "" return SeasonDetail(**filtered)
[docs] class SeasonOwnership(BaseModel): """The association and league a season belongs to. ``POST /api/schedule-game`` wants ``association_id`` and ``league_id`` alongside ``season_id``, but they are wholly determined by the season, so the games commands derive them from ``GET /api/seasons`` rather than asking for them on the command line. Attributes: association_id (str): Parent association identifier, or ``'0'`` when the season does not name one. league_id (str): Parent league identifier, or ``'0'`` when the season does not name one. """ model_config = ConfigDict(extra="forbid") association_id: str = Field(default="0", description="Parent association identifier.") league_id: str = Field(default="0", description="Parent league identifier.")
def _nested_id(season: dict[str, Any], key: str) -> str: """Read ``season[key]['id']`` defensively. Args: season (dict[str, Any]): Raw season dictionary. key (str): Nested object key, ``'association'`` or ``'league'``. Returns: str: The identifier as a string, or ``''`` when absent. """ nested = season.get(key) if not isinstance(nested, dict): return "" ident = nested.get("id") return str(ident) if ident else ""
[docs] def get_season_ownership( session: BaseAuthenticatedSession, season_id: str | int, *, timeout: float = DEFAULT_TIMEOUT_S, ) -> SeasonOwnership: """Derive a season's association and league identifiers. Args: session (BaseAuthenticatedSession): Authenticated HTTP session. season_id (str | int): Identifier of the season. timeout (float): HTTP request timeout in seconds. Returns: SeasonOwnership: The association and league the season belongs to. """ raw_seasons = fetch_seasons_raw(session, timeout=timeout) season = _find_season(raw_seasons, season_id) league_id = _nested_id(season, "league") or str(season.get("leagueId") or "") return SeasonOwnership( association_id=_nested_id(season, "association") or "0", league_id=league_id or "0", )
[docs] def get_season_penalty_codes( session: BaseAuthenticatedSession, season_id: str | int, *, timeout: float = DEFAULT_TIMEOUT_S, ) -> list[PenaltyCode]: """Retrieve all penalty codes configured for a specific season. Args: session (BaseAuthenticatedSession): Authenticated HTTP session. season_id (str | int): Identifier of the season. timeout (float): HTTP request timeout in seconds. Returns: list[PenaltyCode]: List of :class:`PenaltyCode` objects for the season. """ raw_seasons = fetch_seasons_raw(session, timeout=timeout) season = _find_season(raw_seasons, season_id) penalty_codes = season.get("penaltyCodes", []) if not isinstance(penalty_codes, list): return [] result: list[PenaltyCode] = [] for item in penalty_codes: if isinstance(item, dict): item_dict = dict(item) if "code" in item_dict and item_dict["code"] is not None: item_dict["code"] = str(item_dict["code"]) result.append(PenaltyCode(**item_dict)) else: result.append(PenaltyCode(code=str(item))) return result
[docs] def get_season_teams( session: BaseAuthenticatedSession, season_id: str | int, *, timeout: float = DEFAULT_TIMEOUT_S, ) -> list[SeasonTeam]: """Retrieve all teams participating in a specific season. Args: session (BaseAuthenticatedSession): Authenticated HTTP session. season_id (str | int): Identifier of the season. timeout (float): HTTP request timeout in seconds. Returns: list[SeasonTeam]: List of :class:`SeasonTeam` objects for the season. """ raw_seasons = fetch_seasons_raw(session, timeout=timeout) season = _find_season(raw_seasons, season_id) teams = season.get("teams", []) if not isinstance(teams, list): return [] result: list[SeasonTeam] = [] for item in teams: if isinstance(item, dict): item_dict = dict(item) if "id" in item_dict and item_dict["id"] is not None: item_dict["id"] = str(item_dict["id"]) result.append(SeasonTeam(**item_dict)) else: result.append(SeasonTeam(id=str(item))) return result