Skip to content

Commit 57cbf46

Browse files
authored
Merge pull request #243 from zero-sum-seattle/fix/pydantic-models
Fix/pydantic models
2 parents 0831c23 + 52c7ea7 commit 57cbf46

File tree

8 files changed

+148
-15
lines changed

8 files changed

+148
-15
lines changed

mlbstatsapi/models/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,8 @@ class MLBBaseModel(BaseModel):
2020
extra="ignore",
2121
alias_generator=to_camel_case,
2222
populate_by_name=True,
23+
# MLB's API occasionally returns numbers for fields that are logically strings
24+
# (e.g. liveData.plays.*.playEvents.*.base can be 1/2/3).
25+
# Enable coercion to be resilient to these inconsistencies.
26+
coerce_numbers_to_str=True,
2327
)

mlbstatsapi/models/game/livedata/linescore/attributes.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,12 @@ class LinescoreOffense(MLBBaseModel):
112112
in_hole: Optional[Person] = Field(default=None, alias="inHole")
113113
pitcher: Optional[Person] = None
114114
batting_order: Optional[int] = Field(default=None, alias="battingOrder")
115-
first: Optional[str] = None
116-
second: Optional[str] = None
117-
third: Optional[str] = None
115+
# MLB API sometimes returns these as person objects (id/link/fullName).
116+
first: Optional[Person] = None
117+
second: Optional[Person] = None
118+
third: Optional[Person] = None
118119

119-
@field_validator('batter', 'on_deck', 'in_hole', 'pitcher', mode='before')
120+
@field_validator('batter', 'on_deck', 'in_hole', 'pitcher', 'first', 'second', 'third', mode='before')
120121
@classmethod
121122
def empty_dict_to_none(cls, v: Any) -> Any:
122123
"""Convert empty dicts to None."""

mlbstatsapi/models/game/livedata/plays/play/attributes.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Optional
1+
from typing import Optional, Any, Dict, List, Union
22
from pydantic import Field
33
from mlbstatsapi.models.base import MLBBaseModel
44

@@ -99,4 +99,5 @@ class PlayReviewDetails(MLBBaseModel):
9999
in_progress: bool = Field(alias="inProgress")
100100
review_type: str = Field(alias="reviewType")
101101
challenge_team_id: Optional[int] = Field(default=None, alias="challengeTeamId")
102-
additional_reviews: Optional[str] = Field(default=None, alias="additionalReviews")
102+
# MLB API returns this as either null, a string, or a list of review objects.
103+
additional_reviews: Optional[Union[str, List[Dict[str, Any]]]] = Field(default=None, alias="additionalReviews")

mlbstatsapi/models/game/livedata/plays/play/playevent/playevent.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
from typing import Optional
2-
from pydantic import Field
1+
from typing import Optional, Union, Any
2+
from pydantic import Field, field_validator
33
from mlbstatsapi.models.base import MLBBaseModel
44
from mlbstatsapi.models.people import Person, Position
55
from mlbstatsapi.models.data import Count, HitData, PitchData, PlayDetails
@@ -55,7 +55,8 @@ class PlayEvent(MLBBaseModel):
5555
pfx_id: Optional[str] = Field(default=None, alias="pfxId")
5656
start_time: Optional[str] = Field(default=None, alias="startTime")
5757
end_time: Optional[str] = Field(default=None, alias="endTime")
58-
umpire: Optional[str] = None
58+
# MLB API sometimes returns a person object (id/link) instead of a string.
59+
umpire: Optional[Union[str, Person]] = None
5960
base: Optional[str] = None
6061
play_id: Optional[str] = Field(default=None, alias="playId")
6162
pitch_number: Optional[int] = Field(default=None, alias="pitchNumber")
@@ -71,3 +72,10 @@ class PlayEvent(MLBBaseModel):
7172
replaced_player: Optional[Person] = Field(default=None, alias="replacedPlayer")
7273
review_details: Optional[dict] = Field(default=None, alias="reviewDetails")
7374
injury_type: Optional[str] = Field(default=None, alias="injuryType")
75+
76+
@field_validator("umpire", mode="before")
77+
@classmethod
78+
def _empty_dict_to_none(cls, v: Any) -> Any:
79+
if isinstance(v, dict) and not v:
80+
return None
81+
return v

mlbstatsapi/models/game/livedata/plays/play/playrunner/attributes.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from typing import Optional
2-
from pydantic import Field
2+
from pydantic import Field, field_validator
33
from mlbstatsapi.models.base import MLBBaseModel
44
from mlbstatsapi.models.people import Person, Position
55

@@ -41,13 +41,21 @@ class RunnerMovement(MLBBaseModel):
4141
out_base : str
4242
Base runner was made out.
4343
"""
44-
is_out: bool = Field(alias="isOut")
44+
is_out: bool = Field(default=False, alias="isOut")
4545
out_number: Optional[int] = Field(default=None, alias="outNumber")
4646
origin_base: Optional[str] = Field(default=None, alias="originBase")
4747
start: Optional[str] = None
4848
end: Optional[str] = None
4949
out_base: Optional[str] = Field(default=None, alias="outBase")
5050

51+
@field_validator("is_out", mode="before")
52+
@classmethod
53+
def _coerce_is_out(cls, v):
54+
# MLB API occasionally returns null for isOut.
55+
if v is None:
56+
return False
57+
return v
58+
5159

5260
class RunnerDetails(MLBBaseModel):
5361
"""

mlbstatsapi/models/game/livedata/plays/playbyinning/attributes.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import List
1+
from typing import List, Optional
22
from pydantic import Field
33
from mlbstatsapi.models.base import MLBBaseModel
44
from mlbstatsapi.models.teams import Team
@@ -16,8 +16,9 @@ class HitCoordinates(MLBBaseModel):
1616
y : float
1717
Y coordinate for hit.
1818
"""
19-
x: float
20-
y: float
19+
# MLB API occasionally returns null for these coordinates.
20+
x: Optional[float] = None
21+
y: Optional[float] = None
2122

2223

2324
class HitsByTeam(MLBBaseModel):

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "python-mlb-statsapi"
3-
version = "0.7.1"
3+
version = "0.7.2"
44
description = "mlbstatsapi python wrapper"
55
authors = [
66
"Matthew Spah <spahmatthew@gmail.com>",
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import pytest
2+
3+
from mlbstatsapi.models.game.livedata.linescore.attributes import LinescoreOffense
4+
from mlbstatsapi.models.game.livedata.plays.play.attributes import PlayReviewDetails
5+
from mlbstatsapi.models.game.livedata.plays.play.playevent.playevent import PlayEvent
6+
from mlbstatsapi.models.game.livedata.plays.playbyinning.attributes import HitCoordinates
7+
from mlbstatsapi.models.game.livedata.plays.play.playrunner.attributes import RunnerMovement
8+
9+
10+
# These gamepk IDs are taken from a user-submitted error log where the MLB API payload
11+
# was rejected by Pydantic validation. The underlying issues are schema inconsistencies
12+
# in MLB's API responses (int where str expected, dict where str expected, null where bool expected).
13+
14+
GAMEPKS_BASE_INT = [
15+
776160, 776165, 776219, 776252, 776286, 776320, 776336, 776351, 776386, 776420,
16+
776498, 776659, 776759, 776770, 776903, 776937, 777091, 777135, 777191, 777265,
17+
777305, 777445, 777488, 777514, 777555, 777570, 777650, 777722,
18+
# Additional gamepks reported later (same base=int issue)
19+
744814, 744819, 744824, 744826, 744832, 744836, 744837, 744838,
20+
745146, 745542, 745796, 745799,
21+
747000, 747080, 747170,
22+
]
23+
24+
GAMEPKS_ISOUT_NULL = [
25+
776320, 776545,
26+
# Additional gamepks reported later (same isOut=null issue)
27+
744832, 744836,
28+
]
29+
30+
GAMEPKS_UMPIRE_DICT = [
31+
776221, 776367, 776420, 776525, 776650, 776850, 776903,
32+
# Additional gamepks reported later (same umpire=dict issue)
33+
744831, 747000,
34+
]
35+
36+
GAMEPKS_ADDITIONAL_REVIEWS_LIST = [
37+
776259, 776386, 777213, 777544, 777555,
38+
# Additional gamepks reported later (same additionalReviews=list issue)
39+
747000,
40+
]
41+
42+
GAMEPKS_LINESCORE_OFFENSE_RUNNER_DICT = [
43+
776784, 777091,
44+
# Additional gamepks reported later (same offense.*=person dict issue)
45+
744814, 747080,
46+
]
47+
48+
GAMEPKS_HIT_COORDS_NULL = [
49+
778077,
50+
]
51+
52+
53+
@pytest.mark.parametrize("gamepk", GAMEPKS_BASE_INT)
54+
def test_gamepk_play_event_base_coerces_int_to_str(gamepk: int):
55+
# path in log: liveData.plays.*.playEvents.*.base is int
56+
evt = PlayEvent(details={}, index=0, isPitch=True, type="pitch", base=1)
57+
assert evt.base == "1"
58+
59+
60+
@pytest.mark.parametrize("gamepk", GAMEPKS_ISOUT_NULL)
61+
def test_gamepk_runner_movement_is_out_coerces_null_to_false(gamepk: int):
62+
# path in log: liveData.plays.*.runners.*.movement.isOut is null
63+
mv = RunnerMovement(isOut=None)
64+
assert mv.is_out is False
65+
66+
67+
@pytest.mark.parametrize("gamepk", GAMEPKS_UMPIRE_DICT)
68+
def test_gamepk_play_event_umpire_accepts_person_object(gamepk: int):
69+
# path in log: liveData.plays.*.playEvents.*.umpire is a dict {id, link}
70+
evt = PlayEvent(
71+
details={},
72+
index=0,
73+
isPitch=True,
74+
type="pitch",
75+
umpire={"id": 484499, "link": "/api/v1/people/484499"},
76+
)
77+
assert evt.umpire is not None
78+
79+
80+
@pytest.mark.parametrize("gamepk", GAMEPKS_ADDITIONAL_REVIEWS_LIST)
81+
def test_gamepk_review_details_additional_reviews_accepts_list(gamepk: int):
82+
# path in log: liveData.plays.allPlays.*.reviewDetails.additionalReviews is a list
83+
rd = PlayReviewDetails(
84+
isOverturned=False,
85+
inProgress=False,
86+
reviewType="NA",
87+
additionalReviews=[{"isOverturned": False, "reviewType": "NA", "challengeTeamId": 120}],
88+
)
89+
assert isinstance(rd.additional_reviews, list)
90+
91+
92+
@pytest.mark.parametrize("gamepk", GAMEPKS_LINESCORE_OFFENSE_RUNNER_DICT)
93+
def test_gamepk_linescore_offense_baserunners_accept_person_object(gamepk: int):
94+
# path in log: liveData.linescore.offense.first/second/third is a dict person object
95+
offense = LinescoreOffense(
96+
team={"id": 120, "link": "/api/v1/teams/120"},
97+
first={"id": 682928, "fullName": "Runner One", "link": "/api/v1/people/682928"},
98+
second=None,
99+
third=None,
100+
)
101+
assert offense.first is not None
102+
103+
104+
@pytest.mark.parametrize("gamepk", GAMEPKS_HIT_COORDS_NULL)
105+
def test_gamepk_hit_coordinates_accept_null_x_y(gamepk: int):
106+
coords = HitCoordinates(x=None, y=None)
107+
assert coords.x is None
108+
assert coords.y is None
109+
110+

0 commit comments

Comments
 (0)