Commit 4d40494f3e

4d40494f3e9aade0f5302678e4df67a7613c157b

parent: b542a36c07

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-16 03:37 UTC

update README

Layout: unified · split

README.md +1 −9
@@ -19,18 +19,10 @@ auto-refresh.
19 19
20[Back to top](#table-of-contents) 20[Back to top](#table-of-contents)
21 21
22## PyPi
23
24```shell
25pipx install nba-scores
26```
27
28## Manual
29
30```shell 22```shell
31git clone https://git.cleberg.net/nba-scores.git 23git clone https://git.cleberg.net/nba-scores.git
32cd nba-scores 24cd nba-scores
33uv sync 25pipx install .
34``` 26```
35 27
36# Usage 28# Usage
build/lib/nba/__init__.py added
build/lib/nba/__main__.py added +8
@@ -0,0 +1,8 @@
1"""
2Entry point for the app.
3"""
4
5if __name__ == "__main__":
6 from nba.cli import nba
7
8 nba()
build/lib/nba/box_score.py added +81
@@ -0,0 +1,81 @@
1"""
2Fetches and formats live box scores for individual games.
3"""
4
5import json
6
7from tabulate import tabulate
8from nba_api.live.nba.endpoints.boxscore import BoxScore
9
10BOLD = "\033[1m"
11END = "\033[0m"
12
13_HEADERS = ["Player", "Pos", "Min", "Pts", "Reb", "Ast", "Stl", "Blk", "TO", "FG", "3P", "FT", "+/-"]
14
15
16def fetch_box_score(game_id: str) -> dict:
17 endpoint = BoxScore(game_id=game_id)
18 return json.loads(endpoint.get_json())
19
20
21def _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
33def _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
57def 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"""
2This script uses argparse to parse command line arguments.
3
4It imports the required modules and sets up a parser with basic options for demonstration purposes.
5"""
6
7import argparse
8from nba import fetch_data, scores, standings
9
10
11def 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"""
2Fetches data for use in other modules.
3"""
4
5import json
6from nba_api.live.nba.endpoints import scoreboard
7from nba_api.stats.endpoints import leaguestandings
8
9
10def 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"""
2Fetches and formats NBA statistical leaders.
3"""
4
5import json
6
7from tabulate import tabulate
8from nba_api.stats.endpoints.leagueleaders import LeagueLeaders
9
10BOLD = "\033[1m"
11END = "\033[0m"
12
13# Ordered list of (api_abbreviation, display_label)
14CATEGORIES = [
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
46def 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
54def 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"""
2Fetches and formats the NBA playoff picture.
3"""
4
5import json
6
7from tabulate import tabulate
8from nba_api.stats.endpoints.playoffpicture import PlayoffPicture
9
10BOLD = "\033[1m"
11END = "\033[0m"
12RED = "\033[91m"
13GREEN = "\033[32m"
14YELLOW = "\033[33m"
15
16
17def fetch_playoff_picture() -> dict:
18 endpoint = PlayoffPicture()
19 return json.loads(endpoint.get_json())
20
21
22def _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
38def _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
75def get_west_playoff_table(data: dict) -> str:
76 return _build_conference_table(data["resultSets"], "WestConfStandings")
77
78
79def 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"""
2Tabulates a scoreboard for today's games.
3"""
4
5from tabulate import tabulate
6
7# ANSI escape codes for text formatting
8BOLD = "\033[1m"
9END = "\033[0m"
10RED = "\033[91m"
11GREEN = "\033[32m"
12
13
14# Function to get team record from standings
15def 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
34def 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
98def 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"""
2Tabulate the current conference standings.
3"""
4
5from tabulate import tabulate
6
7# ANSI escape codes for text formatting
8BOLD = "\033[1m"
9END = "\033[0m"
10RED = "\033[91m"
11GREEN = "\033[32m"
12
13
14def _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
56def 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
61def 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
66def 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
79def 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"""
2Textual TUI application for NBA scores and standings.
3"""
4
5from __future__ import annotations
6
7import asyncio
8from pathlib import Path
9
10from rich.text import Text
11from textual.app import App, ComposeResult
12from textual.containers import Horizontal
13from textual.widgets import Footer, Header, Static, TabbedContent, TabPane
14
15from nba import fetch_data
16from nba import leaders as leaders_mod
17from nba import playoff as playoff_mod
18from nba import box_score as box_score_mod
19from nba.scores import get_scoreboard_table
20from nba.standings import get_east_standings_table, get_west_standings_table
21from nba.tui.widgets import CountdownBar, ScoresWidget
22
23
24class 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 @@
1Screen {
2 background: $background;
3}
4
5TabbedContent {
6 height: 1fr;
7}
8
9TabPane {
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"""
2Custom Textual widgets for the NBA scores TUI.
3"""
4
5from textual.reactive import reactive
6from textual.widgets import Static
7
8
9class ScoresWidget(Static):
10 """Displays the NBA scoreboard as a scrollable ANSI-formatted table."""
11
12
13class StandingsWidget(Static):
14 """Displays the NBA standings as a scrollable ANSI-formatted table."""
15
16
17class 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# NBA 20# NBA
21 21
22NBA is a simple Python package that provides you with a quick and easy 22![](./screenshots/preview.png)
23command-line interface to current NBA scores and standings.
24 23
25See the [Installation](#installation) & [Usage](#usage) sections below 24NBA is a Python package that provides a command-line interface to current NBA
26for more information. 25scores, standings, statistical leaders, playoff picture, and live box scores.
26It supports both a static one-shot mode and an interactive TUI with
27auto-refresh.
27 28
28# Table of Contents 29# Table of Contents
29 30
30- [Installation](#installation) 31- [Installation](#installation)
31- [Usage](#usage) 32- [Usage](#usage)
33 - [Interactive TUI](#interactive-tui)
34 - [Static output](#static-output)
32- [Contributing](#contributing) 35- [Contributing](#contributing)
33 36
34# Installation 37# Installation
@@ -43,33 +46,70 @@ pipx install nba-scores
43 46
44## Manual 47## Manual
45 48
46To run the script locally, run the following commands:
47
48```shell 49```shell
49git clone https://git.cleberg.net/nba-scores.git 50git clone https://git.cleberg.net/nba-scores.git
50cd nba-scores 51cd nba-scores
51pipx install . 52uv sync
52``` 53```
53 54
54![Installation](./screenshots/installation.png)
55
56# Usage 55# Usage
57 56
58[Back to top](#table-of-contents) 57[Back to top](#table-of-contents)
59 58
60All commands can be passed to the program with the following template: 59## Interactive TUI
61`nba <ARGUMENT>` 60
61Launch the full interactive terminal UI:
62
63```shell
64nba --tui
65```
66
67Optional 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
98Cycles through: Points, Rebounds, Assists, Steals, Blocks, Efficiency, FG%, FT%, 3P%
99
100## Static output
101
102Print scores or standings directly to the terminal without launching the TUI:
62 103
63| Argument | Shortcut | Description | 104| Argument | Shortcut | Description |
64|---------------|----------|-----------------------------------| 105|---------------|----------|-----------------------------------|
65| `--scores` | `-sc` | Show today's scoreboard | 106| `--scores` | `-sc` | Print today's scoreboard |
66| `--standings` | `-st` | Show current conference standings | 107| `--standings` | `-st` | Print current conference standings|
67
68Scores:
69![Scores](./screenshots/scores.png)
70 108
71Standings: 109```shell
72![Standings](./screenshots/standings.png) 110nba --scores
111nba --standings
112```
73 113
74# Contributing 114# Contributing
75 115
nba_scores.egg-info/SOURCES.txt +3
@@ -3,8 +3,11 @@ README.md
3pyproject.toml 3pyproject.toml
4nba/__init__.py 4nba/__init__.py
5nba/__main__.py 5nba/__main__.py
6nba/box_score.py
6nba/cli.py 7nba/cli.py
7nba/fetch_data.py 8nba/fetch_data.py
9nba/leaders.py
10nba/playoff.py
8nba/scores.py 11nba/scores.py
9nba/standings.py 12nba/standings.py
10nba/tui/__init__.py 13nba/tui/__init__.py