Commit 232e808bd8
Verified · cmc
Layout: unified · split
.gitignore +3
| @@ -2,5 +2,8 @@ | |||
| 2 | venv/ | 2 | venv/ |
| 3 | dist/ | 3 | dist/ |
| 4 | nba-scores.egg-info* | 4 | nba-scores.egg-info* |
| 5 | nba_scores.egg-info/ | ||
| 5 | nba/__pycache__/ | 6 | nba/__pycache__/ |
| 6 | nba/tui/__pycache__/ | 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 | ||