Coverage for src/usls/adapters/outbound/parsers.py: 91%
69 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-05 10:11 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-05 10:11 +0000
1"""Pure functions that map ESPN JSON wire format into domain models.
3Extracted from espn_adapter.py so the adapter focuses on HTTP/transport concerns
4while parsing stays a side-effect-free, easily testable concern.
5"""
7from typing import Any
9from ...domain.exceptions import USLSNotFoundError
10from ...domain.models import Match, MatchCompetitor, MatchDetails, MatchEvent, NewsArticle, Player, Standing, Team
13def _parse_team(raw: dict[str, Any]) -> Team:
14 """Map a raw ESPN team object to a domain Team.
16 Args:
17 raw: The team dict from the ESPN API (may be nested under "team" key).
18 """
19 team = raw.get("team", raw)
20 logos = team.get("logos", [])
21 logo_url = logos[0].get("href") if logos else None
22 return Team(
23 id=str(team.get("id", "")),
24 name=team.get("name", ""),
25 abbreviation=team.get("abbreviation", ""),
26 location=team.get("location", ""),
27 display_name=team.get("displayName", ""),
28 logo_url=logo_url,
29 )
32def _extract_score(raw_score: object) -> str | None:
33 """Pull a clean score string out of either ESPN serialization shape.
35 The scoreboard endpoint returns ``score`` as a primitive (e.g. ``2``), but the
36 team-schedule endpoint returns it as a $ref dict like
37 ``{'$ref': '...', 'value': 2.0, 'displayValue': '2', 'winner': False, ...}``.
38 We prefer ``displayValue``, fall back to ``value``, and stringify primitives.
39 """
40 if raw_score is None:
41 return None
42 if isinstance(raw_score, dict):
43 display = raw_score.get("displayValue")
44 if display is not None:
45 return str(display)
46 value = raw_score.get("value")
47 return str(int(value)) if isinstance(value, int | float) else None
48 return str(raw_score)
51def _extract_winner(raw_score: object, raw_winner: object) -> bool | None:
52 """Pull ``winner`` from the competitor, falling back to the embedded score dict."""
53 if raw_winner is not None:
54 return bool(raw_winner)
55 if isinstance(raw_score, dict) and raw_score.get("winner") is not None:
56 return bool(raw_score.get("winner"))
57 return None
60def _parse_competitor(raw: dict[str, Any]) -> MatchCompetitor:
61 """Map a raw ESPN competitor object to a domain MatchCompetitor."""
62 score = raw.get("score")
63 return MatchCompetitor(
64 team=_parse_team(raw),
65 home_away=raw.get("homeAway", ""),
66 score=_extract_score(score),
67 winner=_extract_winner(score, raw.get("winner")),
68 )
71def _parse_match(event: dict[str, Any]) -> Match:
72 """Map a raw ESPN scoreboard event to a domain Match."""
73 competition = event.get("competitions", [{}])[0]
74 status = competition.get("status", {})
75 status_type = status.get("type", {})
76 competitors = [_parse_competitor(c) for c in competition.get("competitors", [])]
77 return Match(
78 id=str(event.get("id", "")),
79 date=event.get("date", ""),
80 name=event.get("name", ""),
81 short_name=event.get("shortName", ""),
82 status_type=status_type.get("state", ""),
83 status_detail=status.get("displayClock", status_type.get("description", "")),
84 competitors=competitors,
85 )
88def _parse_key_event(raw: dict[str, Any]) -> MatchEvent:
89 """Map a raw ESPN keyEvents entry to a domain MatchEvent."""
90 team = raw.get("team") or {}
91 return MatchEvent(
92 clock=raw.get("clock", {}).get("displayValue", ""),
93 period=int(raw.get("period", {}).get("number", 0)),
94 type=raw.get("type", {}).get("type", ""),
95 scoring=bool(raw.get("scoringPlay", False)),
96 text=raw.get("text"),
97 team_name=team.get("displayName"),
98 )
101def _competitor_by_side(competitors: list[dict[str, Any]], side: str) -> dict[str, Any]:
102 """Find the home or away competitor in a competitors list."""
103 return next((c for c in competitors if c.get("homeAway") == side), {})
106def _competitor_team_name(competitor: dict[str, Any]) -> str:
107 """Pull the displayName off a competitor's nested team dict."""
108 return (competitor.get("team") or {}).get("displayName", "")
111def _extract_status_detail(status: dict[str, Any]) -> str:
112 """Pick a human-readable status string, preferring description over clock."""
113 return status.get("type", {}).get("description", status.get("displayClock", ""))
116def _extract_venue(game_info: dict[str, Any]) -> tuple[str | None, str | None]:
117 """Return ``(venue_full_name, venue_city)`` from a gameInfo block."""
118 venue = game_info.get("venue") or {}
119 return venue.get("fullName"), (venue.get("address") or {}).get("city")
122def _parse_match_details(data: dict[str, Any]) -> MatchDetails:
123 """Map a raw ESPN summary response to a domain MatchDetails.
125 Raises:
126 USLSNotFoundError: If the response lacks a ``header.competitions[0]`` block.
127 """
128 header = data.get("header") or {}
129 competitions = header.get("competitions") or []
130 if not competitions:
131 raise USLSNotFoundError(f"Match not found: {header.get('id', '?')}")
133 competition = competitions[0]
134 competitors = competition.get("competitors", [])
135 home = _competitor_by_side(competitors, "home")
136 away = _competitor_by_side(competitors, "away")
137 game_info = data.get("gameInfo") or {}
138 venue_name, venue_city = _extract_venue(game_info)
140 return MatchDetails(
141 id=str(header.get("id", "")),
142 date=competition.get("date", ""),
143 status_detail=_extract_status_detail(competition.get("status", {})),
144 home_team=_competitor_team_name(home),
145 away_team=_competitor_team_name(away),
146 home_score=home.get("score"),
147 away_score=away.get("score"),
148 venue=venue_name,
149 venue_city=venue_city,
150 attendance=game_info.get("attendance"),
151 key_events=[_parse_key_event(e) for e in data.get("keyEvents", [])],
152 )
155def _parse_player(raw: dict[str, Any]) -> Player:
156 """Map a raw ESPN roster athlete entry to a domain Player."""
157 position = raw.get("position") or {}
158 return Player(
159 id=str(raw.get("id", "")),
160 full_name=raw.get("fullName", ""),
161 jersey=raw.get("jersey"),
162 position=position.get("displayName"),
163 position_abbr=position.get("abbreviation"),
164 citizenship=raw.get("citizenship"),
165 age=raw.get("age"),
166 )
169def _parse_article(raw: dict[str, Any]) -> NewsArticle:
170 """Map a raw ESPN news article entry to a domain NewsArticle."""
171 web_link = (raw.get("links") or {}).get("web") or {}
172 return NewsArticle(
173 id=str(raw.get("id", "")),
174 headline=raw.get("headline", ""),
175 description=raw.get("description", ""),
176 published=raw.get("published", ""),
177 link=web_link.get("href"),
178 )
181def _parse_standing(entry: dict[str, Any]) -> Standing | None:
182 """Map a raw ESPN standings entry to a domain Standing.
184 Returns None for entries that lack the required team data.
185 """
186 team_raw = entry.get("team")
187 if not team_raw:
188 return None
190 stats: dict[str, Any] = {s["name"]: s.get("value", 0) for s in entry.get("stats", [])}
191 return Standing(
192 team=_parse_team(team_raw),
193 wins=int(stats.get("wins", 0)),
194 losses=int(stats.get("losses", 0)),
195 ties=int(stats.get("ties", 0)),
196 points=int(stats.get("points", 0)),
197 goals_for=int(stats.get("pointsFor", 0)),
198 goals_against=int(stats.get("pointsAgainst", 0)),
199 goal_difference=int(stats.get("pointDifferential", 0)),
200 )