Skip to content

Commit e956599

Browse files
authored
Merge pull request #341 from zero-sum-seattle/fix/issue-340-pydantic-float-uiuzmh
Use Pydantic coercion for numeric stat fields
2 parents 34554d0 + 4ef6223 commit e956599

8 files changed

Lines changed: 600 additions & 148 deletions

File tree

docs/stats.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,37 @@ The MLB Stats API publishes the available values directly:
263263

264264
Common stat groups include `hitting`, `pitching`, and `fielding`. Available stat types depend on the group and endpoint. Examples include `season`, `career`, `seasonAdvanced`, `gameLog`, and `playLog`.
265265

266+
## Numeric stat fields are typed as `float`
267+
268+
Rate and average stats such as `avg`, `obp`, `slg`, `ops`, `era`, `whip`, and
269+
`babip` are typed as `Optional[float]`. The MLB Stats API returns these as
270+
decimal strings (for example `".287"`), and Pydantic converts them to floats
271+
automatically:
272+
273+
```python
274+
split.stat.avg == 0.287 # not ".287"
275+
split.stat.model_dump()["avg"] # 0.287, not ".287"
276+
```
277+
278+
This is a behavioral change from earlier releases where these fields were
279+
`str`. Code relying on string operations (`avg.startswith(".")`) or on
280+
`avg == ".287"` needs to switch to numeric comparisons.
281+
282+
The MLB Stats API also uses two placeholder strings, `".---"` and `"-.--"`,
283+
for these rate stats when the underlying value is not applicable (for
284+
example a caught-stealing percentage when nobody has attempted a steal).
285+
These two known sentinels are normalized to `None` before conversion, so
286+
`split.stat.avg` is `None` rather than raising a validation error. Any other
287+
non-numeric string still raises a `ValidationError`, since it isn't a
288+
sentinel MLB is known to send.
289+
290+
Fields that use MLB's innings notation remain `str`, because values like
291+
`"6.2"` mean 6 2/3 innings rather than the decimal 6.2:
292+
293+
- `SimpleFieldingSplit.innings`
294+
- `SimplePitchingSplit.innings_pitched`
295+
- `AdvancedPitchingSplit.innings_pitched_per_game`
296+
266297
## Related documentation
267298

268299
- [Method reference](methods.md)

mlbstatsapi/models/stats/catching.py

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from typing import Optional, List, ClassVar
2-
from pydantic import Field
2+
from pydantic import Field, field_validator
33
from mlbstatsapi.models.base import MLBBaseModel
44
from mlbstatsapi.models.teams import Team
55
from mlbstatsapi.models.game import Game
66
from .stats import Split
7+
from .sentinels import normalize_mlb_float_sentinel
78

89

910
class SimpleCatchingSplit(MLBBaseModel):
@@ -30,23 +31,23 @@ class SimpleCatchingSplit(MLBBaseModel):
3031
The number of hits while catching.
3132
hit_by_pitch : int
3233
The number of batters hit by a pitch while catching.
33-
avg : str
34+
avg : float
3435
The batting average while catching.
3536
at_bats : int
3637
The number of at bats while catching.
37-
obp : str
38+
obp : float
3839
The on base percentage while catching.
39-
slg : str
40+
slg : float
4041
The slugging percentage while catching.
41-
ops : str
42+
ops : float
4243
The on-base slugging while catching.
4344
caught_stealing : int
4445
The number of runners caught stealing by the catcher.
45-
caught_stealing_percentage : str
46+
caught_stealing_percentage : float
4647
Percentage of runners caught stealing by the catcher.
4748
stolen_bases : int
4849
The number of stolen bases while catching.
49-
stolen_base_percentage : str
50+
stolen_base_percentage : float
5051
The stolen base percentage against the catcher.
5152
earned_runs : int
5253
The earned run amount against the catcher.
@@ -62,7 +63,7 @@ class SimpleCatchingSplit(MLBBaseModel):
6263
The number of pick offs while catching.
6364
total_bases : int
6465
The total number of bases.
65-
strikeout_walk_ratio : str
66+
strikeout_walk_ratio : float
6667
The strike out to walk ratio while catching.
6768
catchers_interference : int
6869
The number of times catcher interference committed.
@@ -84,29 +85,43 @@ class SimpleCatchingSplit(MLBBaseModel):
8485
intentional_walks: Optional[int] = Field(default=None, alias="intentionalWalks")
8586
hits: Optional[int] = None
8687
hit_by_pitch: Optional[int] = Field(default=None, alias="hitByPitch")
87-
avg: Optional[str] = None
88+
avg: Optional[float] = None
8889
at_bats: Optional[int] = Field(default=None, alias="atBats")
89-
obp: Optional[str] = None
90-
slg: Optional[str] = None
91-
ops: Optional[str] = None
90+
obp: Optional[float] = None
91+
slg: Optional[float] = None
92+
ops: Optional[float] = None
9293
caught_stealing: Optional[int] = Field(default=None, alias="caughtStealing")
93-
caught_stealing_percentage: Optional[str] = Field(default=None, alias="caughtStealingPercentage")
94+
caught_stealing_percentage: Optional[float] = Field(default=None, alias="caughtStealingPercentage")
9495
stolen_bases: Optional[int] = Field(default=None, alias="stolenBases")
95-
stolen_base_percentage: Optional[str] = Field(default=None, alias="stolenBasePercentage")
96+
stolen_base_percentage: Optional[float] = Field(default=None, alias="stolenBasePercentage")
9697
earned_runs: Optional[int] = Field(default=None, alias="earnedRuns")
9798
batters_faced: Optional[int] = Field(default=None, alias="battersFaced")
9899
games_pitched: Optional[int] = Field(default=None, alias="gamesPitched")
99100
hit_batsmen: Optional[int] = Field(default=None, alias="hitBatsmen")
100101
wild_pitches: Optional[int] = Field(default=None, alias="wildPitches")
101102
pickoffs: Optional[int] = None
102103
total_bases: Optional[int] = Field(default=None, alias="totalBases")
103-
strikeout_walk_ratio: Optional[str] = Field(default=None, alias="strikeoutWalkRatio")
104+
strikeout_walk_ratio: Optional[float] = Field(default=None, alias="strikeoutWalkRatio")
104105
catchers_interference: Optional[int] = Field(default=None, alias="catchersInterference")
105106
sac_bunts: Optional[int] = Field(default=None, alias="sacBunts")
106107
sac_flies: Optional[int] = Field(default=None, alias="sacFlies")
107108
passed_ball: Optional[int] = Field(default=None, alias="passedBall")
108109
pickoff_attempts: Optional[int] = Field(default=None, alias="pickoffAttempts")
109110

111+
@field_validator(
112+
"avg",
113+
"obp",
114+
"slg",
115+
"ops",
116+
"caught_stealing_percentage",
117+
"stolen_base_percentage",
118+
"strikeout_walk_ratio",
119+
mode="before",
120+
)
121+
@classmethod
122+
def normalize_float_sentinels(cls, value):
123+
return normalize_mlb_float_sentinel(value)
124+
110125

111126
class CatchingSeason(Split):
112127
"""

mlbstatsapi/models/stats/fielding.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from mlbstatsapi.models.teams import Team
66
from mlbstatsapi.models.game import Game
77
from .stats import Split
8+
from .sentinels import normalize_mlb_float_sentinel
89

910

1011
class SimpleFieldingSplit(MLBBaseModel):
@@ -21,11 +22,11 @@ class SimpleFieldingSplit(MLBBaseModel):
2122
The number of games started.
2223
caught_stealing : int
2324
The number of runners caught stealing.
24-
caught_stealing_percentage : str
25+
caught_stealing_percentage : float
2526
The percentage of runners caught stealing.
2627
stolen_bases : int
2728
The number of stolen bases.
28-
stolen_base_percentage : str
29+
stolen_base_percentage : float
2930
The stolen base percentage.
3031
assists : int
3132
The number of assists.
@@ -35,14 +36,15 @@ class SimpleFieldingSplit(MLBBaseModel):
3536
The number of errors committed.
3637
chances : int
3738
The number of chances.
38-
fielding : str
39+
fielding : float
3940
The fielding percentage.
40-
range_factor_per_game : str
41+
range_factor_per_game : float
4142
Range rating per game.
42-
range_factor_per_9_inn : str
43+
range_factor_per_9_inn : float
4344
Range factor per 9 innings.
4445
innings : str
45-
The number of innings played.
46+
The number of innings played. Represented as MLB innings notation
47+
(e.g. "6.2" means 6 2/3 innings), not a true decimal value.
4648
games : int
4749
The number of games played.
4850
passed_ball : int
@@ -51,7 +53,7 @@ class SimpleFieldingSplit(MLBBaseModel):
5153
The number of double plays.
5254
triple_plays : int
5355
The number of triple plays.
54-
catcher_era : str
56+
catcher_era : float
5557
The catcher ERA of the fielding stat.
5658
catchers_interference : int
5759
The number of times catchers interference was committed.
@@ -67,22 +69,22 @@ class SimpleFieldingSplit(MLBBaseModel):
6769
games_played: Optional[int] = Field(default=None, alias="gamesPlayed")
6870
games_started: Optional[int] = Field(default=None, alias="gamesStarted")
6971
caught_stealing: Optional[int] = Field(default=None, alias="caughtStealing")
70-
caught_stealing_percentage: Optional[str] = Field(default=None, alias="caughtStealingPercentage")
72+
caught_stealing_percentage: Optional[float] = Field(default=None, alias="caughtStealingPercentage")
7173
stolen_bases: Optional[int] = Field(default=None, alias="stolenBases")
72-
stolen_base_percentage: Optional[str] = Field(default=None, alias="stolenBasePercentage")
74+
stolen_base_percentage: Optional[float] = Field(default=None, alias="stolenBasePercentage")
7375
assists: Optional[int] = None
7476
putouts: Optional[int] = None
7577
errors: Optional[int] = None
7678
chances: Optional[int] = None
77-
fielding: Optional[str] = None
78-
range_factor_per_game: Optional[str] = Field(default=None, alias="rangeFactorPerGame")
79-
range_factor_per_9_inn: Optional[str] = Field(default=None, alias="rangeFactorPer9Inn")
79+
fielding: Optional[float] = None
80+
range_factor_per_game: Optional[float] = Field(default=None, alias="rangeFactorPerGame")
81+
range_factor_per_9_inn: Optional[float] = Field(default=None, alias="rangeFactorPer9Inn")
8082
innings: Optional[str] = None
8183
games: Optional[int] = None
8284
passed_ball: Optional[int] = Field(default=None, alias="passedBall")
8385
double_plays: Optional[int] = Field(default=None, alias="doublePlays")
8486
triple_plays: Optional[int] = Field(default=None, alias="triplePlays")
85-
catcher_era: Optional[str] = Field(default=None, alias="catcherEra")
87+
catcher_era: Optional[float] = Field(default=None, alias="catcherEra")
8688
catchers_interference: Optional[int] = Field(default=None, alias="catchersInterference")
8789
wild_pitches: Optional[int] = Field(default=None, alias="wildPitches")
8890
throwing_errors: Optional[int] = Field(default=None, alias="throwingErrors")
@@ -96,6 +98,19 @@ def empty_dict_to_none(cls, v: Any) -> Any:
9698
return None
9799
return v
98100

101+
@field_validator(
102+
"caught_stealing_percentage",
103+
"stolen_base_percentage",
104+
"fielding",
105+
"range_factor_per_game",
106+
"range_factor_per_9_inn",
107+
"catcher_era",
108+
mode="before",
109+
)
110+
@classmethod
111+
def normalize_float_sentinels(cls, value: Any) -> Any:
112+
return normalize_mlb_float_sentinel(value)
113+
99114

100115
class FieldingSeasonAdvanced(Split):
101116
"""

0 commit comments

Comments
 (0)