feat(providers): add balldontlie http adapter with backend selection
This commit is contained in:
@ -7,6 +7,7 @@ from apps.ingestion.models import IngestionError, IngestionRun
|
||||
from apps.ingestion.services.sync import run_sync_job
|
||||
from apps.players.models import Player
|
||||
from apps.providers.exceptions import ProviderRateLimitError
|
||||
from apps.providers.models import ExternalMapping
|
||||
from apps.stats.models import PlayerSeason, PlayerSeasonStats
|
||||
from apps.teams.models import Team
|
||||
|
||||
@ -81,3 +82,128 @@ def test_run_sync_handles_rate_limit(settings):
|
||||
assert IngestionError.objects.filter(ingestion_run=run).exists()
|
||||
|
||||
os.environ.pop("PROVIDER_MVP_FORCE_RATE_LIMIT", None)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_balldontlie_sync_idempotency_with_stable_payload(monkeypatch):
|
||||
class StableProvider:
|
||||
def sync_all(self):
|
||||
return {
|
||||
"competitions": [
|
||||
{
|
||||
"external_id": "competition-nba",
|
||||
"name": "NBA",
|
||||
"slug": "nba",
|
||||
"competition_type": "league",
|
||||
"gender": "men",
|
||||
"level": 1,
|
||||
"country": {"name": "United States", "iso2_code": "US", "iso3_code": "USA"},
|
||||
"is_active": True,
|
||||
}
|
||||
],
|
||||
"teams": [
|
||||
{
|
||||
"external_id": "team-14",
|
||||
"name": "Los Angeles Lakers",
|
||||
"short_name": "LAL",
|
||||
"slug": "los-angeles-lakers",
|
||||
"country": {"name": "United States", "iso2_code": "US", "iso3_code": "USA"},
|
||||
"is_national_team": False,
|
||||
}
|
||||
],
|
||||
"seasons": [
|
||||
{
|
||||
"external_id": "season-2024",
|
||||
"label": "2024-2025",
|
||||
"start_date": "2024-10-01",
|
||||
"end_date": "2025-06-30",
|
||||
"is_current": False,
|
||||
}
|
||||
],
|
||||
"players": [
|
||||
{
|
||||
"external_id": "player-237",
|
||||
"first_name": "LeBron",
|
||||
"last_name": "James",
|
||||
"full_name": "LeBron James",
|
||||
"birth_date": None,
|
||||
"nationality": {"name": "United States", "iso2_code": "US", "iso3_code": "USA"},
|
||||
"nominal_position": {"code": "SF", "name": "Small Forward"},
|
||||
"inferred_role": {"code": "wing", "name": "Wing"},
|
||||
"height_cm": None,
|
||||
"weight_kg": None,
|
||||
"dominant_hand": "unknown",
|
||||
"is_active": True,
|
||||
"aliases": [],
|
||||
}
|
||||
],
|
||||
"player_stats": [
|
||||
{
|
||||
"external_id": "ps-2024-237-14",
|
||||
"player_external_id": "player-237",
|
||||
"team_external_id": "team-14",
|
||||
"competition_external_id": "competition-nba",
|
||||
"season_external_id": "season-2024",
|
||||
"games_played": 2,
|
||||
"games_started": 0,
|
||||
"minutes_played": 68,
|
||||
"points": 25,
|
||||
"rebounds": 9,
|
||||
"assists": 8,
|
||||
"steals": 1.5,
|
||||
"blocks": 0.5,
|
||||
"turnovers": 3.5,
|
||||
"fg_pct": 55.0,
|
||||
"three_pct": 45.0,
|
||||
"ft_pct": 95.0,
|
||||
"usage_rate": None,
|
||||
"true_shooting_pct": None,
|
||||
"player_efficiency_rating": None,
|
||||
}
|
||||
],
|
||||
"player_careers": [
|
||||
{
|
||||
"external_id": "career-2024-237-14",
|
||||
"player_external_id": "player-237",
|
||||
"team_external_id": "team-14",
|
||||
"competition_external_id": "competition-nba",
|
||||
"season_external_id": "season-2024",
|
||||
"role_code": "",
|
||||
"shirt_number": None,
|
||||
"start_date": "2024-10-01",
|
||||
"end_date": "2025-06-30",
|
||||
"notes": "Imported from balldontlie aggregated box scores",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def sync_incremental(self, *, cursor: str | None = None):
|
||||
payload = self.sync_all()
|
||||
payload["cursor"] = cursor
|
||||
return payload
|
||||
|
||||
monkeypatch.setattr("apps.ingestion.services.sync.get_provider", lambda namespace: StableProvider())
|
||||
|
||||
run_sync_job(provider_namespace="balldontlie", job_type=IngestionRun.JobType.FULL_SYNC)
|
||||
counts_first = {
|
||||
"competition": Competition.objects.count(),
|
||||
"team": Team.objects.count(),
|
||||
"season": Season.objects.count(),
|
||||
"player": Player.objects.count(),
|
||||
"player_season": PlayerSeason.objects.count(),
|
||||
"player_stats": PlayerSeasonStats.objects.count(),
|
||||
"mapping": ExternalMapping.objects.filter(provider_namespace="balldontlie").count(),
|
||||
}
|
||||
|
||||
run_sync_job(provider_namespace="balldontlie", job_type=IngestionRun.JobType.FULL_SYNC)
|
||||
counts_second = {
|
||||
"competition": Competition.objects.count(),
|
||||
"team": Team.objects.count(),
|
||||
"season": Season.objects.count(),
|
||||
"player": Player.objects.count(),
|
||||
"player_season": PlayerSeason.objects.count(),
|
||||
"player_stats": PlayerSeasonStats.objects.count(),
|
||||
"mapping": ExternalMapping.objects.filter(provider_namespace="balldontlie").count(),
|
||||
}
|
||||
|
||||
assert counts_first == counts_second
|
||||
|
||||
183
tests/test_provider_balldontlie.py
Normal file
183
tests/test_provider_balldontlie.py
Normal file
@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from apps.providers.adapters.balldontlie_provider import BalldontlieProviderAdapter
|
||||
from apps.providers.adapters.mvp_provider import MvpDemoProviderAdapter
|
||||
from apps.providers.clients.balldontlie import BalldontlieClient
|
||||
from apps.providers.exceptions import ProviderRateLimitError, ProviderTransientError
|
||||
from apps.providers.registry import get_default_provider_namespace, get_provider
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, *, status_code: int, payload: dict[str, Any] | None = None, headers: dict[str, str] | None = None, text: str = ""):
|
||||
self.status_code = status_code
|
||||
self._payload = payload or {}
|
||||
self.headers = headers or {}
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, responses: list[Any]):
|
||||
self._responses = responses
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
item = self._responses.pop(0)
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
return item
|
||||
|
||||
|
||||
class _FakeBalldontlieClient:
|
||||
def get_json(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
if path == "teams":
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": 14,
|
||||
"full_name": "Los Angeles Lakers",
|
||||
"abbreviation": "LAL",
|
||||
}
|
||||
]
|
||||
}
|
||||
return {"data": []}
|
||||
|
||||
def list_paginated(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
per_page: int = 100,
|
||||
page_limit: int = 1,
|
||||
) -> list[dict[str, Any]]:
|
||||
if path == "players":
|
||||
return [
|
||||
{
|
||||
"id": 237,
|
||||
"first_name": "LeBron",
|
||||
"last_name": "James",
|
||||
"position": "F",
|
||||
"team": {"id": 14},
|
||||
}
|
||||
]
|
||||
if path == "stats":
|
||||
return [
|
||||
{
|
||||
"pts": 20,
|
||||
"reb": 8,
|
||||
"ast": 7,
|
||||
"stl": 1,
|
||||
"blk": 1,
|
||||
"turnover": 3,
|
||||
"fg_pct": 0.5,
|
||||
"fg3_pct": 0.4,
|
||||
"ft_pct": 0.9,
|
||||
"min": "35:12",
|
||||
"player": {"id": 237},
|
||||
"team": {"id": 14},
|
||||
"game": {"season": 2024},
|
||||
},
|
||||
{
|
||||
"pts": 30,
|
||||
"reb": 10,
|
||||
"ast": 9,
|
||||
"stl": 2,
|
||||
"blk": 0,
|
||||
"turnover": 4,
|
||||
"fg_pct": 0.6,
|
||||
"fg3_pct": 0.5,
|
||||
"ft_pct": 1.0,
|
||||
"min": "33:00",
|
||||
"player": {"id": 237},
|
||||
"team": {"id": 14},
|
||||
"game": {"season": 2024},
|
||||
},
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_provider_registry_backend_selection(settings):
|
||||
settings.PROVIDER_DEFAULT_NAMESPACE = ""
|
||||
settings.PROVIDER_BACKEND = "demo"
|
||||
assert get_default_provider_namespace() == "mvp_demo"
|
||||
assert isinstance(get_provider(), MvpDemoProviderAdapter)
|
||||
|
||||
settings.PROVIDER_BACKEND = "balldontlie"
|
||||
assert get_default_provider_namespace() == "balldontlie"
|
||||
assert isinstance(get_provider(), BalldontlieProviderAdapter)
|
||||
|
||||
settings.PROVIDER_DEFAULT_NAMESPACE = "mvp_demo"
|
||||
assert get_default_provider_namespace() == "mvp_demo"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_balldontlie_adapter_maps_payloads(settings):
|
||||
settings.PROVIDER_BALLDONTLIE_SEASONS = [2024]
|
||||
adapter = BalldontlieProviderAdapter(client=_FakeBalldontlieClient())
|
||||
|
||||
payload = adapter.sync_all()
|
||||
|
||||
assert payload["competitions"][0]["external_id"] == "competition-nba"
|
||||
assert payload["teams"][0]["external_id"] == "team-14"
|
||||
assert payload["players"][0]["external_id"] == "player-237"
|
||||
assert payload["seasons"][0]["external_id"] == "season-2024"
|
||||
assert payload["player_stats"][0]["games_played"] == 2
|
||||
assert payload["player_stats"][0]["points"] == 25.0
|
||||
assert payload["player_stats"][0]["fg_pct"] == 55.0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_balldontlie_client_retries_after_rate_limit(monkeypatch, settings):
|
||||
monkeypatch.setattr(time, "sleep", lambda _: None)
|
||||
settings.PROVIDER_REQUEST_RETRIES = 2
|
||||
settings.PROVIDER_REQUEST_RETRY_SLEEP = 0
|
||||
|
||||
session = _FakeSession(
|
||||
responses=[
|
||||
_FakeResponse(status_code=429, headers={"Retry-After": "0"}),
|
||||
_FakeResponse(status_code=200, payload={"data": []}),
|
||||
]
|
||||
)
|
||||
client = BalldontlieClient(session=session)
|
||||
|
||||
payload = client.get_json("players")
|
||||
assert payload == {"data": []}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_balldontlie_client_timeout_retries_then_fails(monkeypatch, settings):
|
||||
monkeypatch.setattr(time, "sleep", lambda _: None)
|
||||
settings.PROVIDER_REQUEST_RETRIES = 2
|
||||
settings.PROVIDER_REQUEST_RETRY_SLEEP = 0
|
||||
|
||||
session = _FakeSession(responses=[requests.Timeout("slow"), requests.Timeout("slow")])
|
||||
client = BalldontlieClient(session=session)
|
||||
|
||||
with pytest.raises(ProviderTransientError):
|
||||
client.get_json("players")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_balldontlie_client_raises_rate_limit_after_max_retries(monkeypatch, settings):
|
||||
monkeypatch.setattr(time, "sleep", lambda _: None)
|
||||
settings.PROVIDER_REQUEST_RETRIES = 2
|
||||
settings.PROVIDER_REQUEST_RETRY_SLEEP = 0
|
||||
|
||||
session = _FakeSession(
|
||||
responses=[
|
||||
_FakeResponse(status_code=429, headers={"Retry-After": "1"}),
|
||||
_FakeResponse(status_code=429, headers={"Retry-After": "1"}),
|
||||
]
|
||||
)
|
||||
client = BalldontlieClient(session=session)
|
||||
|
||||
with pytest.raises(ProviderRateLimitError):
|
||||
client.get_json("players")
|
||||
Reference in New Issue
Block a user