Coverage for src/epl/adapters/outbound/pulselive_parsers.py: 53%
38 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 Pulselive JSON wire format into domain models."""
3from dataclasses import dataclass
4from typing import Any
6from ...domain.models import PulselivePlayer
9def _int_or_none(value: object) -> int | None:
10 """Coerce a numeric-looking JSON value to an int, or None if absent/malformed."""
11 if value is None:
12 return None
13 try:
14 return int(value)
15 except (TypeError, ValueError):
16 return None
19def _str_or_none(value: object) -> str | None:
20 """Return ``str(value)`` for non-None input, else None."""
21 return None if value is None else str(value)
24def _sub(raw: dict[str, Any], key: str) -> dict[str, Any]:
25 """Return ``raw[key]`` when set to a dict, else an empty dict.
27 Wire entries occasionally send ``null`` for optional sub-objects; treating
28 those the same as missing keeps the downstream ``get`` calls uniform.
29 """
30 return raw.get(key) or {}
33@dataclass(frozen=True)
34class _PlayerParts:
35 """Sub-dicts extracted from a Pulselive player row."""
37 info: dict[str, Any]
38 current_team: dict[str, Any]
39 name: dict[str, Any]
40 national_team: dict[str, Any]
41 alt_ids: dict[str, Any]
42 birth: dict[str, Any]
45def _split_parts(raw: dict[str, Any]) -> _PlayerParts:
46 """Bundle the six nested sub-dicts a player row can carry."""
47 return _PlayerParts(
48 info=_sub(raw, "info"),
49 current_team=_sub(raw, "currentTeam"),
50 name=_sub(raw, "name"),
51 national_team=_sub(raw, "nationalTeam"),
52 alt_ids=_sub(raw, "altIds"),
53 birth=_sub(raw, "birth"),
54 )
57def _extract_display_name(name: dict[str, Any]) -> str:
58 """Prefer ``name.display``; fall back to ``first + last`` for older entries."""
59 display = name.get("display")
60 if display:
61 return str(display)
62 return f"{name.get('first', '')} {name.get('last', '')}".strip()
65def _extract_team_name(current_team: dict[str, Any]) -> str | None:
66 """Return the current-team display name, walking to the nested club as needed."""
67 return current_team.get("name") or _sub(current_team, "club").get("name")
70def _extract_nationality(national_team: dict[str, Any]) -> str | None:
71 """Prefer the full country name; fall back to the demonym."""
72 return national_team.get("country") or national_team.get("demonym")
75def _extract_pulselive_id(raw: dict[str, Any]) -> str:
76 """Return the primary id field, tolerating the two names Pulselive uses."""
77 return str(raw.get("playerId") or raw.get("id") or "")
80def _parse_pulselive_player(raw: dict[str, Any]) -> PulselivePlayer:
81 """Map a Pulselive ``/players`` entry to a domain ``PulselivePlayer``.
83 The wire shape carries ``info`` (position + shirt + loan flag),
84 ``currentTeam`` (club affiliation), ``name`` (display + first/last),
85 ``nationalTeam`` (nationality), and ``birth`` (DoB). The Opta id is
86 nested at ``altIds.opta``.
87 """
88 parts = _split_parts(raw)
89 return PulselivePlayer(
90 pulselive_id=_extract_pulselive_id(raw),
91 opta_id=_str_or_none(parts.alt_ids.get("opta")),
92 full_name=_extract_display_name(parts.name),
93 position=parts.info.get("position"),
94 position_detail=parts.info.get("positionInfo"),
95 shirt_number=_int_or_none(parts.info.get("shirtNum")),
96 current_team_name=_extract_team_name(parts.current_team),
97 current_team_id=_str_or_none(parts.current_team.get("id")),
98 on_loan=bool(parts.info.get("loan", False)),
99 nationality=_extract_nationality(parts.national_team),
100 date_of_birth=parts.birth.get("date"),
101 )