Skip to content

Commit 523701c

Browse files
jrconlintiftran
andauthored
feat: Add MLB support for Sport Suggestions (#1283)
* feat: Add MLB support for Sport Suggestions Closes DISCO-3958 * feedback changes --------- Co-authored-by: Tif Tran <ttran@mozilla.com>
1 parent c9b0fa1 commit 523701c

File tree

4 files changed

+257
-73
lines changed

4 files changed

+257
-73
lines changed

merino/configs/default.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ hourly_forecast_ttl_sec = 1800
374374
type="sports"
375375
# MERINO_PROVIDERS__SPORTS__SPORTS
376376
# e.g. ["NFL","NBA","NHL"]
377-
sports=["NFL","NBA","NHL"]
377+
sports=["NFL","NBA","NHL","MLB"]
378378
# Index names for Elastic Search.
379379
event_index="sports-{lang}-event"
380380
team_index="sports-{lang}-team"

merino/jobs/sportsdata_jobs/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
NFL,
4646
NBA,
4747
NHL,
48+
MLB,
4849
# UCL,
4950
# MLB,
5051
# EPL,
@@ -109,8 +110,8 @@ def __init__(
109110
sport = NHL(settings)
110111
# case "UCL":
111112
# sport = UCL(settings)
112-
# case "MLB":
113-
# sport = MLB(settings)
113+
case "MLB":
114+
sport = MLB(settings)
114115
# case "EPL":
115116
# sport = EPL(settings)
116117
case _:

merino/providers/suggest/sports/backends/sportsdata/common/sports.py

Lines changed: 127 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -431,78 +431,135 @@ async def update_events(self, client: AsyncClient, allow_no_teams: bool = False)
431431
return self
432432

433433

434-
# THE FOLLOWING CLASSES ARE WIP:::
434+
class MLB(Sport):
435+
"""Major League Baseball"""
435436

437+
season: str | None = None
438+
_lock: asyncio.Lock
436439

437-
# class MLB(Sport):
438-
# """Major League Baseball"""
439-
#
440-
# def __init__(self, settings: LazySettings, *args, **kwargs):
441-
# name = self.__class__.__name__
442-
#
443-
# super().__init__(
444-
# name=name,
445-
# base_url=settings.sportsdata.get(
446-
# f"base_url.{name.lower()}",
447-
# default=f"https://api.sportsdata.io/v3/{name.lower()}/scores/json",
448-
# ),
449-
# season=None,
450-
# week=None,
451-
# teams={},
452-
# *args,
453-
# **kwargs,
454-
# )
455-
# self._lock = asyncio.Lock()
456-
#
457-
# async def update_events(self, client: AsyncClient) -> list[Event]:
458-
# """Fetch the list of events for the sport. (5 min interval)"""
459-
# # https://api.sportsdata.io/v3/mlb/scores/json/teams?key=
460-
# # Sample:
461-
# """
462-
# [
463-
# {
464-
# "AwayTeamRuns": 0,
465-
# "HomeTeamRuns": 0,
466-
# "AwayTeamHits": 2,
467-
# "HomeTeamHits": 5,
468-
# "AwayTeamErrors": 0,
469-
# "HomeTeamErrors": 0,
470-
# "Attendance": null,
471-
# "GlobalGameID": 10076415,
472-
# "GlobalAwayTeamID": 10000012,
473-
# "GlobalHomeTeamID": 10000032,
474-
# "NeutralVenue": false,
475-
# "Inning": 4,
476-
# "InningHalf": "B",
477-
# "GameID": 76415,
478-
# "Season": 2025,
479-
# "SeasonType": 1,
480-
# "Status": "InProgress",
481-
# "Day": "2025-09-04T00:00:00",
482-
# "DateTime": "2025-09-04T16:10:00",
483-
# "AwayTeam": "PHI",
484-
# "HomeTeam": "MIL",
485-
# "AwayTeamID": 12,
486-
# "HomeTeamID": 32,
487-
# "RescheduledGameID": null,
488-
# "StadiumID": 92,
489-
# "IsClosed": false,
490-
# "Updated": "2025-09-04T17:19:20",
491-
# "GameEndDateTime": null,
492-
# "DateTimeUTC": "2025-09-04T20:10:00",
493-
# "RescheduledFromGameID": null,
494-
# "SuspensionResumeDay": null,
495-
# "SuspensionResumeDateTime": null,
496-
# "SeriesInfo": null
497-
# },
498-
# ...]
499-
# """
500-
# date = datetime.now(tz=ZoneInfo("America/New_York")).strftime("%Y-%b-%d")
501-
# season = str(date)
502-
# url = f"{self.base_url}/ScoresBasic/{season}?key={self.api_key}"
503-
# await get_data(client, url)
504-
# # TODO: Parse events
505-
# return []
440+
def __init__(self, settings: LazySettings, *args, **kwargs):
441+
name = self.__class__.__name__
442+
super().__init__(
443+
settings=settings,
444+
name=name,
445+
base_url=settings.sportsdata.get(
446+
f"base_url.{name.lower()}",
447+
default=f"https://api.sportsdata.io/v3/{name.lower()}/scores/json",
448+
),
449+
season=None,
450+
cache_dir=settings.sportsdata.get("cache_dir"),
451+
event_ttl=timedelta(hours=48),
452+
team_ttl=timedelta(weeks=4),
453+
)
454+
self._lock = asyncio.Lock()
455+
456+
async def get_season(self, client: AsyncClient):
457+
"""Get the current season"""
458+
if self.season is not None:
459+
return self
460+
logger = logging.getLogger(__name__)
461+
logger.debug(f"{LOGGING_TAG} Getting {self.name} season")
462+
url = f"{self.base_url}/CurrentSeason?key={self.api_key}"
463+
response = await get_data(
464+
client=client,
465+
url=url,
466+
ttl=timedelta(hours=4),
467+
cache_dir=self.cache_dir,
468+
)
469+
"""
470+
{
471+
"Season":2026,
472+
"RegularSeasonStartDate":"2025-10-07T00:00:00",
473+
"PostSeasonStartDate":"2026-04-18T00:00:00",
474+
"SeasonType":"PRE",
475+
"ApiSeason":"2026PRE"
476+
}
477+
"""
478+
self.season = response.get("ApiSeason")
479+
return self
480+
481+
async def update_teams(self, client: AsyncClient):
482+
"""Fetch active team information"""
483+
await self.get_season(client=client)
484+
logger = logging.getLogger(__name__)
485+
if self.season is None:
486+
logger.info(f"{LOGGING_TAG} Skipping out of season {self.name}")
487+
return self
488+
logger.debug(f"{LOGGING_TAG} Getting {self.name} teams ")
489+
url = f"{self.base_url}/teams?key={self.api_key}"
490+
response = await get_data(
491+
client=client,
492+
url=url,
493+
ttl=timedelta(hours=4),
494+
cache_dir=self.cache_dir,
495+
)
496+
async with self._lock:
497+
self.load_teams_from_source(response)
498+
return self
499+
500+
async def get_team(self, id: int) -> Team | None:
501+
"""Attempt to find the team information in a thread-locking manner."""
502+
async with self._lock:
503+
team = self.teams.get(id)
504+
return team
505+
506+
async def update_events(self, client: AsyncClient, allow_no_teams: bool = False):
507+
"""Fetch the list of events for the sport. (5 min interval)"""
508+
local_timezone = ZoneInfo("America/New_York")
509+
date = datetime.now(tz=local_timezone).strftime("%Y-%b-%d").upper()
510+
url = f"{self.base_url}/ScoresBasic/{date}?key={self.api_key}"
511+
"""
512+
[
513+
{
514+
"AwayTeamRuns": 0,
515+
"HomeTeamRuns": 0,
516+
"AwayTeamHits": 2,
517+
"HomeTeamHits": 5,
518+
"AwayTeamErrors": 0,
519+
"HomeTeamErrors": 0,
520+
"Attendance": null,
521+
"GlobalGameID": 10076415,
522+
"GlobalAwayTeamID": 10000012,
523+
"GlobalHomeTeamID": 10000032,
524+
"NeutralVenue": false,
525+
"Inning": 4,
526+
"InningHalf": "B",
527+
"GameID": 76415,
528+
"Season": 2025,
529+
"SeasonType": 1,
530+
"Status": "InProgress",
531+
"Day": "2025-09-04T00:00:00",
532+
"DateTime": "2025-09-04T16:10:00",
533+
"AwayTeam": "PHI",
534+
"HomeTeam": "MIL",
535+
"AwayTeamID": 12,
536+
"HomeTeamID": 32,
537+
"RescheduledGameID": null,
538+
"StadiumID": 92,
539+
"IsClosed": false,
540+
"Updated": "2025-09-04T17:19:20",
541+
"GameEndDateTime": null,
542+
"DateTimeUTC": "2025-09-04T20:10:00",
543+
"RescheduledFromGameID": null,
544+
"SuspensionResumeDay": null,
545+
"SuspensionResumeDateTime": null,
546+
"SeriesInfo": null
547+
},
548+
...]
549+
"""
550+
response = await get_data(
551+
client=client,
552+
url=url,
553+
ttl=timedelta(minutes=5),
554+
cache_dir=self.cache_dir,
555+
)
556+
self.load_scores_from_source(
557+
response, event_timezone=local_timezone, allow_no_teams=allow_no_teams
558+
)
559+
return self
560+
561+
562+
# THE FOLLOWING CLASSES ARE WIP:::
506563

507564

508565
# class EPL(Sport):

tests/unit/providers/suggest/sports/backends/common/test_sports.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
NHL,
2424
NBA,
2525
UCL,
26+
MLB,
2627
)
2728

2829

@@ -60,6 +61,34 @@ def nfl_teams_payload() -> list[dict]:
6061
]
6162

6263

64+
@pytest.fixture
65+
def generic_teams_payload() -> list[dict]:
66+
"""Provide Generic team payload (for US based sports)."""
67+
return [
68+
{
69+
"Key": "HOM",
70+
"Name": "Homebodies",
71+
"City": "Springfield",
72+
"AreaName": "US",
73+
"FullName": "Springfield Homebodies",
74+
"Nickname1": "Homers",
75+
"GlobalTeamId": 98,
76+
"PrimaryColor": "000000",
77+
"SecondaryColor": "FFFFFF",
78+
},
79+
{
80+
"Key": "AWY",
81+
"Name": "Visitors",
82+
"City": "Elsewhere",
83+
"AreaName": "OS",
84+
"GlobalTeamId": 99,
85+
"FullName": "Visitors from Elsewhere",
86+
"PrimaryColor": "FFFFFF",
87+
"SecondaryColor": "000000",
88+
},
89+
]
90+
91+
6392
@pytest.fixture
6493
def nhl_nba_teams_payload() -> list[dict]:
6594
"""NHL/NBA team payload."""
@@ -173,6 +202,61 @@ def schedules_payload() -> list[dict]:
173202
]
174203

175204

205+
@pytest.fixture
206+
def generic_schedules_payload() -> list[dict]:
207+
"""Provide Generic team Schedules payload that are out and in window."""
208+
return [
209+
{
210+
"GameId": 67890,
211+
"Season": 2026,
212+
"SeasonType": 2,
213+
"Status": "Final",
214+
"Day": "2025-09-21T00:00:00",
215+
"DateTime": "2025-09-21T21:30:00",
216+
"Updated": "2025-09-29T04:10:57",
217+
"IsClosed": True,
218+
"AwayTeam": "AWY",
219+
"HomeTeam": "HOM",
220+
"StadiumID": 9,
221+
"AwayTeamScore": 2,
222+
"HomeTeamScore": 3,
223+
"GlobalGameID": 12345678,
224+
"GlobalAwayTeamID": 99,
225+
"GlobalHomeTeamID": 98,
226+
"GameEndDateTime": "2025-09-22T00:10:17",
227+
"NeutralVenue": False,
228+
"DateTimeUTC": "2025-09-22T01:30:00",
229+
"AwayTeamID": 99,
230+
"HomeTeamID": 98,
231+
"SeriesInfo": None,
232+
},
233+
{
234+
"GameId": 11111,
235+
"Season": 2000,
236+
"SeasonType": 2,
237+
"Status": "Final",
238+
"Day": "2000-01-01T00:00:00",
239+
"DateTime": "2000-01-01T21:30:00",
240+
"Updated": "2000-01-01T04:10:57",
241+
"IsClosed": True,
242+
"AwayTeam": "AWY",
243+
"HomeTeam": "HOM",
244+
"StadiumID": 9,
245+
"AwayTeamScore": 0,
246+
"HomeTeamScore": 0,
247+
"GlobalGameID": 0,
248+
"GlobalAwayTeamID": 99,
249+
"GlobalHomeTeamID": 98,
250+
"GameEndDateTime": "2000-09-22T00:10:17",
251+
"NeutralVenue": False,
252+
"DateTimeUTC": "2000-09-22T01:30:00",
253+
"AwayTeamID": 99,
254+
"HomeTeamID": 98,
255+
"SeriesInfo": None,
256+
},
257+
]
258+
259+
176260
@pytest.fixture
177261
def nfl_scores_payload() -> list[dict[str, Any]]:
178262
"""NFL scores payload."""
@@ -379,6 +463,23 @@ async def test_nba_update_teams(
379463
assert get_data.call_count == 2
380464

381465

466+
@pytest.mark.asyncio
467+
async def test_mlb_update_teams(
468+
generic_teams_payload: list[dict], mock_client: AsyncClient, mocker: MockerFixture
469+
) -> None:
470+
"""Test MLB team updates."""
471+
sport = MLB(settings=settings.providers.sports)
472+
current_season = {"ApiSeason": "2026PRE"}
473+
get_data = mocker.patch(
474+
"merino.providers.suggest.sports.backends.sportsdata.common.sports.get_data",
475+
side_effect=[current_season, generic_teams_payload],
476+
)
477+
await sport.update_teams(client=mock_client)
478+
assert sport.season == "2026PRE"
479+
assert set(sport.teams.keys()) == {98, 99}
480+
assert get_data.call_count == 2
481+
482+
382483
@freezegun.freeze_time("2025-09-22T00:00:00", tz_offset=0)
383484
@pytest.mark.asyncio
384485
async def test_ucl_update_teams(
@@ -534,6 +635,31 @@ async def test_nba_update_events(
534635
get_data.assert_called_once()
535636

536637

638+
@freezegun.freeze_time("2025-09-22T00:00:00", tz_offset=0)
639+
@pytest.mark.asyncio
640+
async def test_mlb_update_events(
641+
generic_teams_payload: list[dict],
642+
generic_schedules_payload: list[dict],
643+
mock_client: AsyncClient,
644+
mocker: MockerFixture,
645+
) -> None:
646+
"""Test MLB event updates."""
647+
sport = MLB(settings=settings.providers.sports)
648+
sport.load_teams_from_source(generic_teams_payload)
649+
sport.season = "2026PRE"
650+
sport.event_ttl = timedelta(weeks=2)
651+
652+
get_data = mocker.patch(
653+
"merino.providers.suggest.sports.backends.sportsdata.common.sports.get_data",
654+
return_value=generic_schedules_payload,
655+
)
656+
657+
await sport.update_events(client=mock_client)
658+
assert 12345678 in sport.events and 0 not in sport.events
659+
assert sport.events[12345678].status == GameStatus.Final
660+
get_data.assert_called_once()
661+
662+
537663
@freezegun.freeze_time("2025-09-22T00:00:00", tz_offset=0)
538664
@pytest.mark.asyncio
539665
async def test_ucl_update_events(

0 commit comments

Comments
 (0)