Commit 232e808bd8
Verified · cmc
Layout: unified · split
.gitignore +3
| @@ -2,5 +2,8 @@ | ||
| 2 | 2 | venv/ |
| 3 | 3 | dist/ |
| 4 | 4 | nba-scores.egg-info* |
| 5 | nba_scores.egg-info/ | |
| 5 | 6 | nba/__pycache__/ |
| 6 | 7 | nba/tui/__pycache__/ |
| 8 | build/ | |
| 9 | *.py[cod] | |
build/lib/nba/__init__.py deleted
build/lib/nba/__main__.py deleted −8
| @@ -1,8 +0,0 @@ | ||
| 1 | """ | |
| 2 | Entry point for the app. | |
| 3 | """ | |
| 4 | ||
| 5 | if __name__ == "__main__": | |
| 6 | from nba.cli import nba | |
| 7 | ||
| 8 | nba() | |
build/lib/nba/__pycache__/bracket.cpython-314.pyc deleted
Binary file not shown.
build/lib/nba/box_score.py deleted −90
| @@ -1,90 +0,0 @@ | ||
| 1 | """ | |
| 2 | Fetches and formats live box scores for individual games. | |
| 3 | """ | |
| 4 | ||
| 5 | import json | |
| 6 | ||
| 7 | from tabulate import tabulate | |
| 8 | from nba_api.live.nba.endpoints.boxscore import BoxScore | |
| 9 | ||
| 10 | BOLD = "\033[1m" | |
| 11 | END = "\033[0m" | |
| 12 | ||
| 13 | _HEADERS = [ | |
| 14 | "Player", | |
| 15 | "Pos", | |
| 16 | "Min", | |
| 17 | "Pts", | |
| 18 | "Reb", | |
| 19 | "Ast", | |
| 20 | "Stl", | |
| 21 | "Blk", | |
| 22 | "TO", | |
| 23 | "FG", | |
| 24 | "3P", | |
| 25 | "FT", | |
| 26 | "+/-", | |
| 27 | ] | |
| 28 | ||
| 29 | ||
| 30 | def fetch_box_score(game_id: str) -> dict: | |
| 31 | endpoint = BoxScore(game_id=game_id) | |
| 32 | return json.loads(endpoint.get_json()) | |
| 33 | ||
| 34 | ||
| 35 | def _parse_minutes(raw: str) -> str: | |
| 36 | """Convert 'PT35M24.00S' → '35:24'.""" | |
| 37 | if not raw: | |
| 38 | return "0:00" | |
| 39 | try: | |
| 40 | raw = raw.replace("PT", "").replace("S", "") | |
| 41 | mins, secs = raw.split("M") | |
| 42 | return f"{int(mins)}:{int(float(secs)):02d}" | |
| 43 | except Exception: | |
| 44 | return raw | |
| 45 | ||
| 46 | ||
| 47 | def _player_rows(players: list) -> list: | |
| 48 | rows = [] | |
| 49 | for p in players: | |
| 50 | if p.get("status") == "INACTIVE": | |
| 51 | continue | |
| 52 | s = p.get("statistics", {}) | |
| 53 | rows.append( | |
| 54 | [ | |
| 55 | p.get("name", ""), | |
| 56 | p.get("position", ""), | |
| 57 | _parse_minutes(s.get("minutes", "")), | |
| 58 | s.get("points", 0), | |
| 59 | s.get("reboundsTotal", 0), | |
| 60 | s.get("assists", 0), | |
| 61 | s.get("steals", 0), | |
| 62 | s.get("blocks", 0), | |
| 63 | s.get("turnovers", 0), | |
| 64 | f"{s.get('fieldGoalsMade', 0)}/{s.get('fieldGoalsAttempted', 0)}", | |
| 65 | f"{s.get('threePointersMade', 0)}/{s.get('threePointersAttempted', 0)}", | |
| 66 | f"{s.get('freeThrowsMade', 0)}/{s.get('freeThrowsAttempted', 0)}", | |
| 67 | s.get("plusMinusPoints", 0), | |
| 68 | ] | |
| 69 | ) | |
| 70 | return rows | |
| 71 | ||
| 72 | ||
| 73 | def get_box_score_tables(data: dict) -> tuple: | |
| 74 | """Return (home_table_str, away_table_str).""" | |
| 75 | game = data["game"] | |
| 76 | home = game["homeTeam"] | |
| 77 | away = game["awayTeam"] | |
| 78 | ||
| 79 | home_rows = _player_rows(home.get("players", [])) | |
| 80 | away_rows = _player_rows(away.get("players", [])) | |
| 81 | ||
| 82 | home_table = ( | |
| 83 | f"{BOLD}{home['teamCity']} {home['teamName']} ({home['score']}){END}\n" | |
| 84 | + tabulate(home_rows, headers=_HEADERS, tablefmt="grid") | |
| 85 | ) | |
| 86 | away_table = ( | |
| 87 | f"{BOLD}{away['teamCity']} {away['teamName']} ({away['score']}){END}\n" | |
| 88 | + tabulate(away_rows, headers=_HEADERS, tablefmt="grid") | |
| 89 | ) | |
| 90 | return home_table, away_table | |
build/lib/nba/bracket.py deleted −309
| @@ -1,309 +0,0 @@ | ||
| 1 | """ | |
| 2 | Fetches and formats the NBA playoff bracket. | |
| 3 | """ | |
| 4 | ||
| 5 | import json | |
| 6 | import re | |
| 7 | from collections import defaultdict | |
| 8 | from typing import Optional | |
| 9 | ||
| 10 | from nba_api.stats.endpoints.commonplayoffseries import CommonPlayoffSeries | |
| 11 | from nba_api.stats.endpoints.leaguegamelog import LeagueGameLog | |
| 12 | from nba_api.stats.static import teams | |
| 13 | ||
| 14 | BOLD = "\033[1m" | |
| 15 | END = "\033[0m" | |
| 16 | GREEN = "\033[32m" | |
| 17 | RED = "\033[91m" | |
| 18 | YELLOW = "\033[33m" | |
| 19 | CYAN = "\033[36m" | |
| 20 | ANSI_RE = re.compile(r"\033\[[0-9;]*m") | |
| 21 | ||
| 22 | ROUND_LABELS = { | |
| 23 | 1: "First Round", | |
| 24 | 2: "Conference Semifinals", | |
| 25 | 3: "Conference Finals", | |
| 26 | 4: "NBA Finals", | |
| 27 | } | |
| 28 | ||
| 29 | ||
| 30 | def fetch_bracket() -> dict: | |
| 31 | """ | |
| 32 | Fetch playoff series and completed playoff games. | |
| 33 | ||
| 34 | CommonPlayoffSeries provides the bracket's scheduled series and games. The | |
| 35 | playoff game log is used to compute each series record from completed games. | |
| 36 | """ | |
| 37 | series_endpoint = CommonPlayoffSeries() | |
| 38 | game_log_endpoint = LeagueGameLog(season_type_all_star="Playoffs") | |
| 39 | return { | |
| 40 | "series": json.loads(series_endpoint.get_json()), | |
| 41 | "game_log": json.loads(game_log_endpoint.get_json()), | |
| 42 | } | |
| 43 | ||
| 44 | ||
| 45 | def get_bracket_table(data: dict) -> str: | |
| 46 | """Return a formatted playoff bracket.""" | |
| 47 | result_sets = data["series"]["resultSets"] | |
| 48 | series_set = next((r for r in result_sets if r["name"] == "PlayoffSeries"), None) | |
| 49 | if series_set is None or not series_set["rowSet"]: | |
| 50 | return "No playoff bracket data available." | |
| 51 | ||
| 52 | headers = series_set["headers"] | |
| 53 | idx = {header: i for i, header in enumerate(headers)} | |
| 54 | team_map = _team_map() | |
| 55 | series_games = _group_series(series_set["rowSet"], idx) | |
| 56 | series_wins = _series_wins(data["game_log"], series_games) | |
| 57 | summaries = _series_summaries(team_map, series_games, series_wins) | |
| 58 | ||
| 59 | return "\n".join( | |
| 60 | [ | |
| 61 | _center(f"{BOLD}NBA Playoff Bracket{END}", 74), | |
| 62 | f"{GREEN}* Advanced{END} {RED}x Eliminated{END} " | |
| 63 | f"{CYAN}> Series lead{END} {YELLOW}! Can clinch next win{END}", | |
| 64 | "", | |
| 65 | _render_conference("Western", summaries), | |
| 66 | "", | |
| 67 | _render_finals(summaries), | |
| 68 | "", | |
| 69 | _render_conference("Eastern", summaries), | |
| 70 | ] | |
| 71 | ) | |
| 72 | ||
| 73 | ||
| 74 | def _team_map() -> dict: | |
| 75 | return { | |
| 76 | team["id"]: { | |
| 77 | "abbr": team["abbreviation"], | |
| 78 | "name": f"{team['city']} {team['nickname']}", | |
| 79 | } | |
| 80 | for team in teams.get_teams() | |
| 81 | } | |
| 82 | ||
| 83 | ||
| 84 | def _group_series(rows: list, idx: dict) -> dict: | |
| 85 | series_games = defaultdict(list) | |
| 86 | for row in rows: | |
| 87 | series_id = row[idx["SERIES_ID"]] | |
| 88 | series_games[series_id].append( | |
| 89 | { | |
| 90 | "game_id": row[idx["GAME_ID"]], | |
| 91 | "game_num": row[idx["GAME_NUM"]], | |
| 92 | "home_team_id": row[idx["HOME_TEAM_ID"]], | |
| 93 | "visitor_team_id": row[idx["VISITOR_TEAM_ID"]], | |
| 94 | } | |
| 95 | ) | |
| 96 | ||
| 97 | return { | |
| 98 | series_id: sorted(games, key=lambda game: game["game_num"]) | |
| 99 | for series_id, games in series_games.items() | |
| 100 | } | |
| 101 | ||
| 102 | ||
| 103 | def _series_wins(game_log: dict, series_games: dict) -> dict: | |
| 104 | game_to_series = { | |
| 105 | game["game_id"]: series_id | |
| 106 | for series_id, games in series_games.items() | |
| 107 | for game in games | |
| 108 | } | |
| 109 | wins = defaultdict(lambda: defaultdict(int)) | |
| 110 | ||
| 111 | for series_id in series_games: | |
| 112 | wins[series_id]["completed_games"] = set() | |
| 113 | ||
| 114 | result_set = game_log["resultSets"][0] | |
| 115 | idx = {header: i for i, header in enumerate(result_set["headers"])} | |
| 116 | for row in result_set["rowSet"]: | |
| 117 | game_id = row[idx["GAME_ID"]] | |
| 118 | series_id = game_to_series.get(game_id) | |
| 119 | if series_id is None or row[idx["WL"]] != "W": | |
| 120 | continue | |
| 121 | ||
| 122 | team_id = row[idx["TEAM_ID"]] | |
| 123 | wins[series_id][team_id] += 1 | |
| 124 | wins[series_id]["completed_games"].add(game_id) | |
| 125 | ||
| 126 | return wins | |
| 127 | ||
| 128 | ||
| 129 | def _series_summaries(team_map: dict, series_games: dict, series_wins: dict) -> dict: | |
| 130 | summaries = {} | |
| 131 | for series_id, games in series_games.items(): | |
| 132 | first_game = games[0] | |
| 133 | home_id = first_game["home_team_id"] | |
| 134 | visitor_id = first_game["visitor_team_id"] | |
| 135 | wins = series_wins[series_id] | |
| 136 | home_wins = wins.get(home_id, 0) | |
| 137 | visitor_wins = wins.get(visitor_id, 0) | |
| 138 | ||
| 139 | summaries[series_id] = { | |
| 140 | "conference": _conference(series_id), | |
| 141 | "round": _round_number(series_id), | |
| 142 | "slot": _series_slot(series_id), | |
| 143 | "home_id": home_id, | |
| 144 | "visitor_id": visitor_id, | |
| 145 | "home_abbr": _abbr(team_map, home_id), | |
| 146 | "visitor_abbr": _abbr(team_map, visitor_id), | |
| 147 | "home_wins": home_wins, | |
| 148 | "visitor_wins": visitor_wins, | |
| 149 | "winner": _winner(team_map, home_id, visitor_id, home_wins, visitor_wins), | |
| 150 | } | |
| 151 | return summaries | |
| 152 | ||
| 153 | ||
| 154 | def _render_conference(conference: str, summaries: dict) -> str: | |
| 155 | first_round = _conference_round(summaries, conference, 1) | |
| 156 | semifinals = _conference_round(summaries, conference, 2) | |
| 157 | finals = _conference_round(summaries, conference, 3) | |
| 158 | final = finals[0] if finals else None | |
| 159 | ||
| 160 | lines = [ | |
| 161 | f"{BOLD}{conference.upper()} CONFERENCE{END}", | |
| 162 | "First Round Semifinals Conference Finals", | |
| 163 | ] | |
| 164 | ||
| 165 | lines.extend( | |
| 166 | [ | |
| 167 | f"{_series_box(_slot(first_round, 0), 24)} ┐", | |
| 168 | f"{_blank(24)} ├── {_series_box(_slot(semifinals, 0), 24)} ┐", | |
| 169 | f"{_series_box(_slot(first_round, 1), 24)} ┘ {_blank(24)} │", | |
| 170 | f"{_blank(24)} {_blank(24)} ├── {_series_box(final, 24)}", | |
| 171 | f"{_series_box(_slot(first_round, 2), 24)} ┐ {_blank(24)} │", | |
| 172 | f"{_blank(24)} ├── {_series_box(_slot(semifinals, 1), 24)} ┘", | |
| 173 | f"{_series_box(_slot(first_round, 3), 24)} ┘", | |
| 174 | ] | |
| 175 | ) | |
| 176 | ||
| 177 | return "\n".join(lines) | |
| 178 | ||
| 179 | ||
| 180 | def _render_finals(summaries: dict) -> str: | |
| 181 | finals = [summary for summary in summaries.values() if summary["round"] == 4] | |
| 182 | final = finals[0] if finals else None | |
| 183 | ||
| 184 | lines = [ | |
| 185 | f"{BOLD}NBA FINALS{END}", | |
| 186 | "West Champion East Champion", | |
| 187 | f"{_series_box(final, 24)}", | |
| 188 | ] | |
| 189 | if final is None: | |
| 190 | lines.append("Winner TBD") | |
| 191 | elif final["winner"]: | |
| 192 | lines.append(f"{BOLD}{GREEN}{final['winner']} wins the Finals{END}") | |
| 193 | return "\n".join(lines) | |
| 194 | ||
| 195 | ||
| 196 | def _conference_round(summaries: dict, conference: str, round_number: int) -> list: | |
| 197 | return sorted( | |
| 198 | [ | |
| 199 | summary | |
| 200 | for summary in summaries.values() | |
| 201 | if summary["conference"] == conference[:4] | |
| 202 | and summary["round"] == round_number | |
| 203 | ], | |
| 204 | key=lambda summary: summary["slot"], | |
| 205 | ) | |
| 206 | ||
| 207 | ||
| 208 | def _slot(items: list, idx: int) -> Optional[dict]: | |
| 209 | return items[idx] if idx < len(items) else None | |
| 210 | ||
| 211 | ||
| 212 | def _series_box(summary: Optional[dict], width: int) -> str: | |
| 213 | if summary is None: | |
| 214 | return _blank(width, "TBD") | |
| 215 | ||
| 216 | visitor = _team_line( | |
| 217 | summary["visitor_abbr"], | |
| 218 | summary["visitor_wins"], | |
| 219 | summary["home_wins"], | |
| 220 | summary["winner"] == summary["visitor_abbr"], | |
| 221 | summary["winner"] == summary["home_abbr"], | |
| 222 | ) | |
| 223 | home = _team_line( | |
| 224 | summary["home_abbr"], | |
| 225 | summary["home_wins"], | |
| 226 | summary["visitor_wins"], | |
| 227 | summary["winner"] == summary["home_abbr"], | |
| 228 | summary["winner"] == summary["visitor_abbr"], | |
| 229 | ) | |
| 230 | return _pad_ansi(f"{visitor} / {home}", width) | |
| 231 | ||
| 232 | ||
| 233 | def _team_line( | |
| 234 | abbr: str, | |
| 235 | wins: int, | |
| 236 | opponent_wins: int, | |
| 237 | won_series: bool, | |
| 238 | lost_series: bool, | |
| 239 | ) -> str: | |
| 240 | label = f"{abbr} {wins}" | |
| 241 | if won_series: | |
| 242 | return f"{BOLD}{GREEN}{label}*{END}" | |
| 243 | if lost_series: | |
| 244 | return f"{RED}{label}x{END}" | |
| 245 | if wins == 3: | |
| 246 | return f"{BOLD}{YELLOW}{label}!{END}" | |
| 247 | if wins > opponent_wins: | |
| 248 | return f"{CYAN}{label}>{END}" | |
| 249 | return label | |
| 250 | ||
| 251 | ||
| 252 | def _winner( | |
| 253 | team_map: dict, | |
| 254 | home_id: int, | |
| 255 | visitor_id: int, | |
| 256 | home_wins: int, | |
| 257 | visitor_wins: int, | |
| 258 | ) -> str: | |
| 259 | if home_wins >= 4: | |
| 260 | return _abbr(team_map, home_id) | |
| 261 | if visitor_wins >= 4: | |
| 262 | return _abbr(team_map, visitor_id) | |
| 263 | return "" | |
| 264 | ||
| 265 | ||
| 266 | def _blank(width: int, text: str = "") -> str: | |
| 267 | return text.ljust(width) | |
| 268 | ||
| 269 | ||
| 270 | def _center(text: str, width: int) -> str: | |
| 271 | return text.center(width) | |
| 272 | ||
| 273 | ||
| 274 | def _pad_ansi(text: str, width: int) -> str: | |
| 275 | visible_length = len(ANSI_RE.sub("", text)) | |
| 276 | return text + " " * max(width - visible_length, 0) | |
| 277 | ||
| 278 | ||
| 279 | def _round_number(series_id: str) -> int: | |
| 280 | try: | |
| 281 | return int(series_id[-3:-1]) | |
| 282 | except ValueError: | |
| 283 | return 0 | |
| 284 | ||
| 285 | ||
| 286 | def _series_slot(series_id: str) -> int: | |
| 287 | try: | |
| 288 | return int(series_id[-1]) | |
| 289 | except ValueError: | |
| 290 | return 0 | |
| 291 | ||
| 292 | ||
| 293 | def _conference(series_id: str) -> str: | |
| 294 | round_number = _round_number(series_id) | |
| 295 | slot = _series_slot(series_id) | |
| 296 | ||
| 297 | if round_number == 4: | |
| 298 | return "NBA" | |
| 299 | if round_number == 3: | |
| 300 | return "East" if slot == 0 else "West" | |
| 301 | if round_number == 2: | |
| 302 | return "East" if slot <= 1 else "West" | |
| 303 | if round_number == 1: | |
| 304 | return "East" if slot <= 3 else "West" | |
| 305 | return "" | |
| 306 | ||
| 307 | ||
| 308 | def _abbr(team_map: dict, team_id: int) -> str: | |
| 309 | return team_map.get(team_id, {}).get("abbr", str(team_id)) | |
build/lib/nba/cli.py deleted −50
| @@ -1,50 +0,0 @@ | ||
| 1 | """ | |
| 2 | This script uses argparse to parse command line arguments. | |
| 3 | ||
| 4 | It imports the required modules and sets up a parser with basic options for demonstration purposes. | |
| 5 | """ | |
| 6 | ||
| 7 | import argparse | |
| 8 | from nba import fetch_data, scores, standings | |
| 9 | ||
| 10 | ||
| 11 | def nba() -> None: | |
| 12 | """ | |
| 13 | Parse command-line arguments and display either scoreboard or standings. | |
| 14 | """ | |
| 15 | parser = argparse.ArgumentParser(description="NBA Scoreboard and Standings") | |
| 16 | parser.add_argument( | |
| 17 | "--scores", "-sc", action="store_true", help="Display the scoreboard" | |
| 18 | ) | |
| 19 | parser.add_argument( | |
| 20 | "--standings", "-st", action="store_true", help="Display the standings" | |
| 21 | ) | |
| 22 | parser.add_argument("--tui", action="store_true", help="Launch the interactive TUI") | |
| 23 | parser.add_argument( | |
| 24 | "--refresh", | |
| 25 | type=int, | |
| 26 | default=60, | |
| 27 | metavar="SECONDS", | |
| 28 | help="Auto-refresh interval in TUI mode (default: 60, minimum: 10)", | |
| 29 | ) | |
| 30 | args = parser.parse_args() | |
| 31 | ||
| 32 | if args.tui: | |
| 33 | from nba.tui.app import NBAApp | |
| 34 | ||
| 35 | initial_tab = "standings" if args.standings else "scores" | |
| 36 | refresh_interval = max(args.refresh, 10) | |
| 37 | NBAApp(initial_tab=initial_tab, refresh_interval=refresh_interval).run() | |
| 38 | return | |
| 39 | ||
| 40 | # Legacy static mode | |
| 41 | games, ranks = fetch_data.fetch_data() | |
| 42 | ||
| 43 | if args.scores: | |
| 44 | scores.build_scoreboard(games, ranks) | |
| 45 | elif args.standings: | |
| 46 | standings.build_standings(ranks) | |
| 47 | else: | |
| 48 | print( | |
| 49 | "Please specify --scores or --standings (or use --tui for interactive mode)" | |
| 50 | ) | |
build/lib/nba/fetch_data.py deleted −30
| @@ -1,30 +0,0 @@ | ||
| 1 | """ | |
| 2 | Fetches data for use in other modules. | |
| 3 | """ | |
| 4 | ||
| 5 | import json | |
| 6 | from nba_api.live.nba.endpoints import scoreboard | |
| 7 | from nba_api.stats.endpoints import leaguestandings | |
| 8 | ||
| 9 | ||
| 10 | def fetch_data() -> tuple: | |
| 11 | """ | |
| 12 | Fetches live NBA scoreboard data and standings from the NBA API. | |
| 13 | ||
| 14 | Returns: | |
| 15 | games (dict): JSON parsed games data. | |
| 16 | standings (dict): JSON parsed team standings data. | |
| 17 | """ | |
| 18 | # Get today's scoreboard data | |
| 19 | games_endpoint = scoreboard.ScoreBoard() | |
| 20 | games_json = games_endpoint.get_json() | |
| 21 | ||
| 22 | # Get league standings | |
| 23 | standings_endpoint = leaguestandings.LeagueStandings() | |
| 24 | standings_json = standings_endpoint.get_json() | |
| 25 | ||
| 26 | # Parse the JSON strings into Python dictionaries | |
| 27 | games = json.loads(games_json) | |
| 28 | standings = json.loads(standings_json) | |
| 29 | ||
| 30 | return games, standings | |
build/lib/nba/leaders.py deleted −72
| @@ -1,72 +0,0 @@ | ||
| 1 | """ | |
| 2 | Fetches and formats NBA statistical leaders. | |
| 3 | """ | |
| 4 | ||
| 5 | import json | |
| 6 | ||
| 7 | from tabulate import tabulate | |
| 8 | from nba_api.stats.endpoints.leagueleaders import LeagueLeaders | |
| 9 | ||
| 10 | BOLD = "\033[1m" | |
| 11 | END = "\033[0m" | |
| 12 | ||
| 13 | # Ordered list of (api_abbreviation, display_label) | |
| 14 | CATEGORIES = [ | |
| 15 | ("PTS", "Points"), | |
| 16 | ("REB", "Rebounds"), | |
| 17 | ("AST", "Assists"), | |
| 18 | ("STL", "Steals"), | |
| 19 | ("BLK", "Blocks"), | |
| 20 | ("EFF", "Efficiency"), | |
| 21 | ("FG_PCT", "FG%"), | |
| 22 | ("FT_PCT", "FT%"), | |
| 23 | ("FG3_PCT", "3P%"), | |
| 24 | ] | |
| 25 | ||
| 26 | # Extra columns to show alongside RANK, PLAYER, TEAM, GP for each category | |
| 27 | _EXTRA_COLS = { | |
| 28 | "PTS": ["PTS", "FGM", "FGA", "FG_PCT", "FTM", "FTA", "FT_PCT"], | |
| 29 | "REB": ["REB", "OREB", "DREB", "GP"], | |
| 30 | "AST": ["AST", "TOV", "AST_TOV", "GP"], | |
| 31 | "STL": ["STL", "TOV", "GP"], | |
| 32 | "BLK": ["BLK", "PF", "GP"], | |
| 33 | "EFF": ["EFF", "PTS", "REB", "AST", "GP"], | |
| 34 | "FG_PCT": ["FG_PCT", "FGM", "FGA", "PTS"], | |
| 35 | "FT_PCT": ["FT_PCT", "FTM", "FTA", "PTS"], | |
| 36 | "FG3_PCT": ["FG3_PCT", "FG3M", "FG3A", "PTS"], | |
| 37 | } | |
| 38 | ||
| 39 | _DISPLAY_NAMES = { | |
| 40 | "FG_PCT": "FG%", | |
| 41 | "FT_PCT": "FT%", | |
| 42 | "FG3_PCT": "3P%", | |
| 43 | "FG3M": "3PM", | |
| 44 | "FG3A": "3PA", | |
| 45 | "AST_TOV": "AST/TO", | |
| 46 | } | |
| 47 | ||
| 48 | ||
| 49 | def fetch_leaders(category: str = "PTS") -> dict: | |
| 50 | endpoint = LeagueLeaders( | |
| 51 | stat_category_abbreviation=category, | |
| 52 | season_type_all_star="Regular Season", | |
| 53 | ) | |
| 54 | return json.loads(endpoint.get_json()) | |
| 55 | ||
| 56 | ||
| 57 | def get_leaders_table(data: dict, category: str = "PTS") -> str: | |
| 58 | result = data["resultSet"] | |
| 59 | headers = result["headers"] | |
| 60 | rows = result["rowSet"] | |
| 61 | ||
| 62 | base = ["RANK", "PLAYER", "TEAM", "GP"] | |
| 63 | extra = [c for c in _EXTRA_COLS.get(category, [category]) if c not in base] | |
| 64 | wanted = base + extra | |
| 65 | ||
| 66 | idx = {h: i for i, h in enumerate(headers)} | |
| 67 | table_data = [[row[idx[col]] for col in wanted if col in idx] for row in rows[:25]] | |
| 68 | display_headers = [_DISPLAY_NAMES.get(c, c) for c in wanted if c in idx] | |
| 69 | ||
| 70 | cat_label = dict(CATEGORIES).get(category, category) | |
| 71 | title = f"{BOLD}League Leaders — {cat_label}{END}" | |
| 72 | return title + "\n" + tabulate(table_data, headers=display_headers, tablefmt="grid") | |
build/lib/nba/playoff.py deleted −93
| @@ -1,93 +0,0 @@ | ||
| 1 | """ | |
| 2 | Fetches and formats the NBA playoff picture. | |
| 3 | """ | |
| 4 | ||
| 5 | import json | |
| 6 | ||
| 7 | from tabulate import tabulate | |
| 8 | from nba_api.stats.endpoints.playoffpicture import PlayoffPicture | |
| 9 | ||
| 10 | BOLD = "\033[1m" | |
| 11 | END = "\033[0m" | |
| 12 | RED = "\033[91m" | |
| 13 | GREEN = "\033[32m" | |
| 14 | YELLOW = "\033[33m" | |
| 15 | ||
| 16 | ||
| 17 | def fetch_playoff_picture() -> dict: | |
| 18 | endpoint = PlayoffPicture() | |
| 19 | return json.loads(endpoint.get_json()) | |
| 20 | ||
| 21 | ||
| 22 | def _clinch_status(row: list, idx: dict) -> str: | |
| 23 | """Return a color-coded status string from clinch/elimination columns.""" | |
| 24 | ||
| 25 | def val(col): | |
| 26 | return row[idx[col]] if col in idx else None | |
| 27 | ||
| 28 | if val("CLINCHED_CONFERENCE"): | |
| 29 | return f"{BOLD}{GREEN}z-Clinched Conf{END}" | |
| 30 | if val("CLINCHED_DIVISION") or val("CLINCHED_PLAYOFFS"): | |
| 31 | return f"{GREEN}x-Clinched{END}" | |
| 32 | if val("Clinched_Play_In"): | |
| 33 | return f"{YELLOW}pi-Play-In{END}" | |
| 34 | if val("ELIMINATED_PLAYOFFS"): | |
| 35 | return f"{RED}e-Eliminated{END}" | |
| 36 | return "" | |
| 37 | ||
| 38 | ||
| 39 | def _build_conference_table(result_sets: list, name: str) -> str: | |
| 40 | """Build a formatted playoff standings table for one conference.""" | |
| 41 | rs = next((r for r in result_sets if r["name"] == name), None) | |
| 42 | if rs is None or not rs["rowSet"]: | |
| 43 | return "No data available." | |
| 44 | ||
| 45 | headers = rs["headers"] | |
| 46 | rows = rs["rowSet"] | |
| 47 | idx = {h: i for i, h in enumerate(headers)} | |
| 48 | ||
| 49 | def get(row, col, default=""): | |
| 50 | return row[idx[col]] if col in idx else default | |
| 51 | ||
| 52 | table_data = [] | |
| 53 | for row in rows: | |
| 54 | wins = get(row, "WINS") | |
| 55 | losses = get(row, "LOSSES") | |
| 56 | pct = get(row, "PCT") | |
| 57 | pct_str = f"{float(pct):.3f}" if pct not in ("", None) else "" | |
| 58 | table_data.append( | |
| 59 | [ | |
| 60 | get(row, "RANK"), | |
| 61 | get(row, "TEAM"), | |
| 62 | f"{wins}-{losses}", | |
| 63 | pct_str, | |
| 64 | get(row, "GB"), | |
| 65 | get(row, "HOME"), | |
| 66 | get(row, "AWAY"), | |
| 67 | get(row, "CONF"), | |
| 68 | _clinch_status(row, idx), | |
| 69 | ] | |
| 70 | ) | |
| 71 | ||
| 72 | display_headers = [ | |
| 73 | "#", | |
| 74 | "Team", | |
| 75 | "W-L", | |
| 76 | "PCT", | |
| 77 | "GB", | |
| 78 | "HOME", | |
| 79 | "AWAY", | |
| 80 | "CONF", | |
| 81 | "Status", | |
| 82 | ] | |
| 83 | conf_label = "Eastern" if "East" in name else "Western" | |
| 84 | title = f"{BOLD}{conf_label} Conference Playoff Picture:{END}" | |
| 85 | return title + "\n" + tabulate(table_data, headers=display_headers, tablefmt="grid") | |
| 86 | ||
| 87 | ||
| 88 | def get_west_playoff_table(data: dict) -> str: | |
| 89 | return _build_conference_table(data["resultSets"], "WestConfStandings") | |
| 90 | ||
| 91 | ||
| 92 | def get_east_playoff_table(data: dict) -> str: | |
| 93 | return _build_conference_table(data["resultSets"], "EastConfStandings") | |
build/lib/nba/scores.py deleted −106
| @@ -1,106 +0,0 @@ | ||
| 1 | """ | |
| 2 | Tabulates a scoreboard for today's games. | |
| 3 | """ | |
| 4 | ||
| 5 | from tabulate import tabulate | |
| 6 | ||
| 7 | # ANSI escape codes for text formatting | |
| 8 | BOLD = "\033[1m" | |
| 9 | END = "\033[0m" | |
| 10 | RED = "\033[91m" | |
| 11 | GREEN = "\033[32m" | |
| 12 | ||
| 13 | ||
| 14 | # Function to get team record from standings | |
| 15 | def get_team_record(team_name, standings) -> str: | |
| 16 | """ | |
| 17 | Retrieves a team's win-loss record from the standings data. | |
| 18 | ||
| 19 | Args: | |
| 20 | team_name (str): Name of the team. | |
| 21 | standings (dict): Team standings data. | |
| 22 | ||
| 23 | Returns: | |
| 24 | record (str): Team's win-loss record in 'W-L' format. Defaults to 'N/A'. | |
| 25 | """ | |
| 26 | for result_set in standings["resultSets"]: | |
| 27 | if result_set["name"] == "Standings": | |
| 28 | for team in result_set["rowSet"]: | |
| 29 | if team[4] == team_name: | |
| 30 | return f"{team[12]}-{team[13]}" | |
| 31 | return "N/A" | |
| 32 | ||
| 33 | ||
| 34 | def get_scoreboard_table(games, standings) -> str: | |
| 35 | """ | |
| 36 | Builds and returns the current day's games as a formatted table string. | |
| 37 | ||
| 38 | Args: | |
| 39 | games (dict): JSON parsed games data. | |
| 40 | standings (dict): Team standings data. | |
| 41 | ||
| 42 | Returns: | |
| 43 | str: Formatted table string with ANSI color codes. | |
| 44 | """ | |
| 45 | scoreboard_data = games["scoreboard"] | |
| 46 | game_list = scoreboard_data["games"] | |
| 47 | ||
| 48 | if not game_list: | |
| 49 | return "No games scheduled today." | |
| 50 | ||
| 51 | # Prepare the table data | |
| 52 | table_data = [] | |
| 53 | for game in game_list: | |
| 54 | home_team = game["homeTeam"]["teamName"] | |
| 55 | away_team = game["awayTeam"]["teamName"] | |
| 56 | game_status = game["gameStatusText"] | |
| 57 | home_score = game["homeTeam"]["score"] | |
| 58 | away_score = game["awayTeam"]["score"] | |
| 59 | ||
| 60 | home_record = get_team_record(home_team, standings) | |
| 61 | away_record = get_team_record(away_team, standings) | |
| 62 | ||
| 63 | # Determine the winning team | |
| 64 | if home_score > away_score: | |
| 65 | home_team_bold = f"{BOLD}{GREEN}{home_team} ({home_record}){END}{END}" | |
| 66 | away_team_bold = f"{away_team} ({away_record}){END}" | |
| 67 | home_score_bold = f"{BOLD}{GREEN}{home_score}{END}{END}" | |
| 68 | away_score_bold = f"{away_score}{END}" | |
| 69 | elif away_score > home_score: | |
| 70 | home_team_bold = f"{home_team} ({home_record}){END}" | |
| 71 | away_team_bold = f"{BOLD}{GREEN}{away_team} ({away_record}){END}{END}" | |
| 72 | home_score_bold = f"{home_score}{END}" | |
| 73 | away_score_bold = f"{BOLD}{GREEN}{away_score}{END}{END}" | |
| 74 | else: | |
| 75 | home_team_bold = f"{home_team} ({home_record})" | |
| 76 | away_team_bold = f"{away_team} ({away_record})" | |
| 77 | home_score_bold = f"{home_score}" | |
| 78 | away_score_bold = f"{away_score}" | |
| 79 | ||
| 80 | # Determine games still in progress | |
| 81 | if game_status != "Final": | |
| 82 | game_status = f"{RED}{game_status}{END}" | |
| 83 | ||
| 84 | table_data.append( | |
| 85 | [ | |
| 86 | f"{home_team_bold}\n{away_team_bold}", | |
| 87 | f"{home_score_bold}\n{away_score_bold}", | |
| 88 | f"{BOLD}{game_status}{END}", | |
| 89 | ] | |
| 90 | ) | |
| 91 | ||
| 92 | # Define the table headers | |
| 93 | headers = ["Team", "Score", "Game Status"] | |
| 94 | ||
| 95 | return tabulate(table_data, headers=headers, tablefmt="grid") | |
| 96 | ||
| 97 | ||
| 98 | def build_scoreboard(games, standings) -> None: | |
| 99 | """ | |
| 100 | Prints the current day's games in a table format. | |
| 101 | ||
| 102 | Args: | |
| 103 | games (dict): JSON parsed games data. | |
| 104 | standings (dict): Team standings data. | |
| 105 | """ | |
| 106 | print(get_scoreboard_table(games, standings)) | |
build/lib/nba/standings.py deleted −91
| @@ -1,91 +0,0 @@ | ||
| 1 | """ | |
| 2 | Tabulate the current conference standings. | |
| 3 | """ | |
| 4 | ||
| 5 | from tabulate import tabulate | |
| 6 | ||
| 7 | # ANSI escape codes for text formatting | |
| 8 | BOLD = "\033[1m" | |
| 9 | END = "\033[0m" | |
| 10 | RED = "\033[91m" | |
| 11 | GREEN = "\033[32m" | |
| 12 | ||
| 13 | ||
| 14 | def _build_conference_table(standings, conference: str) -> str: | |
| 15 | """Build a formatted table string for one conference.""" | |
| 16 | data = [] | |
| 17 | rank = 1 | |
| 18 | ||
| 19 | for result_set in standings["resultSets"]: | |
| 20 | if result_set["name"] == "Standings": | |
| 21 | for team in result_set["rowSet"]: | |
| 22 | if team[5] != conference: | |
| 23 | continue | |
| 24 | wins = team[12] | |
| 25 | losses = team[13] | |
| 26 | win_pct = team[14] | |
| 27 | streak = team[35] | |
| 28 | ||
| 29 | strk_color = ( | |
| 30 | f"{RED}{streak}{END}" | |
| 31 | if int(streak) < 0 | |
| 32 | else f"{GREEN}{streak}{END}" | |
| 33 | ) | |
| 34 | ||
| 35 | data.append( | |
| 36 | [ | |
| 37 | f"{rank}", | |
| 38 | team[4], | |
| 39 | f"{wins}-{losses}", | |
| 40 | f"{win_pct:.3f}", | |
| 41 | team[37], | |
| 42 | strk_color, | |
| 43 | team[19], | |
| 44 | team[17], | |
| 45 | team[18], | |
| 46 | ] | |
| 47 | ) | |
| 48 | rank += 1 | |
| 49 | ||
| 50 | headers = ["Rank", "Team", "W-L", "PCT", "GB", "STRK", "L10", "HOME", "AWAY"] | |
| 51 | label = "Eastern" if conference == "East" else "Western" | |
| 52 | return f"{BOLD}{label} Conference Standings:{END}\n" + tabulate( | |
| 53 | data, headers=headers, tablefmt="grid" | |
| 54 | ) | |
| 55 | ||
| 56 | ||
| 57 | def get_east_standings_table(standings) -> str: | |
| 58 | """Returns the Eastern Conference standings as a formatted string.""" | |
| 59 | return _build_conference_table(standings, "East") | |
| 60 | ||
| 61 | ||
| 62 | def get_west_standings_table(standings) -> str: | |
| 63 | """Returns the Western Conference standings as a formatted string.""" | |
| 64 | return _build_conference_table(standings, "West") | |
| 65 | ||
| 66 | ||
| 67 | def get_standings_tables(standings) -> str: | |
| 68 | """ | |
| 69 | Builds and returns both conference standings as a formatted string. | |
| 70 | ||
| 71 | Args: | |
| 72 | standings (dict): Team standings data. | |
| 73 | ||
| 74 | Returns: | |
| 75 | str: Formatted standings string with ANSI color codes for both conferences. | |
| 76 | """ | |
| 77 | return ( | |
| 78 | get_east_standings_table(standings) | |
| 79 | + "\n\n" | |
| 80 | + get_west_standings_table(standings) | |
| 81 | ) | |
| 82 | ||
| 83 | ||
| 84 | def build_standings(standings) -> None: | |
| 85 | """ | |
| 86 | Prints team standings in two separate tables. | |
| 87 | ||
| 88 | Args: | |
| 89 | standings (dict): Team standings data. | |
| 90 | """ | |
| 91 | print(get_standings_tables(standings)) | |
build/lib/nba/tui/__init__.py deleted
build/lib/nba/tui/app.py deleted −247
| @@ -1,247 +0,0 @@ | ||
| 1 | """ | |
| 2 | Textual TUI application for NBA scores and standings. | |
| 3 | """ | |
| 4 | ||
| 5 | from __future__ import annotations | |
| 6 | ||
| 7 | import asyncio | |
| 8 | from pathlib import Path | |
| 9 | ||
| 10 | from rich.text import Text | |
| 11 | from textual.app import App, ComposeResult | |
| 12 | from textual.containers import Horizontal | |
| 13 | from textual.widgets import Footer, Header, Static, TabbedContent, TabPane | |
| 14 | ||
| 15 | from nba import fetch_data | |
| 16 | from nba import bracket as bracket_mod | |
| 17 | from nba import leaders as leaders_mod | |
| 18 | from nba import playoff as playoff_mod | |
| 19 | from nba import box_score as box_score_mod | |
| 20 | from nba.scores import get_scoreboard_table | |
| 21 | from nba.standings import get_east_standings_table, get_west_standings_table | |
| 22 | from nba.tui.widgets import CountdownBar, ScoresWidget | |
| 23 | ||
| 24 | ||
| 25 | class NBAApp(App): | |
| 26 | """Live NBA scores and standings TUI with auto-refresh.""" | |
| 27 | ||
| 28 | CSS_PATH = Path(__file__).parent / "styles.tcss" | |
| 29 | ||
| 30 | BINDINGS = [ | |
| 31 | ("q", "quit", "Quit"), | |
| 32 | ("s", "show_scores", "Scores"), | |
| 33 | ("t", "show_standings", "Standings"), | |
| 34 | ("l", "show_leaders", "Leaders"), | |
| 35 | ("p", "show_playoff", "Playoff"), | |
| 36 | ("k", "show_bracket", "Bracket"), | |
| 37 | ("b", "show_boxscore", "Box Score"), | |
| 38 | ("r", "refresh_now", "Refresh"), | |
| 39 | ("comma", "prev_category", "◀ Cat"), | |
| 40 | ("full_stop", "next_category", "Cat ▶"), | |
| 41 | ] | |
| 42 | ||
| 43 | TITLE = "NBA Scores" | |
| 44 | ||
| 45 | def __init__(self, initial_tab: str = "scores", refresh_interval: int = 60) -> None: | |
| 46 | super().__init__() | |
| 47 | self.initial_tab = initial_tab | |
| 48 | self.refresh_interval = refresh_interval | |
| 49 | self._games: dict | None = None | |
| 50 | self._ranks: dict | None = None | |
| 51 | self._leaders_data: dict | None = None | |
| 52 | self._leaders_cat_idx: int = 0 | |
| 53 | self._playoff_data: dict | None = None | |
| 54 | self._bracket_data: dict | None = None | |
| 55 | ||
| 56 | def compose(self) -> ComposeResult: | |
| 57 | yield Header() | |
| 58 | with TabbedContent(initial=self.initial_tab): | |
| 59 | with TabPane("Scores", id="scores"): | |
| 60 | yield ScoresWidget("Loading...", id="scores-content") | |
| 61 | with TabPane("Standings", id="standings"): | |
| 62 | with Horizontal(id="standings-container"): | |
| 63 | yield Static("Loading...", id="west-content") | |
| 64 | yield Static("Loading...", id="east-content") | |
| 65 | with TabPane("Leaders", id="leaders"): | |
| 66 | yield Static("Loading...", id="leaders-content") | |
| 67 | with TabPane("Playoff", id="playoff"): | |
| 68 | with Horizontal(id="playoff-container"): | |
| 69 | yield Static("Loading...", id="playoff-west-content") | |
| 70 | yield Static("Loading...", id="playoff-east-content") | |
| 71 | with TabPane("Bracket", id="bracket"): | |
| 72 | yield Static("Loading...", id="bracket-content") | |
| 73 | with TabPane("Box Score", id="boxscore"): | |
| 74 | with Horizontal(id="boxscore-container"): | |
| 75 | yield Static( | |
| 76 | "Press 1–9 to load a game from the Scores tab.", | |
| 77 | id="home-content", | |
| 78 | ) | |
| 79 | yield Static("", id="away-content") | |
| 80 | yield CountdownBar(self.refresh_interval, id="countdown") | |
| 81 | yield Footer() | |
| 82 | ||
| 83 | async def on_mount(self) -> None: | |
| 84 | await self._do_refresh() | |
| 85 | self.set_interval(self.refresh_interval, self._do_refresh) | |
| 86 | ||
| 87 | # ------------------------------------------------------------------ # | |
| 88 | # Refresh logic # | |
| 89 | # ------------------------------------------------------------------ # | |
| 90 | ||
| 91 | async def _do_refresh(self) -> None: | |
| 92 | """Fetch scores, standings, leaders, playoff picture, and bracket in parallel.""" | |
| 93 | loop = asyncio.get_event_loop() | |
| 94 | cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] | |
| 95 | ||
| 96 | results = await asyncio.gather( | |
| 97 | loop.run_in_executor(None, fetch_data.fetch_data), | |
| 98 | loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)), | |
| 99 | loop.run_in_executor(None, playoff_mod.fetch_playoff_picture), | |
| 100 | loop.run_in_executor(None, bracket_mod.fetch_bracket), | |
| 101 | return_exceptions=True, | |
| 102 | ) | |
| 103 | ||
| 104 | games_ranks, leaders_data, playoff_data, bracket_data = results | |
| 105 | ||
| 106 | if not isinstance(games_ranks, Exception): | |
| 107 | self._games, self._ranks = games_ranks | |
| 108 | if not isinstance(leaders_data, Exception): | |
| 109 | self._leaders_data = leaders_data | |
| 110 | if not isinstance(playoff_data, Exception): | |
| 111 | self._playoff_data = playoff_data | |
| 112 | if not isinstance(bracket_data, Exception): | |
| 113 | self._bracket_data = bracket_data | |
| 114 | ||
| 115 | self._update_widgets() | |
| 116 | self.query_one(CountdownBar).reset(self.refresh_interval) | |
| 117 | ||
| 118 | def _update_widgets(self) -> None: | |
| 119 | if self._games and self._ranks: | |
| 120 | self.query_one(ScoresWidget).update( | |
| 121 | Text.from_ansi(get_scoreboard_table(self._games, self._ranks)) | |
| 122 | ) | |
| 123 | self.query_one("#west-content", Static).update( | |
| 124 | Text.from_ansi(get_west_standings_table(self._ranks)) | |
| 125 | ) | |
| 126 | self.query_one("#east-content", Static).update( | |
| 127 | Text.from_ansi(get_east_standings_table(self._ranks)) | |
| 128 | ) | |
| 129 | ||
| 130 | if self._leaders_data: | |
| 131 | cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] | |
| 132 | self.query_one("#leaders-content", Static).update( | |
| 133 | Text.from_ansi(leaders_mod.get_leaders_table(self._leaders_data, cat)) | |
| 134 | ) | |
| 135 | ||
| 136 | if self._playoff_data: | |
| 137 | self.query_one("#playoff-west-content", Static).update( | |
| 138 | Text.from_ansi(playoff_mod.get_west_playoff_table(self._playoff_data)) | |
| 139 | ) | |
| 140 | self.query_one("#playoff-east-content", Static).update( | |
| 141 | Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data)) | |
| 142 | ) | |
| 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 | ||
| 149 | # ------------------------------------------------------------------ # | |
| 150 | # Key handlers # | |
| 151 | # ------------------------------------------------------------------ # | |
| 152 | ||
| 153 | def on_key(self, event) -> None: | |
| 154 | """Handle 1–9 to select a game for the box score tab.""" | |
| 155 | char = event.character | |
| 156 | if char and char.isdigit() and char != "0": | |
| 157 | asyncio.create_task(self._load_box_score(int(char) - 1)) | |
| 158 | ||
| 159 | # ------------------------------------------------------------------ # | |
| 160 | # Actions # | |
| 161 | # ------------------------------------------------------------------ # | |
| 162 | ||
| 163 | async def action_refresh_now(self) -> None: | |
| 164 | await self._do_refresh() | |
| 165 | ||
| 166 | def action_show_scores(self) -> None: | |
| 167 | self.query_one(TabbedContent).active = "scores" | |
| 168 | ||
| 169 | def action_show_standings(self) -> None: | |
| 170 | self.query_one(TabbedContent).active = "standings" | |
| 171 | ||
| 172 | def action_show_leaders(self) -> None: | |
| 173 | self.query_one(TabbedContent).active = "leaders" | |
| 174 | ||
| 175 | def action_show_playoff(self) -> None: | |
| 176 | self.query_one(TabbedContent).active = "playoff" | |
| 177 | ||
| 178 | def action_show_bracket(self) -> None: | |
| 179 | self.query_one(TabbedContent).active = "bracket" | |
| 180 | ||
| 181 | def action_show_boxscore(self) -> None: | |
| 182 | self.query_one(TabbedContent).active = "boxscore" | |
| 183 | ||
| 184 | async def action_prev_category(self) -> None: | |
| 185 | self._leaders_cat_idx = (self._leaders_cat_idx - 1) % len( | |
| 186 | leaders_mod.CATEGORIES | |
| 187 | ) | |
| 188 | await self._refresh_leaders() | |
| 189 | ||
| 190 | async def action_next_category(self) -> None: | |
| 191 | self._leaders_cat_idx = (self._leaders_cat_idx + 1) % len( | |
| 192 | leaders_mod.CATEGORIES | |
| 193 | ) | |
| 194 | await self._refresh_leaders() | |
| 195 | ||
| 196 | # ------------------------------------------------------------------ # | |
| 197 | # Helpers # | |
| 198 | # ------------------------------------------------------------------ # | |
| 199 | ||
| 200 | async def _refresh_leaders(self) -> None: | |
| 201 | cat, label = leaders_mod.CATEGORIES[self._leaders_cat_idx] | |
| 202 | self.query_one("#leaders-content", Static).update(f"Loading {label} leaders...") | |
| 203 | loop = asyncio.get_event_loop() | |
| 204 | try: | |
| 205 | data = await loop.run_in_executor( | |
| 206 | None, lambda: leaders_mod.fetch_leaders(cat) | |
| 207 | ) | |
| 208 | self._leaders_data = data | |
| 209 | self.query_one("#leaders-content", Static).update( | |
| 210 | Text.from_ansi(leaders_mod.get_leaders_table(data, cat)) | |
| 211 | ) | |
| 212 | except Exception as exc: | |
| 213 | self.query_one("#leaders-content", Static).update( | |
| 214 | f"Error loading leaders: {exc}" | |
| 215 | ) | |
| 216 | ||
| 217 | async def _load_box_score(self, game_idx: int) -> None: | |
| 218 | if not self._games: | |
| 219 | return | |
| 220 | games = self._games["scoreboard"]["games"] | |
| 221 | if game_idx >= len(games): | |
| 222 | return | |
| 223 | ||
| 224 | game = games[game_idx] | |
| 225 | game_id = game["gameId"] | |
| 226 | home = game["homeTeam"]["teamName"] | |
| 227 | away = game["awayTeam"]["teamName"] | |
| 228 | ||
| 229 | self.query_one("#home-content", Static).update( | |
| 230 | f"Loading box score: {away} @ {home}..." | |
| 231 | ) | |
| 232 | self.query_one("#away-content", Static).update("") | |
| 233 | self.query_one(TabbedContent).active = "boxscore" | |
| 234 | ||
| 235 | loop = asyncio.get_event_loop() | |
| 236 | try: | |
| 237 | data = await loop.run_in_executor( | |
| 238 | None, lambda: box_score_mod.fetch_box_score(game_id) | |
| 239 | ) | |
| 240 | home_table, away_table = box_score_mod.get_box_score_tables(data) | |
| 241 | self.query_one("#home-content", Static).update(Text.from_ansi(home_table)) | |
| 242 | self.query_one("#away-content", Static).update(Text.from_ansi(away_table)) | |
| 243 | except Exception as exc: | |
| 244 | self.query_one("#home-content", Static).update( | |
| 245 | f"Error loading box score: {exc}" | |
| 246 | ) | |
| 247 | self.query_one("#away-content", Static).update("") | |
build/lib/nba/tui/styles.tcss deleted −59
| @@ -1,59 +0,0 @@ | ||
| 1 | Screen { | |
| 2 | background: $background; | |
| 3 | } | |
| 4 | ||
| 5 | TabbedContent { | |
| 6 | height: 1fr; | |
| 7 | } | |
| 8 | ||
| 9 | TabPane { | |
| 10 | overflow: auto auto; | |
| 11 | padding: 1 2; | |
| 12 | } | |
| 13 | ||
| 14 | /* Scores tab */ | |
| 15 | #scores-content { | |
| 16 | width: auto; | |
| 17 | } | |
| 18 | ||
| 19 | /* Standings, Playoff, Bracket, and Box Score tabs */ | |
| 20 | #standings-container, | |
| 21 | #playoff-container, | |
| 22 | #boxscore-container { | |
| 23 | height: 1fr; | |
| 24 | width: 100%; | |
| 25 | } | |
| 26 | ||
| 27 | #east-content, | |
| 28 | #west-content, | |
| 29 | #playoff-east-content, | |
| 30 | #playoff-west-content, | |
| 31 | #bracket-content { | |
| 32 | width: 1fr; | |
| 33 | overflow: auto auto; | |
| 34 | padding-right: 2; | |
| 35 | } | |
| 36 | ||
| 37 | /* Leaders tab */ | |
| 38 | #leaders-content { | |
| 39 | width: auto; | |
| 40 | overflow: auto auto; | |
| 41 | } | |
| 42 | ||
| 43 | /* Box Score tab */ | |
| 44 | #home-content, | |
| 45 | #away-content { | |
| 46 | width: 1fr; | |
| 47 | overflow: auto auto; | |
| 48 | padding-right: 2; | |
| 49 | } | |
| 50 | ||
| 51 | /* Countdown / status bar */ | |
| 52 | #countdown { | |
| 53 | dock: bottom; | |
| 54 | height: 1; | |
| 55 | background: $panel; | |
| 56 | color: $text-muted; | |
| 57 | content-align: right middle; | |
| 58 | padding-right: 2; | |
| 59 | } | |
build/lib/nba/tui/widgets.py deleted −51
| @@ -1,51 +0,0 @@ | ||
| 1 | """ | |
| 2 | Custom Textual widgets for the NBA scores TUI. | |
| 3 | """ | |
| 4 | ||
| 5 | from textual.reactive import reactive | |
| 6 | from textual.widgets import Static | |
| 7 | ||
| 8 | ||
| 9 | class ScoresWidget(Static): | |
| 10 | """Displays the NBA scoreboard as a scrollable ANSI-formatted table.""" | |
| 11 | ||
| 12 | ||
| 13 | class StandingsWidget(Static): | |
| 14 | """Displays the NBA standings as a scrollable ANSI-formatted table.""" | |
| 15 | ||
| 16 | ||
| 17 | class CountdownBar(Static): | |
| 18 | """ | |
| 19 | Docked status bar that counts down to the next auto-refresh. | |
| 20 | ||
| 21 | Maintains its own 1-second ticker and re-renders via a reactive attribute. | |
| 22 | """ | |
| 23 | ||
| 24 | seconds: reactive[int] = reactive(60) | |
| 25 | ||
| 26 | def __init__(self, interval: int, **kwargs) -> None: | |
| 27 | super().__init__(**kwargs) | |
| 28 | self._interval = interval | |
| 29 | self.seconds = interval | |
| 30 | ||
| 31 | def on_mount(self) -> None: | |
| 32 | self.set_interval(1, self._tick) | |
| 33 | ||
| 34 | def _tick(self) -> None: | |
| 35 | if self.seconds > 0: | |
| 36 | self.seconds -= 1 | |
| 37 | ||
| 38 | def watch_seconds(self, value: int) -> None: | |
| 39 | if value <= 0: | |
| 40 | self.update("Refreshing...") | |
| 41 | else: | |
| 42 | self.update( | |
| 43 | f"Next refresh in {value}s | " | |
| 44 | "\\[1-9] Box Score | \\[k] Bracket | " | |
| 45 | "\\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" | |
| 46 | ) | |
| 47 | ||
| 48 | def reset(self, interval: int) -> None: | |
| 49 | """Reset the countdown to the given interval.""" | |
| 50 | self._interval = interval | |
| 51 | self.seconds = interval | |
nba_scores.egg-info/PKG-INFO deleted −112
| @@ -1,112 +0,0 @@ | ||
| 1 | Metadata-Version: 2.4 | |
| 2 | Name: nba-scores | |
| 3 | Version: 0.2.3 | |
| 4 | Summary: A CLI interface to get current NBA scores and standings. | |
| 5 | Author-email: Christian Cleberg <hello@cleberg.net> | |
| 6 | License-Expression: GPL-3.0-or-later | |
| 7 | Project-URL: Homepage, https://git.cleberg.net/nba-scores.git | |
| 8 | Project-URL: Issues, https://git.cleberg.net/nba-scores.git | |
| 9 | Classifier: Programming Language :: Python :: 3 | |
| 10 | Classifier: Operating System :: OS Independent | |
| 11 | Requires-Python: >=3.9 | |
| 12 | Description-Content-Type: text/markdown | |
| 13 | License-File: LICENSE | |
| 14 | Requires-Dist: nba_api | |
| 15 | Requires-Dist: tabulate | |
| 16 | Requires-Dist: argparse | |
| 17 | Requires-Dist: textual<7.0,>=0.89.1 | |
| 18 | Dynamic: license-file | |
| 19 | ||
| 20 | # NBA | |
| 21 | ||
| 22 |  | |
| 23 | ||
| 24 | NBA is a Python package that provides a command-line interface to current NBA | |
| 25 | scores, standings, statistical leaders, playoff picture, and live box scores. | |
| 26 | It supports both a static one-shot mode and an interactive TUI with | |
| 27 | auto-refresh. | |
| 28 | ||
| 29 | # Table of Contents | |
| 30 | ||
| 31 | - [Installation](#installation) | |
| 32 | - [Usage](#usage) | |
| 33 | - [Interactive TUI](#interactive-tui) | |
| 34 | - [Static output](#static-output) | |
| 35 | - [Contributing](#contributing) | |
| 36 | ||
| 37 | # Installation | |
| 38 | ||
| 39 | [Back to top](#table-of-contents) | |
| 40 | ||
| 41 | ```shell | |
| 42 | git clone https://git.cleberg.net/nba-scores.git | |
| 43 | cd nba-scores | |
| 44 | pipx install . | |
| 45 | ``` | |
| 46 | ||
| 47 | # Usage | |
| 48 | ||
| 49 | [Back to top](#table-of-contents) | |
| 50 | ||
| 51 | ## Interactive TUI | |
| 52 | ||
| 53 | Launch the full interactive terminal UI: | |
| 54 | ||
| 55 | ```shell | |
| 56 | nba --tui | |
| 57 | ``` | |
| 58 | ||
| 59 | Optional flags: | |
| 60 | ||
| 61 | | Flag | Description | | |
| 62 | |--------------------|--------------------------------------------------------| | |
| 63 | | `--tui` | Launch the interactive TUI | | |
| 64 | | `--scores` | Open TUI on the Scores tab (default) | | |
| 65 | | `--standings` | Open TUI on the Standings tab | | |
| 66 | | `--refresh N` | Auto-refresh interval in seconds (default: 60, min: 10)| | |
| 67 | ||
| 68 | ### TUI tabs | |
| 69 | ||
| 70 | | Tab | Key | Description | | |
| 71 | |-----------------|-----|----------------------------------------------------------| | |
| 72 | | Scores | `s` | Today's games with live scores and status | | |
| 73 | | Standings | `t` | East / West conference standings, side by side | | |
| 74 | | Leaders | `l` | Top 25 players by stat category | | |
| 75 | | Playoff Picture | `p` | Conference seeding with clinch and elimination status | | |
| 76 | | Bracket | `k` | Playoff series matchups and series records | | |
| 77 | | Box Score | `b` | Per-player live stats for a selected game | | |
| 78 | ||
| 79 | ### TUI key bindings | |
| 80 | ||
| 81 | | Key | Action | | |
| 82 | |---------|------------------------------------------------| | |
| 83 | | `1`–`9` | Load box score for game N from the Scores tab | | |
| 84 | | `,` | Cycle to the previous leaders stat category | | |
| 85 | | `.` | Cycle to the next leaders stat category | | |
| 86 | | `r` | Refresh all data immediately | | |
| 87 | | `q` | Quit | | |
| 88 | ||
| 89 | ### Leaders stat categories | |
| 90 | ||
| 91 | Cycles through: Points, Rebounds, Assists, Steals, Blocks, Efficiency, FG%, FT%, 3P% | |
| 92 | ||
| 93 | ## Static output | |
| 94 | ||
| 95 | Print scores or standings directly to the terminal without launching the TUI: | |
| 96 | ||
| 97 | | Argument | Shortcut | Description | | |
| 98 | |---------------|----------|-----------------------------------| | |
| 99 | | `--scores` | `-sc` | Print today's scoreboard | | |
| 100 | | `--standings` | `-st` | Print current conference standings| | |
| 101 | ||
| 102 | ```shell | |
| 103 | nba --scores | |
| 104 | nba --standings | |
| 105 | ``` | |
| 106 | ||
| 107 | # Contributing | |
| 108 | ||
| 109 | [Back to top](#table-of-contents) | |
| 110 | ||
| 111 | Any and all contributions are welcome. Feel free to fork the project, | |
| 112 | add features, and submit a pull request. | |
nba_scores.egg-info/SOURCES.txt deleted −23
| @@ -1,23 +0,0 @@ | ||
| 1 | LICENSE | |
| 2 | README.md | |
| 3 | pyproject.toml | |
| 4 | nba/__init__.py | |
| 5 | nba/__main__.py | |
| 6 | nba/box_score.py | |
| 7 | nba/bracket.py | |
| 8 | nba/cli.py | |
| 9 | nba/fetch_data.py | |
| 10 | nba/leaders.py | |
| 11 | nba/playoff.py | |
| 12 | nba/scores.py | |
| 13 | nba/standings.py | |
| 14 | nba/tui/__init__.py | |
| 15 | nba/tui/app.py | |
| 16 | nba/tui/styles.tcss | |
| 17 | nba/tui/widgets.py | |
| 18 | nba_scores.egg-info/PKG-INFO | |
| 19 | nba_scores.egg-info/SOURCES.txt | |
| 20 | nba_scores.egg-info/dependency_links.txt | |
| 21 | nba_scores.egg-info/entry_points.txt | |
| 22 | nba_scores.egg-info/requires.txt | |
| 23 | nba_scores.egg-info/top_level.txt | |
| \ No newline at end of file | ||
nba_scores.egg-info/dependency_links.txt deleted −1
| @@ -1 +0,0 @@ | ||
| 1 | ||
nba_scores.egg-info/entry_points.txt deleted −2
| @@ -1,2 +0,0 @@ | ||
| 1 | [console_scripts] | |
| 2 | nba = nba.cli:nba | |
nba_scores.egg-info/requires.txt deleted −4
| @@ -1,4 +0,0 @@ | ||
| 1 | nba_api | |
| 2 | tabulate | |
| 3 | argparse | |
| 4 | textual<7.0,>=0.89.1 | |
nba_scores.egg-info/top_level.txt deleted −1
| @@ -1 +0,0 @@ | ||
| 1 | nba | |