krz/nba-scores
A CLI/TUI app for NBA scores.
clone: git clone https://gitbay.org/krz/nba-scores.git
232e808bd84bf16ad2459a2324a43c0e0ba7133b
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T23:41:07Z
.gitignore | 3 + build/lib/nba/__init__.py | 0 build/lib/nba/__main__.py | 8 - build/lib/nba/__pycache__/bracket.cpython-314.pyc | Bin 17306 -> 0 bytes build/lib/nba/box_score.py | 90 ------- build/lib/nba/bracket.py | 309 ---------------------- build/lib/nba/cli.py | 50 ---- build/lib/nba/fetch_data.py | 30 --- build/lib/nba/leaders.py | 72 ----- build/lib/nba/playoff.py | 93 ------- build/lib/nba/scores.py | 106 -------- build/lib/nba/standings.py | 91 ------- build/lib/nba/tui/__init__.py | 0 build/lib/nba/tui/app.py | 247 ----------------- build/lib/nba/tui/styles.tcss | 59 ----- build/lib/nba/tui/widgets.py | 51 ---- nba_scores.egg-info/PKG-INFO | 112 -------- nba_scores.egg-info/SOURCES.txt | 23 -- nba_scores.egg-info/dependency_links.txt | 1 - nba_scores.egg-info/entry_points.txt | 2 - nba_scores.egg-info/requires.txt | 4 - nba_scores.egg-info/top_level.txt | 1 - 22 files changed, 3 insertions(+), 1349 deletions(-) @@ -2,5 +2,8 @@ venv/ dist/ nba-scores.egg-info* +nba_scores.egg-info/ nba/__pycache__/ nba/tui/__pycache__/ +build/ +*.py[cod] deleted file mode 100644 deleted file mode 100644 @@ -1,8 +0,0 @@ -""" -Entry point for the app. -""" - -if __name__ == "__main__": - from nba.cli import nba - - nba() deleted file mode 100644 Binary files a/build/lib/nba/__pycache__/bracket.cpython-314.pyc and /dev/null differ deleted file mode 100644 @@ -1,90 +0,0 @@ -""" -Fetches and formats live box scores for individual games. -""" - -import json - -from tabulate import tabulate -from nba_api.live.nba.endpoints.boxscore import BoxScore - -BOLD = "\033[1m" -END = "\033[0m" - -_HEADERS = [ - "Player", - "Pos", - "Min", - "Pts", - "Reb", - "Ast", - "Stl", - "Blk", - "TO", - "FG", - "3P", - "FT", - "+/-", -] - - -def fetch_box_score(game_id: str) -> dict: - endpoint = BoxScore(game_id=game_id) - return json.loads(endpoint.get_json()) - - -def _parse_minutes(raw: str) -> str: - """Convert 'PT35M24.00S' → '35:24'.""" - if not raw: - return "0:00" - try: - raw = raw.replace("PT", "").replace("S", "") - mins, secs = raw.split("M") - return f"{int(mins)}:{int(float(secs)):02d}" - except Exception: - return raw - - -def _player_rows(players: list) -> list: - rows = [] - for p in players: - if p.get("status") == "INACTIVE": - continue - s = p.get("statistics", {}) - rows.append( - [ - p.get("name", ""), - p.get("position", ""), - _parse_minutes(s.get("minutes", "")), - s.get("points", 0), - s.get("reboundsTotal", 0), - s.get("assists", 0), - s.get("steals", 0), - s.get("blocks", 0), - s.get("turnovers", 0), - f"{s.get('fieldGoalsMade', 0)}/{s.get('fieldGoalsAttempted', 0)}", - f"{s.get('threePointersMade', 0)}/{s.get('threePointersAttempted', 0)}", - f"{s.get('freeThrowsMade', 0)}/{s.get('freeThrowsAttempted', 0)}", - s.get("plusMinusPoints", 0), - ] - ) - return rows - - -def get_box_score_tables(data: dict) -> tuple: - """Return (home_table_str, away_table_str).""" - game = data["game"] - home = game["homeTeam"] - away = game["awayTeam"] - - home_rows = _player_rows(home.get("players", [])) - away_rows = _player_rows(away.get("players", [])) - - home_table = ( - f"{BOLD}{home['teamCity']} {home['teamName']} ({home['score']}){END}\n" - + tabulate(home_rows, headers=_HEADERS, tablefmt="grid") - ) - away_table = ( - f"{BOLD}{away['teamCity']} {away['teamName']} ({away['score']}){END}\n" - + tabulate(away_rows, headers=_HEADERS, tablefmt="grid") - ) - return home_table, away_table deleted file mode 100644 @@ -1,309 +0,0 @@ -""" -Fetches and formats the NBA playoff bracket. -""" - -import json -import re -from collections import defaultdict -from typing import Optional - -from nba_api.stats.endpoints.commonplayoffseries import CommonPlayoffSeries -from nba_api.stats.endpoints.leaguegamelog import LeagueGameLog -from nba_api.stats.static import teams - -BOLD = "\033[1m" -END = "\033[0m" -GREEN = "\033[32m" -RED = "\033[91m" -YELLOW = "\033[33m" -CYAN = "\033[36m" -ANSI_RE = re.compile(r"\033\[[0-9;]*m") - -ROUND_LABELS = { - 1: "First Round", - 2: "Conference Semifinals", - 3: "Conference Finals", - 4: "NBA Finals", -} - - -def fetch_bracket() -> dict: - """ - Fetch playoff series and completed playoff games. - - CommonPlayoffSeries provides the bracket's scheduled series and games. The - playoff game log is used to compute each series record from completed games. - """ - series_endpoint = CommonPlayoffSeries() - game_log_endpoint = LeagueGameLog(season_type_all_star="Playoffs") - return { - "series": json.loads(series_endpoint.get_json()), - "game_log": json.loads(game_log_endpoint.get_json()), - } - - -def get_bracket_table(data: dict) -> str: - """Return a formatted playoff bracket.""" - result_sets = data["series"]["resultSets"] - series_set = next((r for r in result_sets if r["name"] == "PlayoffSeries"), None) - if series_set is None or not series_set["rowSet"]: - return "No playoff bracket data available." - - headers = series_set["headers"] - idx = {header: i for i, header in enumerate(headers)} - team_map = _team_map() - series_games = _group_series(series_set["rowSet"], idx) - series_wins = _series_wins(data["game_log"], series_games) - summaries = _series_summaries(team_map, series_games, series_wins) - - return "\n".join( - [ - _center(f"{BOLD}NBA Playoff Bracket{END}", 74), - f"{GREEN}* Advanced{END} {RED}x Eliminated{END} " - f"{CYAN}> Series lead{END} {YELLOW}! Can clinch next win{END}", - "", - _render_conference("Western", summaries), - "", - _render_finals(summaries), - "", - _render_conference("Eastern", summaries), - ] - ) - - -def _team_map() -> dict: - return { - team["id"]: { - "abbr": team["abbreviation"], - "name": f"{team['city']} {team['nickname']}", - } - for team in teams.get_teams() - } - - -def _group_series(rows: list, idx: dict) -> dict: - series_games = defaultdict(list) - for row in rows: - series_id = row[idx["SERIES_ID"]] - series_games[series_id].append( - { - "game_id": row[idx["GAME_ID"]], - "game_num": row[idx["GAME_NUM"]], - "home_team_id": row[idx["HOME_TEAM_ID"]], - "visitor_team_id": row[idx["VISITOR_TEAM_ID"]], - } - ) - - return { - series_id: sorted(games, key=lambda game: game["game_num"]) - for series_id, games in series_games.items() - } - - -def _series_wins(game_log: dict, series_games: dict) -> dict: - game_to_series = { - game["game_id"]: series_id - for series_id, games in series_games.items() - for game in games - } - wins = defaultdict(lambda: defaultdict(int)) - - for series_id in series_games: - wins[series_id]["completed_games"] = set() - - result_set = game_log["resultSets"][0] - idx = {header: i for i, header in enumerate(result_set["headers"])} - for row in result_set["rowSet"]: - game_id = row[idx["GAME_ID"]] - series_id = game_to_series.get(game_id) - if series_id is None or row[idx["WL"]] != "W": - continue - - team_id = row[idx["TEAM_ID"]] - wins[series_id][team_id] += 1 - wins[series_id]["completed_games"].add(game_id) - - return wins - - -def _series_summaries(team_map: dict, series_games: dict, series_wins: dict) -> dict: - summaries = {} - for series_id, games in series_games.items(): - first_game = games[0] - home_id = first_game["home_team_id"] - visitor_id = first_game["visitor_team_id"] - wins = series_wins[series_id] - home_wins = wins.get(home_id, 0) - visitor_wins = wins.get(visitor_id, 0) - - summaries[series_id] = { - "conference": _conference(series_id), - "round": _round_number(series_id), - "slot": _series_slot(series_id), - "home_id": home_id, - "visitor_id": visitor_id, - "home_abbr": _abbr(team_map, home_id), - "visitor_abbr": _abbr(team_map, visitor_id), - "home_wins": home_wins, - "visitor_wins": visitor_wins, - "winner": _winner(team_map, home_id, visitor_id, home_wins, visitor_wins), - } - return summaries - - -def _render_conference(conference: str, summaries: dict) -> str: - first_round = _conference_round(summaries, conference, 1) - semifinals = _conference_round(summaries, conference, 2) - finals = _conference_round(summaries, conference, 3) - final = finals[0] if finals else None - - lines = [ - f"{BOLD}{conference.upper()} CONFERENCE{END}", - "First Round Semifinals Conference Finals", - ] - - lines.extend( - [ - f"{_series_box(_slot(first_round, 0), 24)} ┐", - f"{_blank(24)} ├── {_series_box(_slot(semifinals, 0), 24)} ┐", - f"{_series_box(_slot(first_round, 1), 24)} ┘ {_blank(24)} │", - f"{_blank(24)} {_blank(24)} ├── {_series_box(final, 24)}", - f"{_series_box(_slot(first_round, 2), 24)} ┐ {_blank(24)} │", - f"{_blank(24)} ├── {_series_box(_slot(semifinals, 1), 24)} ┘", - f"{_series_box(_slot(first_round, 3), 24)} ┘", - ] - ) - - return "\n".join(lines) - - -def _render_finals(summaries: dict) -> str: - finals = [summary for summary in summaries.values() if summary["round"] == 4] - final = finals[0] if finals else None - - lines = [ - f"{BOLD}NBA FINALS{END}", - "West Champion East Champion", - f"{_series_box(final, 24)}", - ] - if final is None: - lines.append("Winner TBD") - elif final["winner"]: - lines.append(f"{BOLD}{GREEN}{final['winner']} wins the Finals{END}") - return "\n".join(lines) - - -def _conference_round(summaries: dict, conference: str, round_number: int) -> list: - return sorted( - [ - summary - for summary in summaries.values() - if summary["conference"] == conference[:4] - and summary["round"] == round_number - ], - key=lambda summary: summary["slot"], - ) - - -def _slot(items: list, idx: int) -> Optional[dict]: - return items[idx] if idx < len(items) else None - - -def _series_box(summary: Optional[dict], width: int) -> str: - if summary is None: - return _blank(width, "TBD") - - visitor = _team_line( - summary["visitor_abbr"], - summary["visitor_wins"], - summary["home_wins"], - summary["winner"] == summary["visitor_abbr"], - summary["winner"] == summary["home_abbr"], - ) - home = _team_line( - summary["home_abbr"], - summary["home_wins"], - summary["visitor_wins"], - summary["winner"] == summary["home_abbr"], - summary["winner"] == summary["visitor_abbr"], - ) - return _pad_ansi(f"{visitor} / {home}", width) - - -def _team_line( - abbr: str, - wins: int, - opponent_wins: int, - won_series: bool, - lost_series: bool, -) -> str: - label = f"{abbr} {wins}" - if won_series: - return f"{BOLD}{GREEN}{label}*{END}" - if lost_series: - return f"{RED}{label}x{END}" - if wins == 3: - return f"{BOLD}{YELLOW}{label}!{END}" - if wins > opponent_wins: - return f"{CYAN}{label}>{END}" - return label - - -def _winner( - team_map: dict, - home_id: int, - visitor_id: int, - home_wins: int, - visitor_wins: int, -) -> str: - if home_wins >= 4: - return _abbr(team_map, home_id) - if visitor_wins >= 4: - return _abbr(team_map, visitor_id) - return "" - - -def _blank(width: int, text: str = "") -> str: - return text.ljust(width) - - -def _center(text: str, width: int) -> str: - return text.center(width) - - -def _pad_ansi(text: str, width: int) -> str: - visible_length = len(ANSI_RE.sub("", text)) - return text + " " * max(width - visible_length, 0) - - -def _round_number(series_id: str) -> int: - try: - return int(series_id[-3:-1]) - except ValueError: - return 0 - - -def _series_slot(series_id: str) -> int: - try: - return int(series_id[-1]) - except ValueError: - return 0 - - -def _conference(series_id: str) -> str: - round_number = _round_number(series_id) - slot = _series_slot(series_id) - - if round_number == 4: - return "NBA" - if round_number == 3: - return "East" if slot == 0 else "West" - if round_number == 2: - return "East" if slot <= 1 else "West" - if round_number == 1: - return "East" if slot <= 3 else "West" - return "" - - -def _abbr(team_map: dict, team_id: int) -> str: - return team_map.get(team_id, {}).get("abbr", str(team_id)) deleted file mode 100644 @@ -1,50 +0,0 @@ -""" -This script uses argparse to parse command line arguments. - -It imports the required modules and sets up a parser with basic options for demonstration purposes. -""" - -import argparse -from nba import fetch_data, scores, standings - - -def nba() -> None: - """ - Parse command-line arguments and display either scoreboard or standings. - """ - parser = argparse.ArgumentParser(description="NBA Scoreboard and Standings") - parser.add_argument( - "--scores", "-sc", action="store_true", help="Display the scoreboard" - ) - parser.add_argument( - "--standings", "-st", action="store_true", help="Display the standings" - ) - parser.add_argument("--tui", action="store_true", help="Launch the interactive TUI") - parser.add_argument( - "--refresh", - type=int, - default=60, - metavar="SECONDS", - help="Auto-refresh interval in TUI mode (default: 60, minimum: 10)", - ) - args = parser.parse_args() - - if args.tui: - from nba.tui.app import NBAApp - - initial_tab = "standings" if args.standings else "scores" - refresh_interval = max(args.refresh, 10) - NBAApp(initial_tab=initial_tab, refresh_interval=refresh_interval).run() - return - - # Legacy static mode - games, ranks = fetch_data.fetch_data() - - if args.scores: - scores.build_scoreboard(games, ranks) - elif args.standings: - standings.build_standings(ranks) - else: - print( - "Please specify --scores or --standings (or use --tui for interactive mode)" - ) deleted file mode 100644 @@ -1,30 +0,0 @@ -""" -Fetches data for use in other modules. -""" - -import json -from nba_api.live.nba.endpoints import scoreboard -from nba_api.stats.endpoints import leaguestandings - - -def fetch_data() -> tuple: - """ - Fetches live NBA scoreboard data and standings from the NBA API. - - Returns: - games (dict): JSON parsed games data. - standings (dict): JSON parsed team standings data. - """ - # Get today's scoreboard data - games_endpoint = scoreboard.ScoreBoard() - games_json = games_endpoint.get_json() - - # Get league standings - standings_endpoint = leaguestandings.LeagueStandings() - standings_json = standings_endpoint.get_json() - - # Parse the JSON strings into Python dictionaries - games = json.loads(games_json) - standings = json.loads(standings_json) - - return games, standings deleted file mode 100644 @@ -1,72 +0,0 @@ -""" -Fetches and formats NBA statistical leaders. -""" - -import json - -from tabulate import tabulate -from nba_api.stats.endpoints.leagueleaders import LeagueLeaders - -BOLD = "\033[1m" -END = "\033[0m" - -# Ordered list of (api_abbreviation, display_label) -CATEGORIES = [ - ("PTS", "Points"), - ("REB", "Rebounds"), - ("AST", "Assists"), - ("STL", "Steals"), - ("BLK", "Blocks"), - ("EFF", "Efficiency"), - ("FG_PCT", "FG%"), - ("FT_PCT", "FT%"), - ("FG3_PCT", "3P%"), -] - -# Extra columns to show alongside RANK, PLAYER, TEAM, GP for each category -_EXTRA_COLS = { - "PTS": ["PTS", "FGM", "FGA", "FG_PCT", "FTM", "FTA", "FT_PCT"], - "REB": ["REB", "OREB", "DREB", "GP"], - "AST": ["AST", "TOV", "AST_TOV", "GP"], - "STL": ["STL", "TOV", "GP"], - "BLK": ["BLK", "PF", "GP"], - "EFF": ["EFF", "PTS", "REB", "AST", "GP"], - "FG_PCT": ["FG_PCT", "FGM", "FGA", "PTS"], - "FT_PCT": ["FT_PCT", "FTM", "FTA", "PTS"], - "FG3_PCT": ["FG3_PCT", "FG3M", "FG3A", "PTS"], -} - -_DISPLAY_NAMES = { - "FG_PCT": "FG%", - "FT_PCT": "FT%", - "FG3_PCT": "3P%", - "FG3M": "3PM", - "FG3A": "3PA", - "AST_TOV": "AST/TO", -} - - -def fetch_leaders(category: str = "PTS") -> dict: - endpoint = LeagueLeaders( - stat_category_abbreviation=category, - season_type_all_star="Regular Season", - ) - return json.loads(endpoint.get_json()) - - -def get_leaders_table(data: dict, category: str = "PTS") -> str: - result = data["resultSet"] - headers = result["headers"] - rows = result["rowSet"] - - base = ["RANK", "PLAYER", "TEAM", "GP"] - extra = [c for c in _EXTRA_COLS.get(category, [category]) if c not in base] - wanted = base + extra - - idx = {h: i for i, h in enumerate(headers)} - table_data = [[row[idx[col]] for col in wanted if col in idx] for row in rows[:25]] - display_headers = [_DISPLAY_NAMES.get(c, c) for c in wanted if c in idx] - - cat_label = dict(CATEGORIES).get(category, category) - title = f"{BOLD}League Leaders — {cat_label}{END}" - return title + "\n" + tabulate(table_data, headers=display_headers, tablefmt="grid") deleted file mode 100644 @@ -1,93 +0,0 @@ -""" -Fetches and formats the NBA playoff picture. -""" - -import json - -from tabulate import tabulate -from nba_api.stats.endpoints.playoffpicture import PlayoffPicture - -BOLD = "\033[1m" -END = "\033[0m" -RED = "\033[91m" -GREEN = "\033[32m" -YELLOW = "\033[33m" - - -def fetch_playoff_picture() -> dict: - endpoint = PlayoffPicture() - return json.loads(endpoint.get_json()) - - -def _clinch_status(row: list, idx: dict) -> str: - """Return a color-coded status string from clinch/elimination columns.""" - - def val(col): - return row[idx[col]] if col in idx else None - - if val("CLINCHED_CONFERENCE"): - return f"{BOLD}{GREEN}z-Clinched Conf{END}" - if val("CLINCHED_DIVISION") or val("CLINCHED_PLAYOFFS"): - return f"{GREEN}x-Clinched{END}" - if val("Clinched_Play_In"): - return f"{YELLOW}pi-Play-In{END}" - if val("ELIMINATED_PLAYOFFS"): - return f"{RED}e-Eliminated{END}" - return "" - - -def _build_conference_table(result_sets: list, name: str) -> str: - """Build a formatted playoff standings table for one conference.""" - rs = next((r for r in result_sets if r["name"] == name), None) - if rs is None or not rs["rowSet"]: - return "No data available." - - headers = rs["headers"] - rows = rs["rowSet"] - idx = {h: i for i, h in enumerate(headers)} - - def get(row, col, default=""): - return row[idx[col]] if col in idx else default - - table_data = [] - for row in rows: - wins = get(row, "WINS") - losses = get(row, "LOSSES") - pct = get(row, "PCT") - pct_str = f"{float(pct):.3f}" if pct not in ("", None) else "" - table_data.append( - [ - get(row, "RANK"), - get(row, "TEAM"), - f"{wins}-{losses}", - pct_str, - get(row, "GB"), - get(row, "HOME"), - get(row, "AWAY"), - get(row, "CONF"), - _clinch_status(row, idx), - ] - ) - - display_headers = [ - "#", - "Team", - "W-L", - "PCT", - "GB", - "HOME", - "AWAY", - "CONF", - "Status", - ] - conf_label = "Eastern" if "East" in name else "Western" - title = f"{BOLD}{conf_label} Conference Playoff Picture:{END}" - return title + "\n" + tabulate(table_data, headers=display_headers, tablefmt="grid") - - -def get_west_playoff_table(data: dict) -> str: - return _build_conference_table(data["resultSets"], "WestConfStandings") - - -def get_east_playoff_table(data: dict) -> str: - return _build_conference_table(data["resultSets"], "EastConfStandings") deleted file mode 100644 @@ -1,106 +0,0 @@ -""" -Tabulates a scoreboard for today's games. -""" - -from tabulate import tabulate - -# ANSI escape codes for text formatting -BOLD = "\033[1m" -END = "\033[0m" -RED = "\033[91m" -GREEN = "\033[32m" - - -# Function to get team record from standings -def get_team_record(team_name, standings) -> str: - """ - Retrieves a team's win-loss record from the standings data. - - Args: - team_name (str): Name of the team. - standings (dict): Team standings data. - - Returns: - record (str): Team's win-loss record in 'W-L' format. Defaults to 'N/A'. - """ - for result_set in standings["resultSets"]: - if result_set["name"] == "Standings": - for team in result_set["rowSet"]: - if team[4] == team_name: - return f"{team[12]}-{team[13]}" - return "N/A" - - -def get_scoreboard_table(games, standings) -> str: - """ - Builds and returns the current day's games as a formatted table string. - - Args: - games (dict): JSON parsed games data. - standings (dict): Team standings data. - - Returns: - str: Formatted table string with ANSI color codes. - """ - scoreboard_data = games["scoreboard"] - game_list = scoreboard_data["games"] - - if not game_list: - return "No games scheduled today." - - # Prepare the table data - table_data = [] - for game in game_list: - home_team = game["homeTeam"]["teamName"] - away_team = game["awayTeam"]["teamName"] - game_status = game["gameStatusText"] - home_score = game["homeTeam"]["score"] - away_score = game["awayTeam"]["score"] - - home_record = get_team_record(home_team, standings) - away_record = get_team_record(away_team, standings) - - # Determine the winning team - if home_score > away_score: - home_team_bold = f"{BOLD}{GREEN}{home_team} ({home_record}){END}{END}" - away_team_bold = f"{away_team} ({away_record}){END}" - home_score_bold = f"{BOLD}{GREEN}{home_score}{END}{END}" - away_score_bold = f"{away_score}{END}" - elif away_score > home_score: - home_team_bold = f"{home_team} ({home_record}){END}" - away_team_bold = f"{BOLD}{GREEN}{away_team} ({away_record}){END}{END}" - home_score_bold = f"{home_score}{END}" - away_score_bold = f"{BOLD}{GREEN}{away_score}{END}{END}" - else: - home_team_bold = f"{home_team} ({home_record})" - away_team_bold = f"{away_team} ({away_record})" - home_score_bold = f"{home_score}" - away_score_bold = f"{away_score}" - - # Determine games still in progress - if game_status != "Final": - game_status = f"{RED}{game_status}{END}" - - table_data.append( - [ - f"{home_team_bold}\n{away_team_bold}", - f"{home_score_bold}\n{away_score_bold}", - f"{BOLD}{game_status}{END}", - ] - ) - - # Define the table headers - headers = ["Team", "Score", "Game Status"] - - return tabulate(table_data, headers=headers, tablefmt="grid") - - -def build_scoreboard(games, standings) -> None: - """ - Prints the current day's games in a table format. - - Args: - games (dict): JSON parsed games data. - standings (dict): Team standings data. - """ - print(get_scoreboard_table(games, standings)) deleted file mode 100644 @@ -1,91 +0,0 @@ -""" -Tabulate the current conference standings. -""" - -from tabulate import tabulate - -# ANSI escape codes for text formatting -BOLD = "\033[1m" -END = "\033[0m" -RED = "\033[91m" -GREEN = "\033[32m" - - -def _build_conference_table(standings, conference: str) -> str: - """Build a formatted table string for one conference.""" - data = [] - rank = 1 - - for result_set in standings["resultSets"]: - if result_set["name"] == "Standings": - for team in result_set["rowSet"]: - if team[5] != conference: - continue - wins = team[12] - losses = team[13] - win_pct = team[14] - streak = team[35] - - strk_color = ( - f"{RED}{streak}{END}" - if int(streak) < 0 - else f"{GREEN}{streak}{END}" - ) - - data.append( - [ - f"{rank}", - team[4], - f"{wins}-{losses}", - f"{win_pct:.3f}", - team[37], - strk_color, - team[19], - team[17], - team[18], - ] - ) - rank += 1 - - headers = ["Rank", "Team", "W-L", "PCT", "GB", "STRK", "L10", "HOME", "AWAY"] - label = "Eastern" if conference == "East" else "Western" - return f"{BOLD}{label} Conference Standings:{END}\n" + tabulate( - data, headers=headers, tablefmt="grid" - ) - - -def get_east_standings_table(standings) -> str: - """Returns the Eastern Conference standings as a formatted string.""" - return _build_conference_table(standings, "East") - - -def get_west_standings_table(standings) -> str: - """Returns the Western Conference standings as a formatted string.""" - return _build_conference_table(standings, "West") - - -def get_standings_tables(standings) -> str: - """ - Builds and returns both conference standings as a formatted string. - - Args: - standings (dict): Team standings data. - - Returns: - str: Formatted standings string with ANSI color codes for both conferences. - """ - return ( - get_east_standings_table(standings) - + "\n\n" - + get_west_standings_table(standings) - ) - - -def build_standings(standings) -> None: - """ - Prints team standings in two separate tables. - - Args: - standings (dict): Team standings data. - """ - print(get_standings_tables(standings)) deleted file mode 100644 deleted file mode 100644 @@ -1,247 +0,0 @@ -""" -Textual TUI application for NBA scores and standings. -""" - -from __future__ import annotations - -import asyncio -from pathlib import Path - -from rich.text import Text -from textual.app import App, ComposeResult -from textual.containers import Horizontal -from textual.widgets import Footer, Header, Static, TabbedContent, TabPane - -from nba import fetch_data -from nba import bracket as bracket_mod -from nba import leaders as leaders_mod -from nba import playoff as playoff_mod -from nba import box_score as box_score_mod -from nba.scores import get_scoreboard_table -from nba.standings import get_east_standings_table, get_west_standings_table -from nba.tui.widgets import CountdownBar, ScoresWidget - - -class NBAApp(App): - """Live NBA scores and standings TUI with auto-refresh.""" - - CSS_PATH = Path(__file__).parent / "styles.tcss" - - BINDINGS = [ - ("q", "quit", "Quit"), - ("s", "show_scores", "Scores"), - ("t", "show_standings", "Standings"), - ("l", "show_leaders", "Leaders"), - ("p", "show_playoff", "Playoff"), - ("k", "show_bracket", "Bracket"), - ("b", "show_boxscore", "Box Score"), - ("r", "refresh_now", "Refresh"), - ("comma", "prev_category", "◀ Cat"), - ("full_stop", "next_category", "Cat ▶"), - ] - - TITLE = "NBA Scores" - - def __init__(self, initial_tab: str = "scores", refresh_interval: int = 60) -> None: - super().__init__() - self.initial_tab = initial_tab - self.refresh_interval = refresh_interval - self._games: dict | None = None - self._ranks: dict | None = None - self._leaders_data: dict | None = None - self._leaders_cat_idx: int = 0 - self._playoff_data: dict | None = None - self._bracket_data: dict | None = None - - def compose(self) -> ComposeResult: - yield Header() - with TabbedContent(initial=self.initial_tab): - with TabPane("Scores", id="scores"): - yield ScoresWidget("Loading...", id="scores-content") - with TabPane("Standings", id="standings"): - with Horizontal(id="standings-container"): - yield Static("Loading...", id="west-content") - yield Static("Loading...", id="east-content") - with TabPane("Leaders", id="leaders"): - yield Static("Loading...", id="leaders-content") - with TabPane("Playoff", id="playoff"): - with Horizontal(id="playoff-container"): - yield Static("Loading...", id="playoff-west-content") - yield Static("Loading...", id="playoff-east-content") - with TabPane("Bracket", id="bracket"): - yield Static("Loading...", id="bracket-content") - with TabPane("Box Score", id="boxscore"): - with Horizontal(id="boxscore-container"): - yield Static( - "Press 1–9 to load a game from the Scores tab.", - id="home-content", - ) - yield Static("", id="away-content") - yield CountdownBar(self.refresh_interval, id="countdown") - yield Footer() - - async def on_mount(self) -> None: - await self._do_refresh() - self.set_interval(self.refresh_interval, self._do_refresh) - - # ------------------------------------------------------------------ # - # Refresh logic # - # ------------------------------------------------------------------ # - - async def _do_refresh(self) -> None: - """Fetch scores, standings, leaders, playoff picture, and bracket in parallel.""" - loop = asyncio.get_event_loop() - cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] - - results = await asyncio.gather( - loop.run_in_executor(None, fetch_data.fetch_data), - loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)), - loop.run_in_executor(None, playoff_mod.fetch_playoff_picture), - loop.run_in_executor(None, bracket_mod.fetch_bracket), - return_exceptions=True, - ) - - games_ranks, leaders_data, playoff_data, bracket_data = results - - if not isinstance(games_ranks, Exception): - self._games, self._ranks = games_ranks - if not isinstance(leaders_data, Exception): - self._leaders_data = leaders_data - if not isinstance(playoff_data, Exception): - self._playoff_data = playoff_data - if not isinstance(bracket_data, Exception): - self._bracket_data = bracket_data - - self._update_widgets() - self.query_one(CountdownBar).reset(self.refresh_interval) - - def _update_widgets(self) -> None: - if self._games and self._ranks: - self.query_one(ScoresWidget).update( - Text.from_ansi(get_scoreboard_table(self._games, self._ranks)) - ) - self.query_one("#west-content", Static).update( - Text.from_ansi(get_west_standings_table(self._ranks)) - ) - self.query_one("#east-content", Static).update( - Text.from_ansi(get_east_standings_table(self._ranks)) - ) - - if self._leaders_data: - cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] - self.query_one("#leaders-content", Static).update( - Text.from_ansi(leaders_mod.get_leaders_table(self._leaders_data, cat)) - ) - - if self._playoff_data: - self.query_one("#playoff-west-content", Static).update( - Text.from_ansi(playoff_mod.get_west_playoff_table(self._playoff_data)) - ) - self.query_one("#playoff-east-content", Static).update( - Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data)) - ) - - if self._bracket_data: - self.query_one("#bracket-content", Static).update( - Text.from_ansi(bracket_mod.get_bracket_table(self._bracket_data)) - ) - - # ------------------------------------------------------------------ # - # Key handlers # - # ------------------------------------------------------------------ # - - def on_key(self, event) -> None: - """Handle 1–9 to select a game for the box score tab.""" - char = event.character - if char and char.isdigit() and char != "0": - asyncio.create_task(self._load_box_score(int(char) - 1)) - - # ------------------------------------------------------------------ # - # Actions # - # ------------------------------------------------------------------ # - - async def action_refresh_now(self) -> None: - await self._do_refresh() - - def action_show_scores(self) -> None: - self.query_one(TabbedContent).active = "scores" - - def action_show_standings(self) -> None: - self.query_one(TabbedContent).active = "standings" - - def action_show_leaders(self) -> None: - self.query_one(TabbedContent).active = "leaders" - - def action_show_playoff(self) -> None: - self.query_one(TabbedContent).active = "playoff" - - def action_show_bracket(self) -> None: - self.query_one(TabbedContent).active = "bracket" - - def action_show_boxscore(self) -> None: - self.query_one(TabbedContent).active = "boxscore" - - async def action_prev_category(self) -> None: - self._leaders_cat_idx = (self._leaders_cat_idx - 1) % len( - leaders_mod.CATEGORIES - ) - await self._refresh_leaders() - - async def action_next_category(self) -> None: - self._leaders_cat_idx = (self._leaders_cat_idx + 1) % len( - leaders_mod.CATEGORIES - ) - await self._refresh_leaders() - - # ------------------------------------------------------------------ # - # Helpers # - # ------------------------------------------------------------------ # - - async def _refresh_leaders(self) -> None: - cat, label = leaders_mod.CATEGORIES[self._leaders_cat_idx] - self.query_one("#leaders-content", Static).update(f"Loading {label} leaders...") - loop = asyncio.get_event_loop() - try: - data = await loop.run_in_executor( - None, lambda: leaders_mod.fetch_leaders(cat) - ) - self._leaders_data = data - self.query_one("#leaders-content", Static).update( - Text.from_ansi(leaders_mod.get_leaders_table(data, cat)) - ) - except Exception as exc: - self.query_one("#leaders-content", Static).update( - f"Error loading leaders: {exc}" - ) - - async def _load_box_score(self, game_idx: int) -> None: - if not self._games: - return - games = self._games["scoreboard"]["games"] - if game_idx >= len(games): - return - - game = games[game_idx] - game_id = game["gameId"] - home = game["homeTeam"]["teamName"] - away = game["awayTeam"]["teamName"] - - self.query_one("#home-content", Static).update( - f"Loading box score: {away} @ {home}..." - ) - self.query_one("#away-content", Static).update("") - self.query_one(TabbedContent).active = "boxscore" - - loop = asyncio.get_event_loop() - try: - data = await loop.run_in_executor( - None, lambda: box_score_mod.fetch_box_score(game_id) - ) - home_table, away_table = box_score_mod.get_box_score_tables(data) - self.query_one("#home-content", Static).update(Text.from_ansi(home_table)) - self.query_one("#away-content", Static).update(Text.from_ansi(away_table)) - except Exception as exc: - self.query_one("#home-content", Static).update( - f"Error loading box score: {exc}" - ) - self.query_one("#away-content", Static).update("") deleted file mode 100644 @@ -1,59 +0,0 @@ -Screen { - background: $background; -} - -TabbedContent { - height: 1fr; -} - -TabPane { - overflow: auto auto; - padding: 1 2; -} - -/* Scores tab */ -#scores-content { - width: auto; -} - -/* Standings, Playoff, Bracket, and Box Score tabs */ -#standings-container, -#playoff-container, -#boxscore-container { - height: 1fr; - width: 100%; -} - -#east-content, -#west-content, -#playoff-east-content, -#playoff-west-content, -#bracket-content { - width: 1fr; - overflow: auto auto; - padding-right: 2; -} - -/* Leaders tab */ -#leaders-content { - width: auto; - overflow: auto auto; -} - -/* Box Score tab */ -#home-content, -#away-content { - width: 1fr; - overflow: auto auto; - padding-right: 2; -} - -/* Countdown / status bar */ -#countdown { - dock: bottom; - height: 1; - background: $panel; - color: $text-muted; - content-align: right middle; - padding-right: 2; -} deleted file mode 100644 @@ -1,51 +0,0 @@ -""" -Custom Textual widgets for the NBA scores TUI. -""" - -from textual.reactive import reactive -from textual.widgets import Static - - -class ScoresWidget(Static): - """Displays the NBA scoreboard as a scrollable ANSI-formatted table.""" - - -class StandingsWidget(Static): - """Displays the NBA standings as a scrollable ANSI-formatted table.""" - - -class CountdownBar(Static): - """ - Docked status bar that counts down to the next auto-refresh. - - Maintains its own 1-second ticker and re-renders via a reactive attribute. - """ - - seconds: reactive[int] = reactive(60) - - def __init__(self, interval: int, **kwargs) -> None: - super().__init__(**kwargs) - self._interval = interval - self.seconds = interval - - def on_mount(self) -> None: - self.set_interval(1, self._tick) - - def _tick(self) -> None: - if self.seconds > 0: - self.seconds -= 1 - - def watch_seconds(self, value: int) -> None: - if value <= 0: - self.update("Refreshing...") - else: - self.update( - f"Next refresh in {value}s | " - "\\[1-9] Box Score | \\[k] Bracket | " - "\\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" - ) - - def reset(self, interval: int) -> None: - """Reset the countdown to the given interval.""" - self._interval = interval - self.seconds = interval deleted file mode 100644 @@ -1,112 +0,0 @@ -Metadata-Version: 2.4 -Name: nba-scores -Version: 0.2.3 -Summary: A CLI interface to get current NBA scores and standings. -Author-email: Christian Cleberg <hello@cleberg.net> -License-Expression: GPL-3.0-or-later -Project-URL: Homepage, https://git.cleberg.net/nba-scores.git -Project-URL: Issues, https://git.cleberg.net/nba-scores.git -Classifier: Programming Language :: Python :: 3 -Classifier: Operating System :: OS Independent -Requires-Python: >=3.9 -Description-Content-Type: text/markdown -License-File: LICENSE -Requires-Dist: nba_api -Requires-Dist: tabulate -Requires-Dist: argparse -Requires-Dist: textual<7.0,>=0.89.1 -Dynamic: license-file - -# NBA - - - -NBA is a Python package that provides a command-line interface to current NBA -scores, standings, statistical leaders, playoff picture, and live box scores. -It supports both a static one-shot mode and an interactive TUI with -auto-refresh. - -# Table of Contents - -- [Installation](#installation) -- [Usage](#usage) - - [Interactive TUI](#interactive-tui) - - [Static output](#static-output) -- [Contributing](#contributing) - -# Installation - -[Back to top](#table-of-contents) - -```shell -git clone https://git.cleberg.net/nba-scores.git -cd nba-scores -pipx install . -``` - -# Usage - -[Back to top](#table-of-contents) - -## Interactive TUI - -Launch the full interactive terminal UI: - -```shell -nba --tui -``` - -Optional flags: - -| Flag | Description | -|--------------------|--------------------------------------------------------| -| `--tui` | Launch the interactive TUI | -| `--scores` | Open TUI on the Scores tab (default) | -| `--standings` | Open TUI on the Standings tab | -| `--refresh N` | Auto-refresh interval in seconds (default: 60, min: 10)| - -### TUI tabs - -| Tab | Key | Description | -|-----------------|-----|----------------------------------------------------------| -| Scores | `s` | Today's games with live scores and status | -| Standings | `t` | East / West conference standings, side by side | -| Leaders | `l` | Top 25 players by stat category | -| Playoff Picture | `p` | Conference seeding with clinch and elimination status | -| Bracket | `k` | Playoff series matchups and series records | -| Box Score | `b` | Per-player live stats for a selected game | - -### TUI key bindings - -| Key | Action | -|---------|------------------------------------------------| -| `1`–`9` | Load box score for game N from the Scores tab | -| `,` | Cycle to the previous leaders stat category | -| `.` | Cycle to the next leaders stat category | -| `r` | Refresh all data immediately | -| `q` | Quit | - -### Leaders stat categories - -Cycles through: Points, Rebounds, Assists, Steals, Blocks, Efficiency, FG%, FT%, 3P% - -## Static output - -Print scores or standings directly to the terminal without launching the TUI: - -| Argument | Shortcut | Description | -|---------------|----------|-----------------------------------| -| `--scores` | `-sc` | Print today's scoreboard | -| `--standings` | `-st` | Print current conference standings| - -```shell -nba --scores -nba --standings -``` - -# Contributing - -[Back to top](#table-of-contents) - -Any and all contributions are welcome. Feel free to fork the project, -add features, and submit a pull request. deleted file mode 100644 @@ -1,23 +0,0 @@ -LICENSE -README.md -pyproject.toml -nba/__init__.py -nba/__main__.py -nba/box_score.py -nba/bracket.py -nba/cli.py -nba/fetch_data.py -nba/leaders.py -nba/playoff.py -nba/scores.py -nba/standings.py -nba/tui/__init__.py -nba/tui/app.py -nba/tui/styles.tcss -nba/tui/widgets.py -nba_scores.egg-info/PKG-INFO -nba_scores.egg-info/SOURCES.txt -nba_scores.egg-info/dependency_links.txt -nba_scores.egg-info/entry_points.txt -nba_scores.egg-info/requires.txt -nba_scores.egg-info/top_level.txt \ No newline at end of file deleted file mode 100644 @@ -1 +0,0 @@ - deleted file mode 100644 @@ -1,2 +0,0 @@ -[console_scripts] -nba = nba.cli:nba deleted file mode 100644 @@ -1,4 +0,0 @@ -nba_api -tabulate -argparse -textual<7.0,>=0.89.1 deleted file mode 100644 @@ -1 +0,0 @@ -nba