Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/nba_api/library/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
88 changes: 88 additions & 0 deletions src/nba_api/stats/library/lineup_tracker.py
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions tests/library/test_lineup_tracker.py
Original file line number Diff line number Diff line change
@@ -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