Commit 14d8039e4a
Verified · cmc
Layout: unified · split
README.md +1
| @@ -54,6 +54,7 @@ Optional flags: | |||
| 54 | | Standings | `t` | East / West conference standings, side by side | | 54 | | Standings | `t` | East / West conference standings, side by side | |
| 55 | | Leaders | `l` | Top 25 players by stat category | | 55 | | Leaders | `l` | Top 25 players by stat category | |
| 56 | | Playoff Picture | `p` | Conference seeding with clinch and elimination status | | 56 | | Playoff Picture | `p` | Conference seeding with clinch and elimination status | |
| 57 | | Bracket | `k` | Playoff series matchups and series records | | ||
| 57 | | Box Score | `b` | Per-player live stats for a selected game | | 58 | | Box Score | `b` | Per-player live stats for a selected game | |
| 58 | 59 | ||
| 59 | ### TUI key bindings | 60 | ### TUI key bindings |
build/lib/nba/bracket.py added +281
| @@ -0,0 +1,281 @@ | |||
| 1 | """ | ||
| 2 | Fetches and formats the NBA playoff bracket. | ||
| 3 | """ | ||
| 4 | |||
| 5 | import json | ||
| 6 | from collections import defaultdict | ||
| 7 | from typing import Optional | ||
| 8 | |||
| 9 | from nba_api.stats.endpoints.commonplayoffseries import CommonPlayoffSeries | ||
| 10 | from nba_api.stats.endpoints.leaguegamelog import LeagueGameLog | ||
| 11 | from nba_api.stats.static import teams | ||
| 12 | |||
| 13 | BOLD = "\033[1m" | ||
| 14 | END = "\033[0m" | ||
| 15 | GREEN = "\033[32m" | ||
| 16 | |||
| 17 | ROUND_LABELS = { | ||
| 18 | 1: "First Round", | ||
| 19 | 2: "Conference Semifinals", | ||
| 20 | 3: "Conference Finals", | ||
| 21 | 4: "NBA Finals", | ||
| 22 | } | ||
| 23 | |||
| 24 | |||
| 25 | def fetch_bracket() -> dict: | ||
| 26 | """ | ||
| 27 | Fetch playoff series and completed playoff games. | ||
| 28 | |||
| 29 | CommonPlayoffSeries provides the bracket's scheduled series and games. The | ||
| 30 | playoff game log is used to compute each series record from completed games. | ||
| 31 | """ | ||
| 32 | series_endpoint = CommonPlayoffSeries() | ||
| 33 | game_log_endpoint = LeagueGameLog(season_type_all_star="Playoffs") | ||
| 34 | return { | ||
| 35 | "series": json.loads(series_endpoint.get_json()), | ||
| 36 | "game_log": json.loads(game_log_endpoint.get_json()), | ||
| 37 | } | ||
| 38 | |||
| 39 | |||
| 40 | def get_bracket_table(data: dict) -> str: | ||
| 41 | """Return a formatted playoff bracket.""" | ||
| 42 | result_sets = data["series"]["resultSets"] | ||
| 43 | series_set = next((r for r in result_sets if r["name"] == "PlayoffSeries"), None) | ||
| 44 | if series_set is None or not series_set["rowSet"]: | ||
| 45 | return "No playoff bracket data available." | ||
| 46 | |||
| 47 | headers = series_set["headers"] | ||
| 48 | idx = {header: i for i, header in enumerate(headers)} | ||
| 49 | team_map = _team_map() | ||
| 50 | series_games = _group_series(series_set["rowSet"], idx) | ||
| 51 | series_wins = _series_wins(data["game_log"], series_games) | ||
| 52 | summaries = _series_summaries(team_map, series_games, series_wins) | ||
| 53 | |||
| 54 | return "\n".join( | ||
| 55 | [ | ||
| 56 | _center(f"{BOLD}NBA Playoff Bracket{END}", 74), | ||
| 57 | "", | ||
| 58 | _render_conference("Western", summaries), | ||
| 59 | "", | ||
| 60 | _render_finals(summaries), | ||
| 61 | "", | ||
| 62 | _render_conference("Eastern", summaries), | ||
| 63 | ] | ||
| 64 | ) | ||
| 65 | |||
| 66 | |||
| 67 | def _team_map() -> dict: | ||
| 68 | return { | ||
| 69 | team["id"]: { | ||
| 70 | "abbr": team["abbreviation"], | ||
| 71 | "name": f"{team['city']} {team['nickname']}", | ||
| 72 | } | ||
| 73 | for team in teams.get_teams() | ||
| 74 | } | ||
| 75 | |||
| 76 | |||
| 77 | def _group_series(rows: list, idx: dict) -> dict: | ||
| 78 | series_games = defaultdict(list) | ||
| 79 | for row in rows: | ||
| 80 | series_id = row[idx["SERIES_ID"]] | ||
| 81 | series_games[series_id].append( | ||
| 82 | { | ||
| 83 | "game_id": row[idx["GAME_ID"]], | ||
| 84 | "game_num": row[idx["GAME_NUM"]], | ||
| 85 | "home_team_id": row[idx["HOME_TEAM_ID"]], | ||
| 86 | "visitor_team_id": row[idx["VISITOR_TEAM_ID"]], | ||
| 87 | } | ||
| 88 | ) | ||
| 89 | |||
| 90 | return { | ||
| 91 | series_id: sorted(games, key=lambda game: game["game_num"]) | ||
| 92 | for series_id, games in series_games.items() | ||
| 93 | } | ||
| 94 | |||
| 95 | |||
| 96 | def _series_wins(game_log: dict, series_games: dict) -> dict: | ||
| 97 | game_to_series = { | ||
| 98 | game["game_id"]: series_id | ||
| 99 | for series_id, games in series_games.items() | ||
| 100 | for game in games | ||
| 101 | } | ||
| 102 | wins = defaultdict(lambda: defaultdict(int)) | ||
| 103 | |||
| 104 | for series_id in series_games: | ||
| 105 | wins[series_id]["completed_games"] = set() | ||
| 106 | |||
| 107 | result_set = game_log["resultSets"][0] | ||
| 108 | idx = {header: i for i, header in enumerate(result_set["headers"])} | ||
| 109 | for row in result_set["rowSet"]: | ||
| 110 | game_id = row[idx["GAME_ID"]] | ||
| 111 | series_id = game_to_series.get(game_id) | ||
| 112 | if series_id is None or row[idx["WL"]] != "W": | ||
| 113 | continue | ||
| 114 | |||
| 115 | team_id = row[idx["TEAM_ID"]] | ||
| 116 | wins[series_id][team_id] += 1 | ||
| 117 | wins[series_id]["completed_games"].add(game_id) | ||
| 118 | |||
| 119 | return wins | ||
| 120 | |||
| 121 | |||
| 122 | def _series_summaries(team_map: dict, series_games: dict, series_wins: dict) -> dict: | ||
| 123 | summaries = {} | ||
| 124 | for series_id, games in series_games.items(): | ||
| 125 | first_game = games[0] | ||
| 126 | home_id = first_game["home_team_id"] | ||
| 127 | visitor_id = first_game["visitor_team_id"] | ||
| 128 | wins = series_wins[series_id] | ||
| 129 | home_wins = wins.get(home_id, 0) | ||
| 130 | visitor_wins = wins.get(visitor_id, 0) | ||
| 131 | |||
| 132 | summaries[series_id] = { | ||
| 133 | "conference": _conference(series_id), | ||
| 134 | "round": _round_number(series_id), | ||
| 135 | "slot": _series_slot(series_id), | ||
| 136 | "home_id": home_id, | ||
| 137 | "visitor_id": visitor_id, | ||
| 138 | "home_abbr": _abbr(team_map, home_id), | ||
| 139 | "visitor_abbr": _abbr(team_map, visitor_id), | ||
| 140 | "home_wins": home_wins, | ||
| 141 | "visitor_wins": visitor_wins, | ||
| 142 | "winner": _winner(team_map, home_id, visitor_id, home_wins, visitor_wins), | ||
| 143 | } | ||
| 144 | return summaries | ||
| 145 | |||
| 146 | |||
| 147 | def _render_conference(conference: str, summaries: dict) -> str: | ||
| 148 | first_round = _conference_round(summaries, conference, 1) | ||
| 149 | semifinals = _conference_round(summaries, conference, 2) | ||
| 150 | finals = _conference_round(summaries, conference, 3) | ||
| 151 | final = finals[0] if finals else None | ||
| 152 | |||
| 153 | lines = [ | ||
| 154 | f"{BOLD}{conference.upper()} CONFERENCE{END}", | ||
| 155 | "First Round Semifinals Conference Finals", | ||
| 156 | ] | ||
| 157 | |||
| 158 | lines.extend( | ||
| 159 | [ | ||
| 160 | f"{_series_box(_slot(first_round, 0), 24)} ┐", | ||
| 161 | f"{_blank(24)} ├── {_series_box(_slot(semifinals, 0), 24)} ┐", | ||
| 162 | f"{_series_box(_slot(first_round, 1), 24)} ┘ {_blank(24)} │", | ||
| 163 | f"{_blank(24)} {_blank(24)} ├── {_series_box(final, 24)}", | ||
| 164 | f"{_series_box(_slot(first_round, 2), 24)} ┐ {_blank(24)} │", | ||
| 165 | f"{_blank(24)} ├── {_series_box(_slot(semifinals, 1), 24)} ┘", | ||
| 166 | f"{_series_box(_slot(first_round, 3), 24)} ┘", | ||
| 167 | ] | ||
| 168 | ) | ||
| 169 | |||
| 170 | return "\n".join(lines) | ||
| 171 | |||
| 172 | |||
| 173 | def _render_finals(summaries: dict) -> str: | ||
| 174 | finals = [summary for summary in summaries.values() if summary["round"] == 4] | ||
| 175 | final = finals[0] if finals else None | ||
| 176 | |||
| 177 | lines = [ | ||
| 178 | f"{BOLD}NBA FINALS{END}", | ||
| 179 | "West Champion East Champion", | ||
| 180 | f"{_series_box(final, 24)}", | ||
| 181 | ] | ||
| 182 | if final is None: | ||
| 183 | lines.append("Winner TBD") | ||
| 184 | elif final["winner"]: | ||
| 185 | lines.append(f"{GREEN}{final['winner']} wins the Finals{END}") | ||
| 186 | return "\n".join(lines) | ||
| 187 | |||
| 188 | |||
| 189 | def _conference_round(summaries: dict, conference: str, round_number: int) -> list: | ||
| 190 | return sorted( | ||
| 191 | [ | ||
| 192 | summary | ||
| 193 | for summary in summaries.values() | ||
| 194 | if summary["conference"] == conference[:4] | ||
| 195 | and summary["round"] == round_number | ||
| 196 | ], | ||
| 197 | key=lambda summary: summary["slot"], | ||
| 198 | ) | ||
| 199 | |||
| 200 | |||
| 201 | def _slot(items: list, idx: int) -> Optional[dict]: | ||
| 202 | return items[idx] if idx < len(items) else None | ||
| 203 | |||
| 204 | |||
| 205 | def _series_box(summary: Optional[dict], width: int) -> str: | ||
| 206 | if summary is None: | ||
| 207 | return _blank(width, "TBD") | ||
| 208 | |||
| 209 | visitor = _team_line( | ||
| 210 | summary["visitor_abbr"], | ||
| 211 | summary["visitor_wins"], | ||
| 212 | summary["winner"] == summary["visitor_abbr"], | ||
| 213 | ) | ||
| 214 | home = _team_line( | ||
| 215 | summary["home_abbr"], | ||
| 216 | summary["home_wins"], | ||
| 217 | summary["winner"] == summary["home_abbr"], | ||
| 218 | ) | ||
| 219 | return f"{visitor} / {home}".ljust(width) | ||
| 220 | |||
| 221 | |||
| 222 | def _team_line(abbr: str, wins: int, won_series: bool) -> str: | ||
| 223 | label = f"{abbr} {wins}" | ||
| 224 | if won_series: | ||
| 225 | return f"{label}*" | ||
| 226 | return label | ||
| 227 | |||
| 228 | |||
| 229 | def _winner( | ||
| 230 | team_map: dict, | ||
| 231 | home_id: int, | ||
| 232 | visitor_id: int, | ||
| 233 | home_wins: int, | ||
| 234 | visitor_wins: int, | ||
| 235 | ) -> str: | ||
| 236 | if home_wins >= 4: | ||
| 237 | return _abbr(team_map, home_id) | ||
| 238 | if visitor_wins >= 4: | ||
| 239 | return _abbr(team_map, visitor_id) | ||
| 240 | return "" | ||
| 241 | |||
| 242 | |||
| 243 | def _blank(width: int, text: str = "") -> str: | ||
| 244 | return text.ljust(width) | ||
| 245 | |||
| 246 | |||
| 247 | def _center(text: str, width: int) -> str: | ||
| 248 | return text.center(width) | ||
| 249 | |||
| 250 | |||
| 251 | def _round_number(series_id: str) -> int: | ||
| 252 | try: | ||
| 253 | return int(series_id[-3:-1]) | ||
| 254 | except ValueError: | ||
| 255 | return 0 | ||
| 256 | |||
| 257 | |||
| 258 | def _series_slot(series_id: str) -> int: | ||
| 259 | try: | ||
| 260 | return int(series_id[-1]) | ||
| 261 | except ValueError: | ||
| 262 | return 0 | ||
| 263 | |||
| 264 | |||
| 265 | def _conference(series_id: str) -> str: | ||
| 266 | round_number = _round_number(series_id) | ||
| 267 | slot = _series_slot(series_id) | ||
| 268 | |||
| 269 | if round_number == 4: | ||
| 270 | return "NBA" | ||
| 271 | if round_number == 3: | ||
| 272 | return "East" if slot == 0 else "West" | ||
| 273 | if round_number == 2: | ||
| 274 | return "East" if slot <= 1 else "West" | ||
| 275 | if round_number == 1: | ||
| 276 | return "East" if slot <= 3 else "West" | ||
| 277 | return "" | ||
| 278 | |||
| 279 | |||
| 280 | def _abbr(team_map: dict, team_id: int) -> str: | ||
| 281 | return team_map.get(team_id, {}).get("abbr", str(team_id)) | ||
build/lib/nba/tui/app.py +18 −2
| @@ -13,6 +13,7 @@ from textual.containers import Horizontal | |||
| 13 | from textual.widgets import Footer, Header, Static, TabbedContent, TabPane | 13 | from textual.widgets import Footer, Header, Static, TabbedContent, TabPane |
| 14 | 14 | ||
| 15 | from nba import fetch_data | 15 | from nba import fetch_data |
| 16 | from nba import bracket as bracket_mod | ||
| 16 | from nba import leaders as leaders_mod | 17 | from nba import leaders as leaders_mod |
| 17 | from nba import playoff as playoff_mod | 18 | from nba import playoff as playoff_mod |
| 18 | from nba import box_score as box_score_mod | 19 | from nba import box_score as box_score_mod |
| @@ -32,6 +33,7 @@ class NBAApp(App): | |||
| 32 | ("t", "show_standings", "Standings"), | 33 | ("t", "show_standings", "Standings"), |
| 33 | ("l", "show_leaders", "Leaders"), | 34 | ("l", "show_leaders", "Leaders"), |
| 34 | ("p", "show_playoff", "Playoff"), | 35 | ("p", "show_playoff", "Playoff"), |
| 36 | ("k", "show_bracket", "Bracket"), | ||
| 35 | ("b", "show_boxscore", "Box Score"), | 37 | ("b", "show_boxscore", "Box Score"), |
| 36 | ("r", "refresh_now", "Refresh"), | 38 | ("r", "refresh_now", "Refresh"), |
| 37 | ("comma", "prev_category", "◀ Cat"), | 39 | ("comma", "prev_category", "◀ Cat"), |
| @@ -49,6 +51,7 @@ class NBAApp(App): | |||
| 49 | self._leaders_data: dict | None = None | 51 | self._leaders_data: dict | None = None |
| 50 | self._leaders_cat_idx: int = 0 | 52 | self._leaders_cat_idx: int = 0 |
| 51 | self._playoff_data: dict | None = None | 53 | self._playoff_data: dict | None = None |
| 54 | self._bracket_data: dict | None = None | ||
| 52 | 55 | ||
| 53 | def compose(self) -> ComposeResult: | 56 | def compose(self) -> ComposeResult: |
| 54 | yield Header() | 57 | yield Header() |
| @@ -65,6 +68,8 @@ class NBAApp(App): | |||
| 65 | with Horizontal(id="playoff-container"): | 68 | with Horizontal(id="playoff-container"): |
| 66 | yield Static("Loading...", id="playoff-west-content") | 69 | yield Static("Loading...", id="playoff-west-content") |
| 67 | yield Static("Loading...", id="playoff-east-content") | 70 | yield Static("Loading...", id="playoff-east-content") |
| 71 | with TabPane("Bracket", id="bracket"): | ||
| 72 | yield Static("Loading...", id="bracket-content") | ||
| 68 | with TabPane("Box Score", id="boxscore"): | 73 | with TabPane("Box Score", id="boxscore"): |
| 69 | with Horizontal(id="boxscore-container"): | 74 | with Horizontal(id="boxscore-container"): |
| 70 | yield Static( | 75 | yield Static( |
| @@ -84,7 +89,7 @@ class NBAApp(App): | |||
| 84 | # ------------------------------------------------------------------ # | 89 | # ------------------------------------------------------------------ # |
| 85 | 90 | ||
| 86 | async def _do_refresh(self) -> None: | 91 | async def _do_refresh(self) -> None: |
| 87 | """Fetch scores, standings, leaders, and playoff picture in parallel.""" | 92 | """Fetch scores, standings, leaders, playoff picture, and bracket in parallel.""" |
| 88 | loop = asyncio.get_event_loop() | 93 | loop = asyncio.get_event_loop() |
| 89 | cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] | 94 | cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] |
| 90 | 95 | ||
| @@ -92,10 +97,11 @@ class NBAApp(App): | |||
| 92 | loop.run_in_executor(None, fetch_data.fetch_data), | 97 | loop.run_in_executor(None, fetch_data.fetch_data), |
| 93 | loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)), | 98 | loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)), |
| 94 | loop.run_in_executor(None, playoff_mod.fetch_playoff_picture), | 99 | loop.run_in_executor(None, playoff_mod.fetch_playoff_picture), |
| 100 | loop.run_in_executor(None, bracket_mod.fetch_bracket), | ||
| 95 | return_exceptions=True, | 101 | return_exceptions=True, |
| 96 | ) | 102 | ) |
| 97 | 103 | ||
| 98 | games_ranks, leaders_data, playoff_data = results | 104 | games_ranks, leaders_data, playoff_data, bracket_data = results |
| 99 | 105 | ||
| 100 | if not isinstance(games_ranks, Exception): | 106 | if not isinstance(games_ranks, Exception): |
| 101 | self._games, self._ranks = games_ranks | 107 | self._games, self._ranks = games_ranks |
| @@ -103,6 +109,8 @@ class NBAApp(App): | |||
| 103 | self._leaders_data = leaders_data | 109 | self._leaders_data = leaders_data |
| 104 | if not isinstance(playoff_data, Exception): | 110 | if not isinstance(playoff_data, Exception): |
| 105 | self._playoff_data = playoff_data | 111 | self._playoff_data = playoff_data |
| 112 | if not isinstance(bracket_data, Exception): | ||
| 113 | self._bracket_data = bracket_data | ||
| 106 | 114 | ||
| 107 | self._update_widgets() | 115 | self._update_widgets() |
| 108 | self.query_one(CountdownBar).reset(self.refresh_interval) | 116 | self.query_one(CountdownBar).reset(self.refresh_interval) |
| @@ -133,6 +141,11 @@ class NBAApp(App): | |||
| 133 | Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data)) | 141 | Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data)) |
| 134 | ) | 142 | ) |
| 135 | 143 | ||
| 144 | if self._bracket_data: | ||
| 145 | self.query_one("#bracket-content", Static).update( | ||
| 146 | Text.from_ansi(bracket_mod.get_bracket_table(self._bracket_data)) | ||
| 147 | ) | ||
| 148 | |||
| 136 | # ------------------------------------------------------------------ # | 149 | # ------------------------------------------------------------------ # |
| 137 | # Key handlers # | 150 | # Key handlers # |
| 138 | # ------------------------------------------------------------------ # | 151 | # ------------------------------------------------------------------ # |
| @@ -162,6 +175,9 @@ class NBAApp(App): | |||
| 162 | def action_show_playoff(self) -> None: | 175 | def action_show_playoff(self) -> None: |
| 163 | self.query_one(TabbedContent).active = "playoff" | 176 | self.query_one(TabbedContent).active = "playoff" |
| 164 | 177 | ||
| 178 | def action_show_bracket(self) -> None: | ||
| 179 | self.query_one(TabbedContent).active = "bracket" | ||
| 180 | |||
| 165 | def action_show_boxscore(self) -> None: | 181 | def action_show_boxscore(self) -> None: |
| 166 | self.query_one(TabbedContent).active = "boxscore" | 182 | self.query_one(TabbedContent).active = "boxscore" |
| 167 | 183 | ||
build/lib/nba/tui/styles.tcss +3 −2
| @@ -16,7 +16,7 @@ TabPane { | |||
| 16 | width: auto; | 16 | width: auto; |
| 17 | } | 17 | } |
| 18 | 18 | ||
| 19 | /* Standings & Playoff tabs — two columns side by side */ | 19 | /* Standings, Playoff, Bracket, and Box Score tabs */ |
| 20 | #standings-container, | 20 | #standings-container, |
| 21 | #playoff-container, | 21 | #playoff-container, |
| 22 | #boxscore-container { | 22 | #boxscore-container { |
| @@ -27,7 +27,8 @@ TabPane { | |||
| 27 | #east-content, | 27 | #east-content, |
| 28 | #west-content, | 28 | #west-content, |
| 29 | #playoff-east-content, | 29 | #playoff-east-content, |
| 30 | #playoff-west-content { | 30 | #playoff-west-content, |
| 31 | #bracket-content { | ||
| 31 | width: 1fr; | 32 | width: 1fr; |
| 32 | overflow: auto auto; | 33 | overflow: auto auto; |
| 33 | padding-right: 2; | 34 | padding-right: 2; |
build/lib/nba/tui/widgets.py +2 −1
| @@ -41,7 +41,8 @@ class CountdownBar(Static): | |||
| 41 | else: | 41 | else: |
| 42 | self.update( | 42 | self.update( |
| 43 | f"Next refresh in {value}s | " | 43 | f"Next refresh in {value}s | " |
| 44 | "\\[1-9] Box Score | \\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" | 44 | "\\[1-9] Box Score | \\[k] Bracket | " |
| 45 | "\\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" | ||
| 45 | ) | 46 | ) |
| 46 | 47 | ||
| 47 | def reset(self, interval: int) -> None: | 48 | def reset(self, interval: int) -> None: |
nba/bracket.py added +281
| @@ -0,0 +1,281 @@ | |||
| 1 | """ | ||
| 2 | Fetches and formats the NBA playoff bracket. | ||
| 3 | """ | ||
| 4 | |||
| 5 | import json | ||
| 6 | from collections import defaultdict | ||
| 7 | from typing import Optional | ||
| 8 | |||
| 9 | from nba_api.stats.endpoints.commonplayoffseries import CommonPlayoffSeries | ||
| 10 | from nba_api.stats.endpoints.leaguegamelog import LeagueGameLog | ||
| 11 | from nba_api.stats.static import teams | ||
| 12 | |||
| 13 | BOLD = "\033[1m" | ||
| 14 | END = "\033[0m" | ||
| 15 | GREEN = "\033[32m" | ||
| 16 | |||
| 17 | ROUND_LABELS = { | ||
| 18 | 1: "First Round", | ||
| 19 | 2: "Conference Semifinals", | ||
| 20 | 3: "Conference Finals", | ||
| 21 | 4: "NBA Finals", | ||
| 22 | } | ||
| 23 | |||
| 24 | |||
| 25 | def fetch_bracket() -> dict: | ||
| 26 | """ | ||
| 27 | Fetch playoff series and completed playoff games. | ||
| 28 | |||
| 29 | CommonPlayoffSeries provides the bracket's scheduled series and games. The | ||
| 30 | playoff game log is used to compute each series record from completed games. | ||
| 31 | """ | ||
| 32 | series_endpoint = CommonPlayoffSeries() | ||
| 33 | game_log_endpoint = LeagueGameLog(season_type_all_star="Playoffs") | ||
| 34 | return { | ||
| 35 | "series": json.loads(series_endpoint.get_json()), | ||
| 36 | "game_log": json.loads(game_log_endpoint.get_json()), | ||
| 37 | } | ||
| 38 | |||
| 39 | |||
| 40 | def get_bracket_table(data: dict) -> str: | ||
| 41 | """Return a formatted playoff bracket.""" | ||
| 42 | result_sets = data["series"]["resultSets"] | ||
| 43 | series_set = next((r for r in result_sets if r["name"] == "PlayoffSeries"), None) | ||
| 44 | if series_set is None or not series_set["rowSet"]: | ||
| 45 | return "No playoff bracket data available." | ||
| 46 | |||
| 47 | headers = series_set["headers"] | ||
| 48 | idx = {header: i for i, header in enumerate(headers)} | ||
| 49 | team_map = _team_map() | ||
| 50 | series_games = _group_series(series_set["rowSet"], idx) | ||
| 51 | series_wins = _series_wins(data["game_log"], series_games) | ||
| 52 | summaries = _series_summaries(team_map, series_games, series_wins) | ||
| 53 | |||
| 54 | return "\n".join( | ||
| 55 | [ | ||
| 56 | _center(f"{BOLD}NBA Playoff Bracket{END}", 74), | ||
| 57 | "", | ||
| 58 | _render_conference("Western", summaries), | ||
| 59 | "", | ||
| 60 | _render_finals(summaries), | ||
| 61 | "", | ||
| 62 | _render_conference("Eastern", summaries), | ||
| 63 | ] | ||
| 64 | ) | ||
| 65 | |||
| 66 | |||
| 67 | def _team_map() -> dict: | ||
| 68 | return { | ||
| 69 | team["id"]: { | ||
| 70 | "abbr": team["abbreviation"], | ||
| 71 | "name": f"{team['city']} {team['nickname']}", | ||
| 72 | } | ||
| 73 | for team in teams.get_teams() | ||
| 74 | } | ||
| 75 | |||
| 76 | |||
| 77 | def _group_series(rows: list, idx: dict) -> dict: | ||
| 78 | series_games = defaultdict(list) | ||
| 79 | for row in rows: | ||
| 80 | series_id = row[idx["SERIES_ID"]] | ||
| 81 | series_games[series_id].append( | ||
| 82 | { | ||
| 83 | "game_id": row[idx["GAME_ID"]], | ||
| 84 | "game_num": row[idx["GAME_NUM"]], | ||
| 85 | "home_team_id": row[idx["HOME_TEAM_ID"]], | ||
| 86 | "visitor_team_id": row[idx["VISITOR_TEAM_ID"]], | ||
| 87 | } | ||
| 88 | ) | ||
| 89 | |||
| 90 | return { | ||
| 91 | series_id: sorted(games, key=lambda game: game["game_num"]) | ||
| 92 | for series_id, games in series_games.items() | ||
| 93 | } | ||
| 94 | |||
| 95 | |||
| 96 | def _series_wins(game_log: dict, series_games: dict) -> dict: | ||
| 97 | game_to_series = { | ||
| 98 | game["game_id"]: series_id | ||
| 99 | for series_id, games in series_games.items() | ||
| 100 | for game in games | ||
| 101 | } | ||
| 102 | wins = defaultdict(lambda: defaultdict(int)) | ||
| 103 | |||
| 104 | for series_id in series_games: | ||
| 105 | wins[series_id]["completed_games"] = set() | ||
| 106 | |||
| 107 | result_set = game_log["resultSets"][0] | ||
| 108 | idx = {header: i for i, header in enumerate(result_set["headers"])} | ||
| 109 | for row in result_set["rowSet"]: | ||
| 110 | game_id = row[idx["GAME_ID"]] | ||
| 111 | series_id = game_to_series.get(game_id) | ||
| 112 | if series_id is None or row[idx["WL"]] != "W": | ||
| 113 | continue | ||
| 114 | |||
| 115 | team_id = row[idx["TEAM_ID"]] | ||
| 116 | wins[series_id][team_id] += 1 | ||
| 117 | wins[series_id]["completed_games"].add(game_id) | ||
| 118 | |||
| 119 | return wins | ||
| 120 | |||
| 121 | |||
| 122 | def _series_summaries(team_map: dict, series_games: dict, series_wins: dict) -> dict: | ||
| 123 | summaries = {} | ||
| 124 | for series_id, games in series_games.items(): | ||
| 125 | first_game = games[0] | ||
| 126 | home_id = first_game["home_team_id"] | ||
| 127 | visitor_id = first_game["visitor_team_id"] | ||
| 128 | wins = series_wins[series_id] | ||
| 129 | home_wins = wins.get(home_id, 0) | ||
| 130 | visitor_wins = wins.get(visitor_id, 0) | ||
| 131 | |||
| 132 | summaries[series_id] = { | ||
| 133 | "conference": _conference(series_id), | ||
| 134 | "round": _round_number(series_id), | ||
| 135 | "slot": _series_slot(series_id), | ||
| 136 | "home_id": home_id, | ||
| 137 | "visitor_id": visitor_id, | ||
| 138 | "home_abbr": _abbr(team_map, home_id), | ||
| 139 | "visitor_abbr": _abbr(team_map, visitor_id), | ||
| 140 | "home_wins": home_wins, | ||
| 141 | "visitor_wins": visitor_wins, | ||
| 142 | "winner": _winner(team_map, home_id, visitor_id, home_wins, visitor_wins), | ||
| 143 | } | ||
| 144 | return summaries | ||
| 145 | |||
| 146 | |||
| 147 | def _render_conference(conference: str, summaries: dict) -> str: | ||
| 148 | first_round = _conference_round(summaries, conference, 1) | ||
| 149 | semifinals = _conference_round(summaries, conference, 2) | ||
| 150 | finals = _conference_round(summaries, conference, 3) | ||
| 151 | final = finals[0] if finals else None | ||
| 152 | |||
| 153 | lines = [ | ||
| 154 | f"{BOLD}{conference.upper()} CONFERENCE{END}", | ||
| 155 | "First Round Semifinals Conference Finals", | ||
| 156 | ] | ||
| 157 | |||
| 158 | lines.extend( | ||
| 159 | [ | ||
| 160 | f"{_series_box(_slot(first_round, 0), 24)} ┐", | ||
| 161 | f"{_blank(24)} ├── {_series_box(_slot(semifinals, 0), 24)} ┐", | ||
| 162 | f"{_series_box(_slot(first_round, 1), 24)} ┘ {_blank(24)} │", | ||
| 163 | f"{_blank(24)} {_blank(24)} ├── {_series_box(final, 24)}", | ||
| 164 | f"{_series_box(_slot(first_round, 2), 24)} ┐ {_blank(24)} │", | ||
| 165 | f"{_blank(24)} ├── {_series_box(_slot(semifinals, 1), 24)} ┘", | ||
| 166 | f"{_series_box(_slot(first_round, 3), 24)} ┘", | ||
| 167 | ] | ||
| 168 | ) | ||
| 169 | |||
| 170 | return "\n".join(lines) | ||
| 171 | |||
| 172 | |||
| 173 | def _render_finals(summaries: dict) -> str: | ||
| 174 | finals = [summary for summary in summaries.values() if summary["round"] == 4] | ||
| 175 | final = finals[0] if finals else None | ||
| 176 | |||
| 177 | lines = [ | ||
| 178 | f"{BOLD}NBA FINALS{END}", | ||
| 179 | "West Champion East Champion", | ||
| 180 | f"{_series_box(final, 24)}", | ||
| 181 | ] | ||
| 182 | if final is None: | ||
| 183 | lines.append("Winner TBD") | ||
| 184 | elif final["winner"]: | ||
| 185 | lines.append(f"{GREEN}{final['winner']} wins the Finals{END}") | ||
| 186 | return "\n".join(lines) | ||
| 187 | |||
| 188 | |||
| 189 | def _conference_round(summaries: dict, conference: str, round_number: int) -> list: | ||
| 190 | return sorted( | ||
| 191 | [ | ||
| 192 | summary | ||
| 193 | for summary in summaries.values() | ||
| 194 | if summary["conference"] == conference[:4] | ||
| 195 | and summary["round"] == round_number | ||
| 196 | ], | ||
| 197 | key=lambda summary: summary["slot"], | ||
| 198 | ) | ||
| 199 | |||
| 200 | |||
| 201 | def _slot(items: list, idx: int) -> Optional[dict]: | ||
| 202 | return items[idx] if idx < len(items) else None | ||
| 203 | |||
| 204 | |||
| 205 | def _series_box(summary: Optional[dict], width: int) -> str: | ||
| 206 | if summary is None: | ||
| 207 | return _blank(width, "TBD") | ||
| 208 | |||
| 209 | visitor = _team_line( | ||
| 210 | summary["visitor_abbr"], | ||
| 211 | summary["visitor_wins"], | ||
| 212 | summary["winner"] == summary["visitor_abbr"], | ||
| 213 | ) | ||
| 214 | home = _team_line( | ||
| 215 | summary["home_abbr"], | ||
| 216 | summary["home_wins"], | ||
| 217 | summary["winner"] == summary["home_abbr"], | ||
| 218 | ) | ||
| 219 | return f"{visitor} / {home}".ljust(width) | ||
| 220 | |||
| 221 | |||
| 222 | def _team_line(abbr: str, wins: int, won_series: bool) -> str: | ||
| 223 | label = f"{abbr} {wins}" | ||
| 224 | if won_series: | ||
| 225 | return f"{label}*" | ||
| 226 | return label | ||
| 227 | |||
| 228 | |||
| 229 | def _winner( | ||
| 230 | team_map: dict, | ||
| 231 | home_id: int, | ||
| 232 | visitor_id: int, | ||
| 233 | home_wins: int, | ||
| 234 | visitor_wins: int, | ||
| 235 | ) -> str: | ||
| 236 | if home_wins >= 4: | ||
| 237 | return _abbr(team_map, home_id) | ||
| 238 | if visitor_wins >= 4: | ||
| 239 | return _abbr(team_map, visitor_id) | ||
| 240 | return "" | ||
| 241 | |||
| 242 | |||
| 243 | def _blank(width: int, text: str = "") -> str: | ||
| 244 | return text.ljust(width) | ||
| 245 | |||
| 246 | |||
| 247 | def _center(text: str, width: int) -> str: | ||
| 248 | return text.center(width) | ||
| 249 | |||
| 250 | |||
| 251 | def _round_number(series_id: str) -> int: | ||
| 252 | try: | ||
| 253 | return int(series_id[-3:-1]) | ||
| 254 | except ValueError: | ||
| 255 | return 0 | ||
| 256 | |||
| 257 | |||
| 258 | def _series_slot(series_id: str) -> int: | ||
| 259 | try: | ||
| 260 | return int(series_id[-1]) | ||
| 261 | except ValueError: | ||
| 262 | return 0 | ||
| 263 | |||
| 264 | |||
| 265 | def _conference(series_id: str) -> str: | ||
| 266 | round_number = _round_number(series_id) | ||
| 267 | slot = _series_slot(series_id) | ||
| 268 | |||
| 269 | if round_number == 4: | ||
| 270 | return "NBA" | ||
| 271 | if round_number == 3: | ||
| 272 | return "East" if slot == 0 else "West" | ||
| 273 | if round_number == 2: | ||
| 274 | return "East" if slot <= 1 else "West" | ||
| 275 | if round_number == 1: | ||
| 276 | return "East" if slot <= 3 else "West" | ||
| 277 | return "" | ||
| 278 | |||
| 279 | |||
| 280 | def _abbr(team_map: dict, team_id: int) -> str: | ||
| 281 | return team_map.get(team_id, {}).get("abbr", str(team_id)) | ||
nba/tui/app.py +18 −2
| @@ -13,6 +13,7 @@ from textual.containers import Horizontal | |||
| 13 | from textual.widgets import Footer, Header, Static, TabbedContent, TabPane | 13 | from textual.widgets import Footer, Header, Static, TabbedContent, TabPane |
| 14 | 14 | ||
| 15 | from nba import fetch_data | 15 | from nba import fetch_data |
| 16 | from nba import bracket as bracket_mod | ||
| 16 | from nba import leaders as leaders_mod | 17 | from nba import leaders as leaders_mod |
| 17 | from nba import playoff as playoff_mod | 18 | from nba import playoff as playoff_mod |
| 18 | from nba import box_score as box_score_mod | 19 | from nba import box_score as box_score_mod |
| @@ -32,6 +33,7 @@ class NBAApp(App): | |||
| 32 | ("t", "show_standings", "Standings"), | 33 | ("t", "show_standings", "Standings"), |
| 33 | ("l", "show_leaders", "Leaders"), | 34 | ("l", "show_leaders", "Leaders"), |
| 34 | ("p", "show_playoff", "Playoff"), | 35 | ("p", "show_playoff", "Playoff"), |
| 36 | ("k", "show_bracket", "Bracket"), | ||
| 35 | ("b", "show_boxscore", "Box Score"), | 37 | ("b", "show_boxscore", "Box Score"), |
| 36 | ("r", "refresh_now", "Refresh"), | 38 | ("r", "refresh_now", "Refresh"), |
| 37 | ("comma", "prev_category", "◀ Cat"), | 39 | ("comma", "prev_category", "◀ Cat"), |
| @@ -49,6 +51,7 @@ class NBAApp(App): | |||
| 49 | self._leaders_data: dict | None = None | 51 | self._leaders_data: dict | None = None |
| 50 | self._leaders_cat_idx: int = 0 | 52 | self._leaders_cat_idx: int = 0 |
| 51 | self._playoff_data: dict | None = None | 53 | self._playoff_data: dict | None = None |
| 54 | self._bracket_data: dict | None = None | ||
| 52 | 55 | ||
| 53 | def compose(self) -> ComposeResult: | 56 | def compose(self) -> ComposeResult: |
| 54 | yield Header() | 57 | yield Header() |
| @@ -65,6 +68,8 @@ class NBAApp(App): | |||
| 65 | with Horizontal(id="playoff-container"): | 68 | with Horizontal(id="playoff-container"): |
| 66 | yield Static("Loading...", id="playoff-west-content") | 69 | yield Static("Loading...", id="playoff-west-content") |
| 67 | yield Static("Loading...", id="playoff-east-content") | 70 | yield Static("Loading...", id="playoff-east-content") |
| 71 | with TabPane("Bracket", id="bracket"): | ||
| 72 | yield Static("Loading...", id="bracket-content") | ||
| 68 | with TabPane("Box Score", id="boxscore"): | 73 | with TabPane("Box Score", id="boxscore"): |
| 69 | with Horizontal(id="boxscore-container"): | 74 | with Horizontal(id="boxscore-container"): |
| 70 | yield Static( | 75 | yield Static( |
| @@ -84,7 +89,7 @@ class NBAApp(App): | |||
| 84 | # ------------------------------------------------------------------ # | 89 | # ------------------------------------------------------------------ # |
| 85 | 90 | ||
| 86 | async def _do_refresh(self) -> None: | 91 | async def _do_refresh(self) -> None: |
| 87 | """Fetch scores, standings, leaders, and playoff picture in parallel.""" | 92 | """Fetch scores, standings, leaders, playoff picture, and bracket in parallel.""" |
| 88 | loop = asyncio.get_event_loop() | 93 | loop = asyncio.get_event_loop() |
| 89 | cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] | 94 | cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] |
| 90 | 95 | ||
| @@ -92,10 +97,11 @@ class NBAApp(App): | |||
| 92 | loop.run_in_executor(None, fetch_data.fetch_data), | 97 | loop.run_in_executor(None, fetch_data.fetch_data), |
| 93 | loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)), | 98 | loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)), |
| 94 | loop.run_in_executor(None, playoff_mod.fetch_playoff_picture), | 99 | loop.run_in_executor(None, playoff_mod.fetch_playoff_picture), |
| 100 | loop.run_in_executor(None, bracket_mod.fetch_bracket), | ||
| 95 | return_exceptions=True, | 101 | return_exceptions=True, |
| 96 | ) | 102 | ) |
| 97 | 103 | ||
| 98 | games_ranks, leaders_data, playoff_data = results | 104 | games_ranks, leaders_data, playoff_data, bracket_data = results |
| 99 | 105 | ||
| 100 | if not isinstance(games_ranks, Exception): | 106 | if not isinstance(games_ranks, Exception): |
| 101 | self._games, self._ranks = games_ranks | 107 | self._games, self._ranks = games_ranks |
| @@ -103,6 +109,8 @@ class NBAApp(App): | |||
| 103 | self._leaders_data = leaders_data | 109 | self._leaders_data = leaders_data |
| 104 | if not isinstance(playoff_data, Exception): | 110 | if not isinstance(playoff_data, Exception): |
| 105 | self._playoff_data = playoff_data | 111 | self._playoff_data = playoff_data |
| 112 | if not isinstance(bracket_data, Exception): | ||
| 113 | self._bracket_data = bracket_data | ||
| 106 | 114 | ||
| 107 | self._update_widgets() | 115 | self._update_widgets() |
| 108 | self.query_one(CountdownBar).reset(self.refresh_interval) | 116 | self.query_one(CountdownBar).reset(self.refresh_interval) |
| @@ -133,6 +141,11 @@ class NBAApp(App): | |||
| 133 | Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data)) | 141 | Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data)) |
| 134 | ) | 142 | ) |
| 135 | 143 | ||
| 144 | if self._bracket_data: | ||
| 145 | self.query_one("#bracket-content", Static).update( | ||
| 146 | Text.from_ansi(bracket_mod.get_bracket_table(self._bracket_data)) | ||
| 147 | ) | ||
| 148 | |||
| 136 | # ------------------------------------------------------------------ # | 149 | # ------------------------------------------------------------------ # |
| 137 | # Key handlers # | 150 | # Key handlers # |
| 138 | # ------------------------------------------------------------------ # | 151 | # ------------------------------------------------------------------ # |
| @@ -162,6 +175,9 @@ class NBAApp(App): | |||
| 162 | def action_show_playoff(self) -> None: | 175 | def action_show_playoff(self) -> None: |
| 163 | self.query_one(TabbedContent).active = "playoff" | 176 | self.query_one(TabbedContent).active = "playoff" |
| 164 | 177 | ||
| 178 | def action_show_bracket(self) -> None: | ||
| 179 | self.query_one(TabbedContent).active = "bracket" | ||
| 180 | |||
| 165 | def action_show_boxscore(self) -> None: | 181 | def action_show_boxscore(self) -> None: |
| 166 | self.query_one(TabbedContent).active = "boxscore" | 182 | self.query_one(TabbedContent).active = "boxscore" |
| 167 | 183 | ||
nba/tui/styles.tcss +3 −2
| @@ -16,7 +16,7 @@ TabPane { | |||
| 16 | width: auto; | 16 | width: auto; |
| 17 | } | 17 | } |
| 18 | 18 | ||
| 19 | /* Standings & Playoff tabs — two columns side by side */ | 19 | /* Standings, Playoff, Bracket, and Box Score tabs */ |
| 20 | #standings-container, | 20 | #standings-container, |
| 21 | #playoff-container, | 21 | #playoff-container, |
| 22 | #boxscore-container { | 22 | #boxscore-container { |
| @@ -27,7 +27,8 @@ TabPane { | |||
| 27 | #east-content, | 27 | #east-content, |
| 28 | #west-content, | 28 | #west-content, |
| 29 | #playoff-east-content, | 29 | #playoff-east-content, |
| 30 | #playoff-west-content { | 30 | #playoff-west-content, |
| 31 | #bracket-content { | ||
| 31 | width: 1fr; | 32 | width: 1fr; |
| 32 | overflow: auto auto; | 33 | overflow: auto auto; |
| 33 | padding-right: 2; | 34 | padding-right: 2; |
nba/tui/widgets.py +2 −1
| @@ -41,7 +41,8 @@ class CountdownBar(Static): | |||
| 41 | else: | 41 | else: |
| 42 | self.update( | 42 | self.update( |
| 43 | f"Next refresh in {value}s | " | 43 | f"Next refresh in {value}s | " |
| 44 | "\\[1-9] Box Score | \\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" | 44 | "\\[1-9] Box Score | \\[k] Bracket | " |
| 45 | "\\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" | ||
| 45 | ) | 46 | ) |
| 46 | 47 | ||
| 47 | def reset(self, interval: int) -> None: | 48 | def reset(self, interval: int) -> None: |
nba_scores.egg-info/PKG-INFO +4 −11
| @@ -1,8 +1,8 @@ | |||
| 1 | Metadata-Version: 2.4 | 1 | Metadata-Version: 2.4 |
| 2 | Name: nba-scores | 2 | Name: nba-scores |
| 3 | Version: 0.1.7 | 3 | Version: 0.2.2 |
| 4 | Summary: A CLI interface to get current NBA scores and standings. | 4 | Summary: A CLI interface to get current NBA scores and standings. |
| 5 | Author-email: Christian Cleberg <hello@cmc.pub> | 5 | Author-email: Christian Cleberg <hello@cleberg.net> |
| 6 | License-Expression: GPL-3.0-or-later | 6 | License-Expression: GPL-3.0-or-later |
| 7 | Project-URL: Homepage, https://git.cleberg.net/nba-scores.git | 7 | Project-URL: Homepage, https://git.cleberg.net/nba-scores.git |
| 8 | Project-URL: Issues, https://git.cleberg.net/nba-scores.git | 8 | Project-URL: Issues, https://git.cleberg.net/nba-scores.git |
| @@ -38,18 +38,10 @@ auto-refresh. | |||
| 38 | 38 | ||
| 39 | [Back to top](#table-of-contents) | 39 | [Back to top](#table-of-contents) |
| 40 | 40 | ||
| 41 | ## PyPi | ||
| 42 | |||
| 43 | ```shell | ||
| 44 | pipx install nba-scores | ||
| 45 | ``` | ||
| 46 | |||
| 47 | ## Manual | ||
| 48 | |||
| 49 | ```shell | 41 | ```shell |
| 50 | git clone https://git.cleberg.net/nba-scores.git | 42 | git clone https://git.cleberg.net/nba-scores.git |
| 51 | cd nba-scores | 43 | cd nba-scores |
| 52 | uv sync | 44 | pipx install . |
| 53 | ``` | 45 | ``` |
| 54 | 46 | ||
| 55 | # Usage | 47 | # Usage |
| @@ -81,6 +73,7 @@ Optional flags: | |||
| 81 | | Standings | `t` | East / West conference standings, side by side | | 73 | | Standings | `t` | East / West conference standings, side by side | |
| 82 | | Leaders | `l` | Top 25 players by stat category | | 74 | | Leaders | `l` | Top 25 players by stat category | |
| 83 | | Playoff Picture | `p` | Conference seeding with clinch and elimination status | | 75 | | Playoff Picture | `p` | Conference seeding with clinch and elimination status | |
| 76 | | Bracket | `k` | Playoff series matchups and series records | | ||
| 84 | | Box Score | `b` | Per-player live stats for a selected game | | 77 | | Box Score | `b` | Per-player live stats for a selected game | |
| 85 | 78 | ||
| 86 | ### TUI key bindings | 79 | ### TUI key bindings |
nba_scores.egg-info/SOURCES.txt +1
| @@ -4,6 +4,7 @@ pyproject.toml | |||
| 4 | nba/__init__.py | 4 | nba/__init__.py |
| 5 | nba/__main__.py | 5 | nba/__main__.py |
| 6 | nba/box_score.py | 6 | nba/box_score.py |
| 7 | nba/bracket.py | ||
| 7 | nba/cli.py | 8 | nba/cli.py |
| 8 | nba/fetch_data.py | 9 | nba/fetch_data.py |
| 9 | nba/leaders.py | 10 | nba/leaders.py |
pyproject.toml +2 −2
| @@ -1,8 +1,8 @@ | |||
| 1 | [project] | 1 | [project] |
| 2 | name = "nba-scores" | 2 | name = "nba-scores" |
| 3 | version = "0.1.7" | 3 | version = "0.2.2" |
| 4 | authors = [ | 4 | authors = [ |
| 5 | { name="Christian Cleberg", email="hello@cmc.pub" }, | 5 | { name="Christian Cleberg", email="hello@cleberg.net" }, |
| 6 | ] | 6 | ] |
| 7 | description = "A CLI interface to get current NBA scores and standings." | 7 | description = "A CLI interface to get current NBA scores and standings." |
| 8 | readme = "README.md" | 8 | readme = "README.md" |
uv.lock +1 −1
| @@ -361,7 +361,7 @@ wheels = [ | |||
| 361 | 361 | ||
| 362 | [[package]] | 362 | [[package]] |
| 363 | name = "nba-scores" | 363 | name = "nba-scores" |
| 364 | version = "0.1.7" | 364 | version = "0.2.1" |
| 365 | source = { editable = "." } | 365 | source = { editable = "." } |
| 366 | dependencies = [ | 366 | dependencies = [ |
| 367 | { name = "argparse" }, | 367 | { name = "argparse" }, |