krz/nba-scores

A CLI/TUI app for NBA scores.

clone: git clone https://gitbay.org/krz/nba-scores.git

main: nba/bracket.py · raw

  1"""
  2Fetches and formats the NBA playoff bracket.
  3"""
  4
  5from __future__ import annotations
  6
  7import json
  8import re
  9from collections import defaultdict
 10
 11from nba_api.stats.endpoints.commonplayoffseries import CommonPlayoffSeries
 12from nba_api.stats.endpoints.leaguegamelog import LeagueGameLog
 13from nba_api.stats.static import teams
 14
 15BOLD = "\033[1m"
 16END = "\033[0m"
 17GREEN = "\033[32m"
 18RED = "\033[91m"
 19YELLOW = "\033[33m"
 20CYAN = "\033[36m"
 21ANSI_RE = re.compile(r"\033\[[0-9;]*m")
 22
 23ROUND_LABELS = {
 24    1: "First Round",
 25    2: "Conference Semifinals",
 26    3: "Conference Finals",
 27    4: "NBA Finals",
 28}
 29
 30
 31def fetch_bracket() -> dict:
 32    """
 33    Fetch playoff series and completed playoff games.
 34
 35    CommonPlayoffSeries provides the bracket's scheduled series and games. The
 36    playoff game log is used to compute each series record from completed games.
 37    """
 38    series_endpoint = CommonPlayoffSeries()
 39    game_log_endpoint = LeagueGameLog(season_type_all_star="Playoffs")
 40    return {
 41        "series": json.loads(series_endpoint.get_json()),
 42        "game_log": json.loads(game_log_endpoint.get_json()),
 43    }
 44
 45
 46def get_bracket_table(data: dict) -> str:
 47    """Return a formatted playoff bracket."""
 48    result_sets = data["series"]["resultSets"]
 49    series_set = next((r for r in result_sets if r["name"] == "PlayoffSeries"), None)
 50    if series_set is None or not series_set["rowSet"]:
 51        return "No playoff bracket data available."
 52
 53    headers = series_set["headers"]
 54    idx = {header: i for i, header in enumerate(headers)}
 55    team_map = _team_map()
 56    series_games = _group_series(series_set["rowSet"], idx)
 57    series_wins = _series_wins(data["game_log"], series_games)
 58    summaries = _series_summaries(team_map, series_games, series_wins)
 59
 60    return "\n".join(
 61        [
 62            _center(f"{BOLD}NBA Playoff Bracket{END}", 74),
 63            (
 64                f"{GREEN}* Advanced{END}  {RED}x Eliminated{END}  "
 65                f"{CYAN}> Series lead{END}  {YELLOW}! Can clinch next win{END}"
 66            ),
 67            "",
 68            _render_conference("Western", summaries),
 69            "",
 70            _render_finals(summaries),
 71            "",
 72            _render_conference("Eastern", summaries),
 73        ]
 74    )
 75
 76
 77def _team_map() -> dict:
 78    return {
 79        team["id"]: {
 80            "abbr": team["abbreviation"],
 81            "name": f"{team['city']} {team['nickname']}",
 82        }
 83        for team in teams.get_teams()
 84    }
 85
 86
 87def _group_series(rows: list, idx: dict) -> dict:
 88    series_games = defaultdict(list)
 89    for row in rows:
 90        series_id = row[idx["SERIES_ID"]]
 91        series_games[series_id].append(
 92            {
 93                "game_id": row[idx["GAME_ID"]],
 94                "game_num": row[idx["GAME_NUM"]],
 95                "home_team_id": row[idx["HOME_TEAM_ID"]],
 96                "visitor_team_id": row[idx["VISITOR_TEAM_ID"]],
 97            }
 98        )
 99
100    return {
101        series_id: sorted(games, key=lambda game: game["game_num"])
102        for series_id, games in series_games.items()
103    }
104
105
106def _series_wins(game_log: dict, series_games: dict) -> dict:
107    game_to_series = {
108        game["game_id"]: series_id
109        for series_id, games in series_games.items()
110        for game in games
111    }
112    wins = defaultdict(lambda: defaultdict(int))
113
114    for series_id in series_games:
115        wins[series_id]["completed_games"] = set()
116
117    result_set = game_log["resultSets"][0]
118    idx = {header: i for i, header in enumerate(result_set["headers"])}
119    for row in result_set["rowSet"]:
120        game_id = row[idx["GAME_ID"]]
121        series_id = game_to_series.get(game_id)
122        if series_id is None or row[idx["WL"]] != "W":
123            continue
124
125        team_id = row[idx["TEAM_ID"]]
126        wins[series_id][team_id] += 1
127        wins[series_id]["completed_games"].add(game_id)
128
129    return wins
130
131
132def _series_summaries(team_map: dict, series_games: dict, series_wins: dict) -> dict:
133    summaries = {}
134    for series_id, games in series_games.items():
135        first_game = games[0]
136        home_id = first_game["home_team_id"]
137        visitor_id = first_game["visitor_team_id"]
138        wins = series_wins[series_id]
139        home_wins = wins.get(home_id, 0)
140        visitor_wins = wins.get(visitor_id, 0)
141
142        summaries[series_id] = {
143            "conference": _conference(series_id),
144            "round": _round_number(series_id),
145            "slot": _series_slot(series_id),
146            "home_id": home_id,
147            "visitor_id": visitor_id,
148            "home_abbr": _abbr(team_map, home_id),
149            "visitor_abbr": _abbr(team_map, visitor_id),
150            "home_wins": home_wins,
151            "visitor_wins": visitor_wins,
152            "winner": _winner(team_map, home_id, visitor_id, home_wins, visitor_wins),
153        }
154    return summaries
155
156
157def _render_conference(conference: str, summaries: dict) -> str:
158    first_round = _conference_round(summaries, conference, 1)
159    semifinals = _conference_round(summaries, conference, 2)
160    finals = _conference_round(summaries, conference, 3)
161    final = finals[0] if finals else None
162
163    lines = [
164        f"{BOLD}{conference.upper()} CONFERENCE{END}",
165        "First Round                 Semifinals                 Conference Finals",
166    ]
167
168    lines.extend(
169        [
170            f"{_series_box(_slot(first_round, 0), 24)}",
171            f"{_blank(24)} ├── {_series_box(_slot(semifinals, 0), 24)}",
172            f"{_series_box(_slot(first_round, 1), 24)}{_blank(24)}",
173            f"{_blank(24)}     {_blank(24)} ├── {_series_box(final, 24)}",
174            f"{_series_box(_slot(first_round, 2), 24)}{_blank(24)}",
175            f"{_blank(24)} ├── {_series_box(_slot(semifinals, 1), 24)}",
176            f"{_series_box(_slot(first_round, 3), 24)}",
177        ]
178    )
179
180    return "\n".join(lines)
181
182
183def _render_finals(summaries: dict) -> str:
184    finals = [summary for summary in summaries.values() if summary["round"] == 4]
185    final = finals[0] if finals else None
186
187    lines = [
188        f"{BOLD}NBA FINALS{END}",
189        "West Champion              East Champion",
190        f"{_series_box(final, 24)}",
191    ]
192    if final is None:
193        lines.append("Winner TBD")
194    elif final["winner"]:
195        lines.append(f"{BOLD}{GREEN}{final['winner']} wins the Finals{END}")
196    return "\n".join(lines)
197
198
199def _conference_round(summaries: dict, conference: str, round_number: int) -> list:
200    return sorted(
201        [
202            summary
203            for summary in summaries.values()
204            if summary["conference"] == conference[:4]
205            and summary["round"] == round_number
206        ],
207        key=lambda summary: summary["slot"],
208    )
209
210
211def _slot(items: list, idx: int) -> dict | None:
212    return items[idx] if idx < len(items) else None
213
214
215def _series_box(summary: dict | None, width: int) -> str:
216    if summary is None:
217        return _blank(width, "TBD")
218
219    visitor = _team_line(
220        summary["visitor_abbr"],
221        summary["visitor_wins"],
222        summary["home_wins"],
223        summary["winner"] == summary["visitor_abbr"],
224        summary["winner"] == summary["home_abbr"],
225    )
226    home = _team_line(
227        summary["home_abbr"],
228        summary["home_wins"],
229        summary["visitor_wins"],
230        summary["winner"] == summary["home_abbr"],
231        summary["winner"] == summary["visitor_abbr"],
232    )
233    return _pad_ansi(f"{visitor} / {home}", width)
234
235
236def _team_line(
237    abbr: str,
238    wins: int,
239    opponent_wins: int,
240    won_series: bool,
241    lost_series: bool,
242) -> str:
243    label = f"{abbr} {wins}"
244    if won_series:
245        return f"{BOLD}{GREEN}{label}*{END}"
246    if lost_series:
247        return f"{RED}{label}x{END}"
248    if wins == 3:
249        return f"{BOLD}{YELLOW}{label}!{END}"
250    if wins > opponent_wins:
251        return f"{CYAN}{label}>{END}"
252    return label
253
254
255def _winner(
256    team_map: dict,
257    home_id: int,
258    visitor_id: int,
259    home_wins: int,
260    visitor_wins: int,
261) -> str:
262    if home_wins >= 4:
263        return _abbr(team_map, home_id)
264    if visitor_wins >= 4:
265        return _abbr(team_map, visitor_id)
266    return ""
267
268
269def _blank(width: int, text: str = "") -> str:
270    return text.ljust(width)
271
272
273def _center(text: str, width: int) -> str:
274    return text.center(width)
275
276
277def _pad_ansi(text: str, width: int) -> str:
278    visible_length = len(ANSI_RE.sub("", text))
279    return text + " " * max(width - visible_length, 0)
280
281
282def _round_number(series_id: str) -> int:
283    try:
284        return int(series_id[-3:-1])
285    except ValueError:
286        return 0
287
288
289def _series_slot(series_id: str) -> int:
290    try:
291        return int(series_id[-1])
292    except ValueError:
293        return 0
294
295
296def _conference(series_id: str) -> str:
297    round_number = _round_number(series_id)
298    slot = _series_slot(series_id)
299
300    if round_number == 4:
301        return "NBA"
302    if round_number == 3:
303        return "East" if slot == 0 else "West"
304    if round_number == 2:
305        return "East" if slot <= 1 else "West"
306    if round_number == 1:
307        return "East" if slot <= 3 else "West"
308    return ""
309
310
311def _abbr(team_map: dict, team_id: int) -> str:
312    return team_map.get(team_id, {}).get("abbr", str(team_id))