diff --git a/src/nba_api/library/http.py b/src/nba_api/library/http.py index aeac18ee..108f7d1b 100644 --- a/src/nba_api/library/http.py +++ b/src/nba_api/library/http.py @@ -39,7 +39,16 @@ def get_response(self): return self._response def get_dict(self): - return json.loads(self._response) + try: + return json.loads(self._response) + except json.JSONDecodeError as e: + preview = (self._response or "")[:300].strip() + raise ValueError( + f""" + Failed to parse NBA API response as JSON.\n + Response preview (first 300 chars): {preview!r} + """ + ) from e def get_json(self): return json.dumps(self.get_dict()) diff --git a/src/nba_api/stats/library/lineup_tracker.py b/src/nba_api/stats/library/lineup_tracker.py new file mode 100644 index 00000000..2cf05c08 --- /dev/null +++ b/src/nba_api/stats/library/lineup_tracker.py @@ -0,0 +1,88 @@ +from ..endpoints.boxscoretraditionalv3 import BoxScoreTraditionalV3 +from ..endpoints.playbyplayv3 import PlayByPlayV3 + + +class LineupTracker: + def __init__(self, game_id): + self.game_id = game_id + self.pbp_data = None + self.start_lineup = None + + def fetch_data(self): + """Fetch PBP and Initial Box Score to get 1st Quarter Starters.""" + pbp = PlayByPlayV3(game_id=self.game_id) + + if not pbp: + raise ValueError(f"Invalid game ID: {self.game_id}") + + self.pbp_data = pbp.get_data_frames()[0] + + # Get official starters + box = BoxScoreTraditionalV3(game_id=self.game_id) + box_df = box.get_data_frames()[0] + + starters = box_df[box_df["position"] != ""][["teamId", "personId"]] + + if starters.empty: + starters = box_df.groupby("teamId").head(5)[["teamId", "personId"]] + + self.start_lineup = starters + + def get_starters_for_quarter(self, quarter): + """ + Logic: + 1. Start with Quarter 1 Starters + 2. Iterate through PBP events + 3. If EVENTMSGTYPE == 8 (Substitution), swap PLAYER1 (out) for PLAYER2 (in). + 4. Return the set at the beginning of the requested quarter. + """ + + if quarter == 1: + return self.start_lineup.groupby("teamId")["personId"].aapply(set).to_dict() + + current_lineups = ( + self.start_lineup.groupby("teamId")["personId"].apply(set).to_dict() + ) + + previous_events = self.pbp_data[ + (self.pbp_data["period"] < quarter) + | ( + (self.pbp_data["period"] == quarter) + & (self.pbp_data["clock"] == "PT12M00.00S") + ) + ] + + for _, row in previous_events.iterrows(): + if row["actionType"] == "substitution": + team_id = row["teamId"] + player_out = row["personId"] + player_in = row["personIdValue"] + + if team_id in current_lineups: + current_lineups[team_id].discard(player_out) + current_lineups[team_id].add(player_in) + + for t_id in current_lineups: + current_lineups[t_id] = self.validate_lineup( + current_lineups[t_id], t_id, quarter + ) + + return current_lineups + + def validate_lineup(self, current_set, team_id, quarter): + """Ensures that there are exactly 5 players by looking ahead in the PBP.""" + if len(current_set) == 5: + return current_set + + if len(current_set) < 5: + future_events = self.pbp_data[ + (self.pbp_data_data["period"] == quarter) + & (self.pbp_data["teamId"] == team_id) + ] + for _, row in future_events.iterrows(): + p_id = row["personId"] + if p_id not in current_set and p_id != 0: + current_set.add(p_id) + if len(current_set) == 5: + break + return current_set diff --git a/tests/library/test_lineup_tracker.py b/tests/library/test_lineup_tracker.py new file mode 100644 index 00000000..20afe4f5 --- /dev/null +++ b/tests/library/test_lineup_tracker.py @@ -0,0 +1,41 @@ +import pytest + +from nba_api.stats.library.lineup_tracker import LineupTracker + + +@pytest.mark.live +class TestLineupTracker: + TEST_GAME_ID = "0022300001" + + def test_initialization(self): + tracker = LineupTracker(self.TEST_GAME_ID) + tracker.fetch_data() + assert tracker.pbp_data is not None + assert not tracker.start_lineup.empty + + def test_quarter_transition(self): + tracker = LineupTracker(self.TEST_GAME_ID) + tracker.fetch_data() + + # Test 2nd and 3rd quarters + for q in [2, 3]: + lineups = tracker.get_starters_for_quarter(q) + assert len(lineups) == 2, "Should return data for exactly 2 teams" + for team_id in lineups: + assert len(lineups[team_id]) == 5, ( + f"Quarter {q} failed to return 5 players for team {team_id}" + ) + + def test_invalid_game_id(self): + with pytest.raises(ValueError): + tracker = LineupTracker("9999999999") + tracker.fetch_data() + + def test_overtime_support(self): + ot_game_id = "0022300185" + tracker = LineupTracker(ot_game_id) + tracker.fetch_data() + + ot_lineup = tracker.get_starters_for_quarter(5) + for team_id in ot_lineup: + assert len(ot_lineup[team_id]) == 5