Commit 4d40494f3e
Verified · cmc
Layout: unified · split
README.md +1 −9
| @@ -19,18 +19,10 @@ auto-refresh. | ||
| 19 | 19 | |
| 20 | 20 | [Back to top](#table-of-contents) |
| 21 | 21 | |
| 22 | ## PyPi | |
| 23 | ||
| 24 | ```shell | |
| 25 | pipx install nba-scores | |
| 26 | ``` | |
| 27 | ||
| 28 | ## Manual | |
| 29 | ||
| 30 | 22 | ```shell |
| 31 | 23 | git clone https://git.cleberg.net/nba-scores.git |
| 32 | 24 | cd nba-scores |
| 33 | uv sync | |
| 25 | pipx install . | |
| 34 | 26 | ``` |
| 35 | 27 | |
| 36 | 28 | # Usage |
build/lib/nba/__init__.py added
build/lib/nba/__main__.py added +8
| @@ -0,0 +1,8 @@ | ||
| 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/box_score.py added +81
| @@ -0,0 +1,81 @@ | ||
| 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 = ["Player", "Pos", "Min", "Pts", "Reb", "Ast", "Stl", "Blk", "TO", "FG", "3P", "FT", "+/-"] | |
| 14 | ||
| 15 | ||
| 16 | def fetch_box_score(game_id: str) -> dict: | |
| 17 | endpoint = BoxScore(game_id=game_id) | |
| 18 | return json.loads(endpoint.get_json()) | |
| 19 | ||
| 20 | ||
| 21 | def _parse_minutes(raw: str) -> str: | |
| 22 | """Convert 'PT35M24.00S' → '35:24'.""" | |
| 23 | if not raw: | |
| 24 | return "0:00" | |
| 25 | try: | |
| 26 | raw = raw.replace("PT", "").replace("S", "") | |
| 27 | mins, secs = raw.split("M") | |
| 28 | return f"{int(mins)}:{int(float(secs)):02d}" | |
| 29 | except Exception: | |
| 30 | return raw | |
| 31 | ||
| 32 | ||
| 33 | def _player_rows(players: list) -> list: | |
| 34 | rows = [] | |
| 35 | for p in players: | |
| 36 | if p.get("status") == "INACTIVE": | |
| 37 | continue | |
| 38 | s = p.get("statistics", {}) | |
| 39 | rows.append([ | |
| 40 | p.get("name", ""), | |
| 41 | p.get("position", ""), | |
| 42 | _parse_minutes(s.get("minutes", "")), | |
| 43 | s.get("points", 0), | |
| 44 | s.get("reboundsTotal", 0), | |
| 45 | s.get("assists", 0), | |
| 46 | s.get("steals", 0), | |
| 47 | s.get("blocks", 0), | |
| 48 | s.get("turnovers", 0), | |
| 49 | f"{s.get('fieldGoalsMade', 0)}/{s.get('fieldGoalsAttempted', 0)}", | |
| 50 | f"{s.get('threePointersMade', 0)}/{s.get('threePointersAttempted', 0)}", | |
| 51 | f"{s.get('freeThrowsMade', 0)}/{s.get('freeThrowsAttempted', 0)}", | |
| 52 | s.get("plusMinusPoints", 0), | |
| 53 | ]) | |
| 54 | return rows | |
| 55 | ||
| 56 | ||
| 57 | def get_box_score_tables(data: dict) -> tuple: | |
| 58 | """Return (home_table_str, away_table_str).""" | |
| 59 | game = data["game"] | |
| 60 | home = game["homeTeam"] | |
| 61 | away = game["awayTeam"] | |
| 62 | ||
| 63 | home_title = ( | |
| 64 | f"{BOLD}{home['teamCity']} {home['teamName']} " | |
| 65 | f"{home['score']} — {away['score']} " | |
| 66 | f"{away['teamCity']} {away['teamName']}{END} " | |
| 67 | f" [{game.get('gameStatusText', '')}]" | |
| 68 | ) | |
| 69 | ||
| 70 | home_rows = _player_rows(home.get("players", [])) | |
| 71 | away_rows = _player_rows(away.get("players", [])) | |
| 72 | ||
| 73 | home_table = ( | |
| 74 | f"{BOLD}{home['teamCity']} {home['teamName']} ({home['score']}){END}\n" | |
| 75 | + tabulate(home_rows, headers=_HEADERS, tablefmt="grid") | |
| 76 | ) | |
| 77 | away_table = ( | |
| 78 | f"{BOLD}{away['teamCity']} {away['teamName']} ({away['score']}){END}\n" | |
| 79 | + tabulate(away_rows, headers=_HEADERS, tablefmt="grid") | |
| 80 | ) | |
| 81 | return home_table, away_table | |
build/lib/nba/cli.py added +50
| @@ -0,0 +1,50 @@ | ||
| 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( | |
| 23 | "--tui", action="store_true", help="Launch the interactive TUI" | |
| 24 | ) | |
| 25 | parser.add_argument( | |
| 26 | "--refresh", | |
| 27 | type=int, | |
| 28 | default=60, | |
| 29 | metavar="SECONDS", | |
| 30 | help="Auto-refresh interval in TUI mode (default: 60, minimum: 10)", | |
| 31 | ) | |
| 32 | args = parser.parse_args() | |
| 33 | ||
| 34 | if args.tui: | |
| 35 | from nba.tui.app import NBAApp | |
| 36 | ||
| 37 | initial_tab = "standings" if args.standings else "scores" | |
| 38 | refresh_interval = max(args.refresh, 10) | |
| 39 | NBAApp(initial_tab=initial_tab, refresh_interval=refresh_interval).run() | |
| 40 | return | |
| 41 | ||
| 42 | # Legacy static mode | |
| 43 | games, ranks = fetch_data.fetch_data() | |
| 44 | ||
| 45 | if args.scores: | |
| 46 | scores.build_scoreboard(games, ranks) | |
| 47 | elif args.standings: | |
| 48 | standings.build_standings(ranks) | |
| 49 | else: | |
| 50 | print("Please specify --scores or --standings (or use --tui for interactive mode)") | |
build/lib/nba/fetch_data.py added +30
| @@ -0,0 +1,30 @@ | ||
| 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 added +72
| @@ -0,0 +1,72 @@ | ||
| 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%", "FT_PCT": "FT%", "FG3_PCT": "3P%", | |
| 41 | "FG3M": "3PM", "FG3A": "3PA", | |
| 42 | "AST_TOV": "AST/TO", | |
| 43 | } | |
| 44 | ||
| 45 | ||
| 46 | def fetch_leaders(category: str = "PTS") -> dict: | |
| 47 | endpoint = LeagueLeaders( | |
| 48 | stat_category_abbreviation=category, | |
| 49 | season_type_all_star="Regular Season", | |
| 50 | ) | |
| 51 | return json.loads(endpoint.get_json()) | |
| 52 | ||
| 53 | ||
| 54 | def get_leaders_table(data: dict, category: str = "PTS") -> str: | |
| 55 | result = data["resultSet"] | |
| 56 | headers = result["headers"] | |
| 57 | rows = result["rowSet"] | |
| 58 | ||
| 59 | base = ["RANK", "PLAYER", "TEAM", "GP"] | |
| 60 | extra = [c for c in _EXTRA_COLS.get(category, [category]) if c not in base] | |
| 61 | wanted = base + extra | |
| 62 | ||
| 63 | idx = {h: i for i, h in enumerate(headers)} | |
| 64 | table_data = [ | |
| 65 | [row[idx[col]] for col in wanted if col in idx] | |
| 66 | for row in rows[:25] | |
| 67 | ] | |
| 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 added +80
| @@ -0,0 +1,80 @@ | ||
| 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 | def val(col): | |
| 25 | return row[idx[col]] if col in idx else None | |
| 26 | ||
| 27 | if val("CLINCHED_CONFERENCE"): | |
| 28 | return f"{BOLD}{GREEN}z-Clinched Conf{END}" | |
| 29 | if val("CLINCHED_DIVISION") or val("CLINCHED_PLAYOFFS"): | |
| 30 | return f"{GREEN}x-Clinched{END}" | |
| 31 | if val("Clinched_Play_In"): | |
| 32 | return f"{YELLOW}pi-Play-In{END}" | |
| 33 | if val("ELIMINATED_PLAYOFFS"): | |
| 34 | return f"{RED}e-Eliminated{END}" | |
| 35 | return "" | |
| 36 | ||
| 37 | ||
| 38 | def _build_conference_table(result_sets: list, name: str) -> str: | |
| 39 | """Build a formatted playoff standings table for one conference.""" | |
| 40 | rs = next((r for r in result_sets if r["name"] == name), None) | |
| 41 | if rs is None or not rs["rowSet"]: | |
| 42 | return "No data available." | |
| 43 | ||
| 44 | headers = rs["headers"] | |
| 45 | rows = rs["rowSet"] | |
| 46 | idx = {h: i for i, h in enumerate(headers)} | |
| 47 | ||
| 48 | def get(row, col, default=""): | |
| 49 | return row[idx[col]] if col in idx else default | |
| 50 | ||
| 51 | table_data = [] | |
| 52 | for row in rows: | |
| 53 | wins = get(row, "WINS") | |
| 54 | losses = get(row, "LOSSES") | |
| 55 | pct = get(row, "PCT") | |
| 56 | pct_str = f"{float(pct):.3f}" if pct not in ("", None) else "" | |
| 57 | table_data.append([ | |
| 58 | get(row, "RANK"), | |
| 59 | get(row, "TEAM"), | |
| 60 | f"{wins}-{losses}", | |
| 61 | pct_str, | |
| 62 | get(row, "GB"), | |
| 63 | get(row, "HOME"), | |
| 64 | get(row, "AWAY"), | |
| 65 | get(row, "CONF"), | |
| 66 | _clinch_status(row, idx), | |
| 67 | ]) | |
| 68 | ||
| 69 | display_headers = ["#", "Team", "W-L", "PCT", "GB", "HOME", "AWAY", "CONF", "Status"] | |
| 70 | conf_label = "Eastern" if "East" in name else "Western" | |
| 71 | title = f"{BOLD}{conf_label} Conference Playoff Picture:{END}" | |
| 72 | return title + "\n" + tabulate(table_data, headers=display_headers, tablefmt="grid") | |
| 73 | ||
| 74 | ||
| 75 | def get_west_playoff_table(data: dict) -> str: | |
| 76 | return _build_conference_table(data["resultSets"], "WestConfStandings") | |
| 77 | ||
| 78 | ||
| 79 | def get_east_playoff_table(data: dict) -> str: | |
| 80 | return _build_conference_table(data["resultSets"], "EastConfStandings") | |
build/lib/nba/scores.py added +106
| @@ -0,0 +1,106 @@ | ||
| 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 added +86
| @@ -0,0 +1,86 @@ | ||
| 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}" if int(streak) < 0 else f"{GREEN}{streak}{END}" | |
| 31 | ) | |
| 32 | ||
| 33 | data.append( | |
| 34 | [ | |
| 35 | f"{rank}", | |
| 36 | team[4], | |
| 37 | f"{wins}-{losses}", | |
| 38 | f"{win_pct:.3f}", | |
| 39 | team[37], | |
| 40 | strk_color, | |
| 41 | team[19], | |
| 42 | team[17], | |
| 43 | team[18], | |
| 44 | ] | |
| 45 | ) | |
| 46 | rank += 1 | |
| 47 | ||
| 48 | headers = ["Rank", "Team", "W-L", "PCT", "GB", "STRK", "L10", "HOME", "AWAY"] | |
| 49 | label = "Eastern" if conference == "East" else "Western" | |
| 50 | return ( | |
| 51 | f"{BOLD}{label} Conference Standings:{END}\n" | |
| 52 | + tabulate(data, headers=headers, tablefmt="grid") | |
| 53 | ) | |
| 54 | ||
| 55 | ||
| 56 | def get_east_standings_table(standings) -> str: | |
| 57 | """Returns the Eastern Conference standings as a formatted string.""" | |
| 58 | return _build_conference_table(standings, "East") | |
| 59 | ||
| 60 | ||
| 61 | def get_west_standings_table(standings) -> str: | |
| 62 | """Returns the Western Conference standings as a formatted string.""" | |
| 63 | return _build_conference_table(standings, "West") | |
| 64 | ||
| 65 | ||
| 66 | def get_standings_tables(standings) -> str: | |
| 67 | """ | |
| 68 | Builds and returns both conference standings as a formatted string. | |
| 69 | ||
| 70 | Args: | |
| 71 | standings (dict): Team standings data. | |
| 72 | ||
| 73 | Returns: | |
| 74 | str: Formatted standings string with ANSI color codes for both conferences. | |
| 75 | """ | |
| 76 | return get_east_standings_table(standings) + "\n\n" + get_west_standings_table(standings) | |
| 77 | ||
| 78 | ||
| 79 | def build_standings(standings) -> None: | |
| 80 | """ | |
| 81 | Prints team standings in two separate tables. | |
| 82 | ||
| 83 | Args: | |
| 84 | standings (dict): Team standings data. | |
| 85 | """ | |
| 86 | print(get_standings_tables(standings)) | |
build/lib/nba/tui/__init__.py added
build/lib/nba/tui/app.py added +221
| @@ -0,0 +1,221 @@ | ||
| 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 leaders as leaders_mod | |
| 17 | from nba import playoff as playoff_mod | |
| 18 | from nba import box_score as box_score_mod | |
| 19 | from nba.scores import get_scoreboard_table | |
| 20 | from nba.standings import get_east_standings_table, get_west_standings_table | |
| 21 | from nba.tui.widgets import CountdownBar, ScoresWidget | |
| 22 | ||
| 23 | ||
| 24 | class NBAApp(App): | |
| 25 | """Live NBA scores and standings TUI with auto-refresh.""" | |
| 26 | ||
| 27 | CSS_PATH = Path(__file__).parent / "styles.tcss" | |
| 28 | ||
| 29 | BINDINGS = [ | |
| 30 | ("q", "quit", "Quit"), | |
| 31 | ("s", "show_scores", "Scores"), | |
| 32 | ("t", "show_standings", "Standings"), | |
| 33 | ("l", "show_leaders", "Leaders"), | |
| 34 | ("p", "show_playoff", "Playoff"), | |
| 35 | ("b", "show_boxscore", "Box Score"), | |
| 36 | ("r", "refresh_now", "Refresh"), | |
| 37 | ("comma", "prev_category", "◀ Cat"), | |
| 38 | ("full_stop", "next_category", "Cat ▶"), | |
| 39 | ] | |
| 40 | ||
| 41 | TITLE = "NBA Scores" | |
| 42 | ||
| 43 | def __init__(self, initial_tab: str = "scores", refresh_interval: int = 60) -> None: | |
| 44 | super().__init__() | |
| 45 | self.initial_tab = initial_tab | |
| 46 | self.refresh_interval = refresh_interval | |
| 47 | self._games: dict | None = None | |
| 48 | self._ranks: dict | None = None | |
| 49 | self._leaders_data: dict | None = None | |
| 50 | self._leaders_cat_idx: int = 0 | |
| 51 | self._playoff_data: dict | None = None | |
| 52 | ||
| 53 | def compose(self) -> ComposeResult: | |
| 54 | yield Header() | |
| 55 | with TabbedContent(initial=self.initial_tab): | |
| 56 | with TabPane("Scores", id="scores"): | |
| 57 | yield ScoresWidget("Loading...", id="scores-content") | |
| 58 | with TabPane("Standings", id="standings"): | |
| 59 | with Horizontal(id="standings-container"): | |
| 60 | yield Static("Loading...", id="west-content") | |
| 61 | yield Static("Loading...", id="east-content") | |
| 62 | with TabPane("Leaders", id="leaders"): | |
| 63 | yield Static("Loading...", id="leaders-content") | |
| 64 | with TabPane("Playoff", id="playoff"): | |
| 65 | with Horizontal(id="playoff-container"): | |
| 66 | yield Static("Loading...", id="playoff-west-content") | |
| 67 | yield Static("Loading...", id="playoff-east-content") | |
| 68 | with TabPane("Box Score", id="boxscore"): | |
| 69 | with Horizontal(id="boxscore-container"): | |
| 70 | yield Static( | |
| 71 | "Press 1–9 to load a game from the Scores tab.", | |
| 72 | id="home-content", | |
| 73 | ) | |
| 74 | yield Static("", id="away-content") | |
| 75 | yield CountdownBar(self.refresh_interval, id="countdown") | |
| 76 | yield Footer() | |
| 77 | ||
| 78 | async def on_mount(self) -> None: | |
| 79 | await self._do_refresh() | |
| 80 | self.set_interval(self.refresh_interval, self._do_refresh) | |
| 81 | ||
| 82 | # ------------------------------------------------------------------ # | |
| 83 | # Refresh logic # | |
| 84 | # ------------------------------------------------------------------ # | |
| 85 | ||
| 86 | async def _do_refresh(self) -> None: | |
| 87 | """Fetch scores, standings, leaders, and playoff picture in parallel.""" | |
| 88 | loop = asyncio.get_event_loop() | |
| 89 | cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] | |
| 90 | ||
| 91 | results = await asyncio.gather( | |
| 92 | loop.run_in_executor(None, fetch_data.fetch_data), | |
| 93 | loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)), | |
| 94 | loop.run_in_executor(None, playoff_mod.fetch_playoff_picture), | |
| 95 | return_exceptions=True, | |
| 96 | ) | |
| 97 | ||
| 98 | games_ranks, leaders_data, playoff_data = results | |
| 99 | ||
| 100 | if not isinstance(games_ranks, Exception): | |
| 101 | self._games, self._ranks = games_ranks | |
| 102 | if not isinstance(leaders_data, Exception): | |
| 103 | self._leaders_data = leaders_data | |
| 104 | if not isinstance(playoff_data, Exception): | |
| 105 | self._playoff_data = playoff_data | |
| 106 | ||
| 107 | self._update_widgets() | |
| 108 | self.query_one(CountdownBar).reset(self.refresh_interval) | |
| 109 | ||
| 110 | def _update_widgets(self) -> None: | |
| 111 | if self._games and self._ranks: | |
| 112 | self.query_one(ScoresWidget).update( | |
| 113 | Text.from_ansi(get_scoreboard_table(self._games, self._ranks)) | |
| 114 | ) | |
| 115 | self.query_one("#west-content", Static).update( | |
| 116 | Text.from_ansi(get_west_standings_table(self._ranks)) | |
| 117 | ) | |
| 118 | self.query_one("#east-content", Static).update( | |
| 119 | Text.from_ansi(get_east_standings_table(self._ranks)) | |
| 120 | ) | |
| 121 | ||
| 122 | if self._leaders_data: | |
| 123 | cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] | |
| 124 | self.query_one("#leaders-content", Static).update( | |
| 125 | Text.from_ansi(leaders_mod.get_leaders_table(self._leaders_data, cat)) | |
| 126 | ) | |
| 127 | ||
| 128 | if self._playoff_data: | |
| 129 | self.query_one("#playoff-west-content", Static).update( | |
| 130 | Text.from_ansi(playoff_mod.get_west_playoff_table(self._playoff_data)) | |
| 131 | ) | |
| 132 | self.query_one("#playoff-east-content", Static).update( | |
| 133 | Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data)) | |
| 134 | ) | |
| 135 | ||
| 136 | # ------------------------------------------------------------------ # | |
| 137 | # Key handlers # | |
| 138 | # ------------------------------------------------------------------ # | |
| 139 | ||
| 140 | def on_key(self, event) -> None: | |
| 141 | """Handle 1–9 to select a game for the box score tab.""" | |
| 142 | char = event.character | |
| 143 | if char and char.isdigit() and char != "0": | |
| 144 | asyncio.create_task(self._load_box_score(int(char) - 1)) | |
| 145 | ||
| 146 | # ------------------------------------------------------------------ # | |
| 147 | # Actions # | |
| 148 | # ------------------------------------------------------------------ # | |
| 149 | ||
| 150 | async def action_refresh_now(self) -> None: | |
| 151 | await self._do_refresh() | |
| 152 | ||
| 153 | def action_show_scores(self) -> None: | |
| 154 | self.query_one(TabbedContent).active = "scores" | |
| 155 | ||
| 156 | def action_show_standings(self) -> None: | |
| 157 | self.query_one(TabbedContent).active = "standings" | |
| 158 | ||
| 159 | def action_show_leaders(self) -> None: | |
| 160 | self.query_one(TabbedContent).active = "leaders" | |
| 161 | ||
| 162 | def action_show_playoff(self) -> None: | |
| 163 | self.query_one(TabbedContent).active = "playoff" | |
| 164 | ||
| 165 | def action_show_boxscore(self) -> None: | |
| 166 | self.query_one(TabbedContent).active = "boxscore" | |
| 167 | ||
| 168 | async def action_prev_category(self) -> None: | |
| 169 | self._leaders_cat_idx = (self._leaders_cat_idx - 1) % len(leaders_mod.CATEGORIES) | |
| 170 | await self._refresh_leaders() | |
| 171 | ||
| 172 | async def action_next_category(self) -> None: | |
| 173 | self._leaders_cat_idx = (self._leaders_cat_idx + 1) % len(leaders_mod.CATEGORIES) | |
| 174 | await self._refresh_leaders() | |
| 175 | ||
| 176 | # ------------------------------------------------------------------ # | |
| 177 | # Helpers # | |
| 178 | # ------------------------------------------------------------------ # | |
| 179 | ||
| 180 | async def _refresh_leaders(self) -> None: | |
| 181 | cat, label = leaders_mod.CATEGORIES[self._leaders_cat_idx] | |
| 182 | self.query_one("#leaders-content", Static).update(f"Loading {label} leaders...") | |
| 183 | loop = asyncio.get_event_loop() | |
| 184 | try: | |
| 185 | data = await loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)) | |
| 186 | self._leaders_data = data | |
| 187 | self.query_one("#leaders-content", Static).update( | |
| 188 | Text.from_ansi(leaders_mod.get_leaders_table(data, cat)) | |
| 189 | ) | |
| 190 | except Exception as exc: | |
| 191 | self.query_one("#leaders-content", Static).update(f"Error loading leaders: {exc}") | |
| 192 | ||
| 193 | async def _load_box_score(self, game_idx: int) -> None: | |
| 194 | if not self._games: | |
| 195 | return | |
| 196 | games = self._games["scoreboard"]["games"] | |
| 197 | if game_idx >= len(games): | |
| 198 | return | |
| 199 | ||
| 200 | game = games[game_idx] | |
| 201 | game_id = game["gameId"] | |
| 202 | home = game["homeTeam"]["teamName"] | |
| 203 | away = game["awayTeam"]["teamName"] | |
| 204 | ||
| 205 | self.query_one("#home-content", Static).update( | |
| 206 | f"Loading box score: {away} @ {home}..." | |
| 207 | ) | |
| 208 | self.query_one("#away-content", Static).update("") | |
| 209 | self.query_one(TabbedContent).active = "boxscore" | |
| 210 | ||
| 211 | loop = asyncio.get_event_loop() | |
| 212 | try: | |
| 213 | data = await loop.run_in_executor( | |
| 214 | None, lambda: box_score_mod.fetch_box_score(game_id) | |
| 215 | ) | |
| 216 | home_table, away_table = box_score_mod.get_box_score_tables(data) | |
| 217 | self.query_one("#home-content", Static).update(Text.from_ansi(home_table)) | |
| 218 | self.query_one("#away-content", Static).update(Text.from_ansi(away_table)) | |
| 219 | except Exception as exc: | |
| 220 | self.query_one("#home-content", Static).update(f"Error loading box score: {exc}") | |
| 221 | self.query_one("#away-content", Static).update("") | |
build/lib/nba/tui/styles.tcss added +58
| @@ -0,0 +1,58 @@ | ||
| 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 tabs — two columns side by side */ | |
| 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 | width: 1fr; | |
| 32 | overflow: auto auto; | |
| 33 | padding-right: 2; | |
| 34 | } | |
| 35 | ||
| 36 | /* Leaders tab */ | |
| 37 | #leaders-content { | |
| 38 | width: auto; | |
| 39 | overflow: auto auto; | |
| 40 | } | |
| 41 | ||
| 42 | /* Box Score tab */ | |
| 43 | #home-content, | |
| 44 | #away-content { | |
| 45 | width: 1fr; | |
| 46 | overflow: auto auto; | |
| 47 | padding-right: 2; | |
| 48 | } | |
| 49 | ||
| 50 | /* Countdown / status bar */ | |
| 51 | #countdown { | |
| 52 | dock: bottom; | |
| 53 | height: 1; | |
| 54 | background: $panel; | |
| 55 | color: $text-muted; | |
| 56 | content-align: right middle; | |
| 57 | padding-right: 2; | |
| 58 | } | |
build/lib/nba/tui/widgets.py added +50
| @@ -0,0 +1,50 @@ | ||
| 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 | \\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" | |
| 45 | ) | |
| 46 | ||
| 47 | def reset(self, interval: int) -> None: | |
| 48 | """Reset the countdown to the given interval.""" | |
| 49 | self._interval = interval | |
| 50 | self.seconds = interval | |
nba_scores.egg-info/PKG-INFO +58 −18
| @@ -19,16 +19,19 @@ Dynamic: license-file | ||
| 19 | 19 | |
| 20 | 20 | # NBA |
| 21 | 21 | |
| 22 | NBA is a simple Python package that provides you with a quick and easy | |
| 23 | command-line interface to current NBA scores and standings. | |
| 22 |  | |
| 24 | 23 | |
| 25 | See the [Installation](#installation) & [Usage](#usage) sections below | |
| 26 | for more information. | |
| 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. | |
| 27 | 28 | |
| 28 | 29 | # Table of Contents |
| 29 | 30 | |
| 30 | 31 | - [Installation](#installation) |
| 31 | 32 | - [Usage](#usage) |
| 33 | - [Interactive TUI](#interactive-tui) | |
| 34 | - [Static output](#static-output) | |
| 32 | 35 | - [Contributing](#contributing) |
| 33 | 36 | |
| 34 | 37 | # Installation |
| @@ -43,33 +46,70 @@ pipx install nba-scores | ||
| 43 | 46 | |
| 44 | 47 | ## Manual |
| 45 | 48 | |
| 46 | To run the script locally, run the following commands: | |
| 47 | ||
| 48 | 49 | ```shell |
| 49 | 50 | git clone https://git.cleberg.net/nba-scores.git |
| 50 | 51 | cd nba-scores |
| 51 | pipx install . | |
| 52 | uv sync | |
| 52 | 53 | ``` |
| 53 | 54 | |
| 54 |  | |
| 55 | ||
| 56 | 55 | # Usage |
| 57 | 56 | |
| 58 | 57 | [Back to top](#table-of-contents) |
| 59 | 58 | |
| 60 | All commands can be passed to the program with the following template: | |
| 61 | `nba <ARGUMENT>` | |
| 59 | ## Interactive TUI | |
| 60 | ||
| 61 | Launch the full interactive terminal UI: | |
| 62 | ||
| 63 | ```shell | |
| 64 | nba --tui | |
| 65 | ``` | |
| 66 | ||
| 67 | Optional flags: | |
| 68 | ||
| 69 | | Flag | Description | | |
| 70 | |--------------------|--------------------------------------------------------| | |
| 71 | | `--tui` | Launch the interactive TUI | | |
| 72 | | `--scores` | Open TUI on the Scores tab (default) | | |
| 73 | | `--standings` | Open TUI on the Standings tab | | |
| 74 | | `--refresh N` | Auto-refresh interval in seconds (default: 60, min: 10)| | |
| 75 | ||
| 76 | ### TUI tabs | |
| 77 | ||
| 78 | | Tab | Key | Description | | |
| 79 | |-----------------|-----|----------------------------------------------------------| | |
| 80 | | Scores | `s` | Today's games with live scores and status | | |
| 81 | | Standings | `t` | East / West conference standings, side by side | | |
| 82 | | Leaders | `l` | Top 25 players by stat category | | |
| 83 | | Playoff Picture | `p` | Conference seeding with clinch and elimination status | | |
| 84 | | Box Score | `b` | Per-player live stats for a selected game | | |
| 85 | ||
| 86 | ### TUI key bindings | |
| 87 | ||
| 88 | | Key | Action | | |
| 89 | |---------|------------------------------------------------| | |
| 90 | | `1`–`9` | Load box score for game N from the Scores tab | | |
| 91 | | `,` | Cycle to the previous leaders stat category | | |
| 92 | | `.` | Cycle to the next leaders stat category | | |
| 93 | | `r` | Refresh all data immediately | | |
| 94 | | `q` | Quit | | |
| 95 | ||
| 96 | ### Leaders stat categories | |
| 97 | ||
| 98 | Cycles through: Points, Rebounds, Assists, Steals, Blocks, Efficiency, FG%, FT%, 3P% | |
| 99 | ||
| 100 | ## Static output | |
| 101 | ||
| 102 | Print scores or standings directly to the terminal without launching the TUI: | |
| 62 | 103 | |
| 63 | 104 | | Argument | Shortcut | Description | |
| 64 | 105 | |---------------|----------|-----------------------------------| |
| 65 | | `--scores` | `-sc` | Show today's scoreboard | | |
| 66 | | `--standings` | `-st` | Show current conference standings | | |
| 67 | ||
| 68 | Scores: | |
| 69 |  | |
| 106 | | `--scores` | `-sc` | Print today's scoreboard | | |
| 107 | | `--standings` | `-st` | Print current conference standings| | |
| 70 | 108 | |
| 71 | Standings: | |
| 72 |  | |
| 109 | ```shell | |
| 110 | nba --scores | |
| 111 | nba --standings | |
| 112 | ``` | |
| 73 | 113 | |
| 74 | 114 | # Contributing |
| 75 | 115 | |
nba_scores.egg-info/SOURCES.txt +3
| @@ -3,8 +3,11 @@ README.md | ||
| 3 | 3 | pyproject.toml |
| 4 | 4 | nba/__init__.py |
| 5 | 5 | nba/__main__.py |
| 6 | nba/box_score.py | |
| 6 | 7 | nba/cli.py |
| 7 | 8 | nba/fetch_data.py |
| 9 | nba/leaders.py | |
| 10 | nba/playoff.py | |
| 8 | 11 | nba/scores.py |
| 9 | 12 | nba/standings.py |
| 10 | 13 | nba/tui/__init__.py |