Coverage for src/ecnl/adapters/outbound/discovery.py: 100%

32 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-28 08:19 +0000

1"""DiscoveryAdapter — resolves league/gender/season queries to events. 

2 

3The AthleteOne feed has no "list all events for a season" endpoint. It does, 

4however, expose each league/gender *organization*'s club list, and every club 

5entry carries the ``eventID`` of the event it currently competes in. So 

6discovery works as: 

7 

8 (league, gender) -> orgID (seed map below) 

9 orgID -> get_org_club_events -> distinct event IDs 

10 event ID -> get_event -> Event classified by name (conference/season) 

11 

12Only the four org IDs are seeded; everything else is derived live. The IDs are 

13stable identifiers, not data — see docs/decisions/0001-data-source-athleteone-api.md 

14for how they were resolved and how to re-derive them if the league restructures. 

15""" 

16 

17import asyncio 

18import logging 

19from collections.abc import Callable 

20 

21from ...domain.models import Event 

22from ...ports.outbound import ECNLAPIPort 

23 

24logger = logging.getLogger(__name__) 

25 

26 

27def _norm(value: str | None, transform: Callable[[str], str]) -> str | None: 

28 """Apply ``transform`` to a non-empty value, returning None otherwise.""" 

29 return transform(value) if value else None 

30 

31 

32# (league, gender) -> AthleteOne organization ID. ECRL is "ECNL RL" in the feed. 

33_ORG_IDS: dict[tuple[str, str], int] = { 

34 ("ECNL", "girls"): 9, 

35 ("ECNL", "boys"): 12, 

36 ("ECRL", "girls"): 13, 

37 ("ECRL", "boys"): 16, 

38} 

39 

40 

41class DiscoveryAdapter: 

42 """Enumerates and classifies events by walking org club lists.""" 

43 

44 def __init__(self, repo: ECNLAPIPort, org_ids: dict[tuple[str, str], int] | None = None) -> None: 

45 """Initialize discovery over an ECNLAPIPort. 

46 

47 Args: 

48 repo: The data port used to fetch org club lists and event details. 

49 org_ids: Override for the (league, gender) -> orgID seed map. 

50 """ 

51 self._repo = repo 

52 # `is not None` (not truthiness) so an explicit empty override is honored. 

53 self._org_ids = org_ids if org_ids is not None else dict(_ORG_IDS) 

54 

55 async def find_events( 

56 self, 

57 league: str | None = None, 

58 gender: str | None = None, 

59 season: str | None = None, 

60 ) -> list[Event]: 

61 """Return events matching the given filters. 

62 

63 Args: 

64 league: "ECNL" or "ECRL" (case-insensitive), or None for both. 

65 gender: "girls" or "boys" (case-insensitive), or None for both. 

66 season: Season label like "2025-26", or None for all seasons present. 

67 

68 Returns: 

69 Events sorted by league, gender, then conference. 

70 """ 

71 league_n = _norm(league, str.upper) 

72 gender_n = _norm(gender, str.lower) 

73 

74 orgs = [org_id for key, org_id in self._org_ids.items() if _org_matches(key, league_n, gender_n)] 

75 if not orgs: 

76 return [] 

77 

78 events = await self._gather_events(orgs) 

79 results = [e for e in events if _event_matches(e, league_n, gender_n, season)] 

80 results.sort(key=lambda e: (e.league, e.gender, e.conference)) 

81 return results 

82 

83 async def _gather_events(self, orgs: list[int]) -> list[Event]: 

84 """Fan out across orgs to collect every distinct event, then fetch each. 

85 

86 Bounded by the number of conferences per org (~10-20), so this stays small. 

87 """ 

88 event_id_lists = await asyncio.gather(*(self._repo.get_org_club_events(o) for o in orgs)) 

89 event_ids = sorted({eid for ids in event_id_lists for eid in ids}) 

90 return list(await asyncio.gather(*(self._repo.get_event(eid) for eid in event_ids))) 

91 

92 

93def _org_matches(key: tuple[str, str], league: str | None, gender: str | None) -> bool: 

94 """Return True if an org's (league, gender) key passes the optional filters.""" 

95 org_league, org_gender = key 

96 return (league is None or org_league == league) and (gender is None or org_gender == gender) 

97 

98 

99def _event_matches(event: Event, league: str | None, gender: str | None, season: str | None) -> bool: 

100 """Return True if a classified event passes the optional league/gender/season filters.""" 

101 return ( 

102 (league is None or event.league == league) 

103 and (gender is None or event.gender == gender) 

104 and (season is None or event.season == season) 

105 )