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
« 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.
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:
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)
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"""
17import asyncio
18import logging
19from collections.abc import Callable
21from ...domain.models import Event
22from ...ports.outbound import ECNLAPIPort
24logger = logging.getLogger(__name__)
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
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}
41class DiscoveryAdapter:
42 """Enumerates and classifies events by walking org club lists."""
44 def __init__(self, repo: ECNLAPIPort, org_ids: dict[tuple[str, str], int] | None = None) -> None:
45 """Initialize discovery over an ECNLAPIPort.
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)
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.
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.
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)
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 []
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
83 async def _gather_events(self, orgs: list[int]) -> list[Event]:
84 """Fan out across orgs to collect every distinct event, then fetch each.
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)))
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)
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 )