From 113c880ef6df84b0703a5db5b6f6fbd0bd62c5a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 01:19:02 +0000 Subject: [PATCH 1/4] Convert numeric stat fields from str to float via Pydantic coercion Closes zero-sum-seattle/python-mlb-statsapi#340. Replaces the string-typed averages, percentages, ratios, and rate stats across SimpleCatchingSplit, SimpleFieldingSplit, AdvancedHittingSplit, SimpleHittingSplit, SimplePitchingSplit, AdvancedPitchingSplit, and ExpectedStatistics with Optional[float] (or float for ExpectedStatistics' required fields). Pydantic's native coercion converts MLB's numeric-string payloads (e.g. ".287") without any custom validator, so this avoids the BeforeValidator/OptionalFloat approach proposed in PR #241. No sentinel value (".---", "-", etc.) appears in this repo's fixtures or tests, so unexpected malformed values raise ValidationError instead of being silently coerced to None. MLB innings notation fields keep their str type since "6.2" means 6 2/3 innings, not the decimal 6.2: SimpleFieldingSplit.innings, SimplePitchingSplit.innings_pitched, and AdvancedPitchingSplit.innings_pitched_per_game are unchanged. This is a breaking change for code comparing these fields as strings (e.g. avg == ".287" or avg.startswith(".")); documented in docs/stats.md. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KBCKzuwwj85qDEaDqqxALM --- docs/stats.md | 23 ++++ mlbstatsapi/models/stats/catching.py | 28 ++--- mlbstatsapi/models/stats/fielding.py | 27 ++--- mlbstatsapi/models/stats/hitting.py | 64 +++++------ mlbstatsapi/models/stats/pitching.py | 162 +++++++++++++------------- mlbstatsapi/models/stats/stats.py | 16 +-- tests/test_stat_float_coercion.py | 163 +++++++++++++++++++++++++++ 7 files changed, 336 insertions(+), 147 deletions(-) create mode 100644 tests/test_stat_float_coercion.py diff --git a/docs/stats.md b/docs/stats.md index 0a301842..8af14a6c 100644 --- a/docs/stats.md +++ b/docs/stats.md @@ -263,6 +263,29 @@ The MLB Stats API publishes the available values directly: 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`. +## Numeric stat fields are typed as `float` + +Rate and average stats such as `avg`, `obp`, `slg`, `ops`, `era`, `whip`, and +`babip` are typed as `Optional[float]`. The MLB Stats API returns these as +decimal strings (for example `".287"`), and Pydantic converts them to floats +automatically: + +```python +split.stat.avg == 0.287 # not ".287" +split.stat.model_dump()["avg"] # 0.287, not ".287" +``` + +This is a behavioral change from earlier releases where these fields were +`str`. Code relying on string operations (`avg.startswith(".")`) or on +`avg == ".287"` needs to switch to numeric comparisons. + +Fields that use MLB's innings notation remain `str`, because values like +`"6.2"` mean 6 2/3 innings rather than the decimal 6.2: + +- `SimpleFieldingSplit.innings` +- `SimplePitchingSplit.innings_pitched` +- `AdvancedPitchingSplit.innings_pitched_per_game` + ## Related documentation - [Method reference](methods.md) diff --git a/mlbstatsapi/models/stats/catching.py b/mlbstatsapi/models/stats/catching.py index 17512f68..ddc69334 100644 --- a/mlbstatsapi/models/stats/catching.py +++ b/mlbstatsapi/models/stats/catching.py @@ -30,23 +30,23 @@ class SimpleCatchingSplit(MLBBaseModel): The number of hits while catching. hit_by_pitch : int The number of batters hit by a pitch while catching. - avg : str + avg : float The batting average while catching. at_bats : int The number of at bats while catching. - obp : str + obp : float The on base percentage while catching. - slg : str + slg : float The slugging percentage while catching. - ops : str + ops : float The on-base slugging while catching. caught_stealing : int The number of runners caught stealing by the catcher. - caught_stealing_percentage : str + caught_stealing_percentage : float Percentage of runners caught stealing by the catcher. stolen_bases : int The number of stolen bases while catching. - stolen_base_percentage : str + stolen_base_percentage : float The stolen base percentage against the catcher. earned_runs : int The earned run amount against the catcher. @@ -62,7 +62,7 @@ class SimpleCatchingSplit(MLBBaseModel): The number of pick offs while catching. total_bases : int The total number of bases. - strikeout_walk_ratio : str + strikeout_walk_ratio : float The strike out to walk ratio while catching. catchers_interference : int The number of times catcher interference committed. @@ -84,15 +84,15 @@ class SimpleCatchingSplit(MLBBaseModel): intentional_walks: Optional[int] = Field(default=None, alias="intentionalWalks") hits: Optional[int] = None hit_by_pitch: Optional[int] = Field(default=None, alias="hitByPitch") - avg: Optional[str] = None + avg: Optional[float] = None at_bats: Optional[int] = Field(default=None, alias="atBats") - obp: Optional[str] = None - slg: Optional[str] = None - ops: Optional[str] = None + obp: Optional[float] = None + slg: Optional[float] = None + ops: Optional[float] = None caught_stealing: Optional[int] = Field(default=None, alias="caughtStealing") - caught_stealing_percentage: Optional[str] = Field(default=None, alias="caughtStealingPercentage") + caught_stealing_percentage: Optional[float] = Field(default=None, alias="caughtStealingPercentage") stolen_bases: Optional[int] = Field(default=None, alias="stolenBases") - stolen_base_percentage: Optional[str] = Field(default=None, alias="stolenBasePercentage") + stolen_base_percentage: Optional[float] = Field(default=None, alias="stolenBasePercentage") earned_runs: Optional[int] = Field(default=None, alias="earnedRuns") batters_faced: Optional[int] = Field(default=None, alias="battersFaced") games_pitched: Optional[int] = Field(default=None, alias="gamesPitched") @@ -100,7 +100,7 @@ class SimpleCatchingSplit(MLBBaseModel): wild_pitches: Optional[int] = Field(default=None, alias="wildPitches") pickoffs: Optional[int] = None total_bases: Optional[int] = Field(default=None, alias="totalBases") - strikeout_walk_ratio: Optional[str] = Field(default=None, alias="strikeoutWalkRatio") + strikeout_walk_ratio: Optional[float] = Field(default=None, alias="strikeoutWalkRatio") catchers_interference: Optional[int] = Field(default=None, alias="catchersInterference") sac_bunts: Optional[int] = Field(default=None, alias="sacBunts") sac_flies: Optional[int] = Field(default=None, alias="sacFlies") diff --git a/mlbstatsapi/models/stats/fielding.py b/mlbstatsapi/models/stats/fielding.py index d13eeaf7..5b663053 100644 --- a/mlbstatsapi/models/stats/fielding.py +++ b/mlbstatsapi/models/stats/fielding.py @@ -21,11 +21,11 @@ class SimpleFieldingSplit(MLBBaseModel): The number of games started. caught_stealing : int The number of runners caught stealing. - caught_stealing_percentage : str + caught_stealing_percentage : float The percentage of runners caught stealing. stolen_bases : int The number of stolen bases. - stolen_base_percentage : str + stolen_base_percentage : float The stolen base percentage. assists : int The number of assists. @@ -35,14 +35,15 @@ class SimpleFieldingSplit(MLBBaseModel): The number of errors committed. chances : int The number of chances. - fielding : str + fielding : float The fielding percentage. - range_factor_per_game : str + range_factor_per_game : float Range rating per game. - range_factor_per_9_inn : str + range_factor_per_9_inn : float Range factor per 9 innings. innings : str - The number of innings played. + The number of innings played. Represented as MLB innings notation + (e.g. "6.2" means 6 2/3 innings), not a true decimal value. games : int The number of games played. passed_ball : int @@ -51,7 +52,7 @@ class SimpleFieldingSplit(MLBBaseModel): The number of double plays. triple_plays : int The number of triple plays. - catcher_era : str + catcher_era : float The catcher ERA of the fielding stat. catchers_interference : int The number of times catchers interference was committed. @@ -67,22 +68,22 @@ class SimpleFieldingSplit(MLBBaseModel): games_played: Optional[int] = Field(default=None, alias="gamesPlayed") games_started: Optional[int] = Field(default=None, alias="gamesStarted") caught_stealing: Optional[int] = Field(default=None, alias="caughtStealing") - caught_stealing_percentage: Optional[str] = Field(default=None, alias="caughtStealingPercentage") + caught_stealing_percentage: Optional[float] = Field(default=None, alias="caughtStealingPercentage") stolen_bases: Optional[int] = Field(default=None, alias="stolenBases") - stolen_base_percentage: Optional[str] = Field(default=None, alias="stolenBasePercentage") + stolen_base_percentage: Optional[float] = Field(default=None, alias="stolenBasePercentage") assists: Optional[int] = None putouts: Optional[int] = None errors: Optional[int] = None chances: Optional[int] = None - fielding: Optional[str] = None - range_factor_per_game: Optional[str] = Field(default=None, alias="rangeFactorPerGame") - range_factor_per_9_inn: Optional[str] = Field(default=None, alias="rangeFactorPer9Inn") + fielding: Optional[float] = None + range_factor_per_game: Optional[float] = Field(default=None, alias="rangeFactorPerGame") + range_factor_per_9_inn: Optional[float] = Field(default=None, alias="rangeFactorPer9Inn") innings: Optional[str] = None games: Optional[int] = None passed_ball: Optional[int] = Field(default=None, alias="passedBall") double_plays: Optional[int] = Field(default=None, alias="doublePlays") triple_plays: Optional[int] = Field(default=None, alias="triplePlays") - catcher_era: Optional[str] = Field(default=None, alias="catcherEra") + catcher_era: Optional[float] = Field(default=None, alias="catcherEra") catchers_interference: Optional[int] = Field(default=None, alias="catchersInterference") wild_pitches: Optional[int] = Field(default=None, alias="wildPitches") throwing_errors: Optional[int] = Field(default=None, alias="throwingErrors") diff --git a/mlbstatsapi/models/stats/hitting.py b/mlbstatsapi/models/stats/hitting.py index ede8cdeb..0b34b1f9 100644 --- a/mlbstatsapi/models/stats/hitting.py +++ b/mlbstatsapi/models/stats/hitting.py @@ -26,7 +26,7 @@ class AdvancedHittingSplit(MLBBaseModel): The amount of sac bunts. sac_flies : int The amount of sac flies. - babip : str + babip : float Batting Average on Balls in Play. extra_base_hits : int The amount of extra base hits. @@ -38,17 +38,17 @@ class AdvancedHittingSplit(MLBBaseModel): The amount of GIDP opportunities. number_of_pitches : int The number of pitches the batter has faced. - pitches_per_plate_appearance : str + pitches_per_plate_appearance : float The avg amount of pitches per plate appearance. - walks_per_plate_appearance : str + walks_per_plate_appearance : float The avg walks per plate appearance. - strikeouts_per_plate_appearance : str + strikeouts_per_plate_appearance : float The amount of strike outs per plate appearance. - home_runs_per_plate_appearance : str + home_runs_per_plate_appearance : float The amount of home runs per plate appearance. - walks_per_strikeout : str + walks_per_strikeout : float The amount of walks per strike out. - iso : str + iso : float Isolated power. reached_on_error : int The amount of times the batter has reached base on an error. @@ -83,18 +83,18 @@ class AdvancedHittingSplit(MLBBaseModel): left_on_base: Optional[int] = Field(default=None, alias="leftOnBase") sac_bunts: Optional[int] = Field(default=None, alias="sacBunts") sac_flies: Optional[int] = Field(default=None, alias="sacFlies") - babip: Optional[str] = None + babip: Optional[float] = None extra_base_hits: Optional[int] = Field(default=None, alias="extraBaseHits") hit_by_pitch: Optional[int] = Field(default=None, alias="hitByPitch") gidp: Optional[int] = None gidp_opp: Optional[int] = Field(default=None, alias="gidpOpp") number_of_pitches: Optional[int] = Field(default=None, alias="numberOfPitches") - pitches_per_plate_appearance: Optional[str] = Field(default=None, alias="pitchesPerPlateAppearance") - walks_per_plate_appearance: Optional[str] = Field(default=None, alias="walksPerPlateAppearance") - strikeouts_per_plate_appearance: Optional[str] = Field(default=None, alias="strikeoutsPerPlateAppearance") - home_runs_per_plate_appearance: Optional[str] = Field(default=None, alias="homeRunsPerPlateAppearance") - walks_per_strikeout: Optional[str] = Field(default=None, alias="walksPerStrikeout") - iso: Optional[str] = None + pitches_per_plate_appearance: Optional[float] = Field(default=None, alias="pitchesPerPlateAppearance") + walks_per_plate_appearance: Optional[float] = Field(default=None, alias="walksPerPlateAppearance") + strikeouts_per_plate_appearance: Optional[float] = Field(default=None, alias="strikeoutsPerPlateAppearance") + home_runs_per_plate_appearance: Optional[float] = Field(default=None, alias="homeRunsPerPlateAppearance") + walks_per_strikeout: Optional[float] = Field(default=None, alias="walksPerStrikeout") + iso: Optional[float] = None reached_on_error: Optional[int] = Field(default=None, alias="reachedOnError") walkoffs: Optional[int] = Field(default=None, alias="walkOffs") flyouts: Optional[int] = Field(default=None, alias="flyOuts") @@ -144,23 +144,23 @@ class SimpleHittingSplit(MLBBaseModel): The number of hits for the batter. hit_by_pitch : int The number of pitches the batter has been hit by. - avg : str + avg : float The batting avg of the batter. at_bats : int The number of at bats of the batter. - obp : str + obp : float The on base percentage of the batter. - slg : str + slg : float The slugging percentage of the batter. - ops : str + ops : float The on-base plus slugging of the batter. caught_stealing : int The amount of times the batter has been caught stealing. - caught_stealing_percentage : str + caught_stealing_percentage : float The caught stealing percentage. stolen_bases : int The amount of stolen bases achieved by the batter. - stolen_base_percentage : int + stolen_base_percentage : float The stolen base percentage of the batter. ground_into_double_play : int The number of times the batter has hit into a double play. @@ -180,13 +180,13 @@ class SimpleHittingSplit(MLBBaseModel): The number of sac bunts performed by the batter. sac_flies : int The number of sac flies performed by the batter. - babip : str + babip : float The batting average of balls in play of the batter. - groundouts_to_airouts : int + groundouts_to_airouts : float The groundout-to-airout ratio of the batter. catchers_interference : int The number of times the batter has reached base due to catchers interference. - at_bats_per_home_run : int + at_bats_per_home_run : float The number of at bats per home run of the batter. """ age: Optional[int] = None @@ -203,15 +203,15 @@ class SimpleHittingSplit(MLBBaseModel): intentional_walks: Optional[int] = Field(default=None, alias="intentionalWalks") hits: Optional[int] = None hit_by_pitch: Optional[int] = Field(default=None, alias="hitByPitch") - avg: Optional[str] = None + avg: Optional[float] = None at_bats: Optional[int] = Field(default=None, alias="atBats") - obp: Optional[str] = None - slg: Optional[str] = None - ops: Optional[str] = None + obp: Optional[float] = None + slg: Optional[float] = None + ops: Optional[float] = None caught_stealing: Optional[int] = Field(default=None, alias="caughtStealing") - caught_stealing_percentage: Optional[str] = Field(default=None, alias="caughtStealingPercentage") + caught_stealing_percentage: Optional[float] = Field(default=None, alias="caughtStealingPercentage") stolen_bases: Optional[int] = Field(default=None, alias="stolenBases") - stolen_base_percentage: Optional[str] = Field(default=None, alias="stolenBasePercentage") + stolen_base_percentage: Optional[float] = Field(default=None, alias="stolenBasePercentage") ground_into_double_play: Optional[int] = Field(default=None, alias="groundIntoDoublePlay") ground_into_triple_play: Optional[int] = Field(default=None, alias="groundIntoTriplePlay") number_of_pitches: Optional[int] = Field(default=None, alias="numberOfPitches") @@ -221,10 +221,10 @@ class SimpleHittingSplit(MLBBaseModel): left_on_base: Optional[int] = Field(default=None, alias="leftOnBase") sac_bunts: Optional[int] = Field(default=None, alias="sacBunts") sac_flies: Optional[int] = Field(default=None, alias="sacFlies") - babip: Optional[str] = None - groundouts_to_airouts: Optional[str] = Field(default=None, alias="groundOutsToAirouts") + babip: Optional[float] = None + groundouts_to_airouts: Optional[float] = Field(default=None, alias="groundOutsToAirouts") catchers_interference: Optional[int] = Field(default=None, alias="catchersInterference") - at_bats_per_home_run: Optional[str] = Field(default=None, alias="atBatsPerHomeRun") + at_bats_per_home_run: Optional[float] = Field(default=None, alias="atBatsPerHomeRun") class HittingWinLoss(Split): diff --git a/mlbstatsapi/models/stats/pitching.py b/mlbstatsapi/models/stats/pitching.py index 3736c83f..c64807dc 100644 --- a/mlbstatsapi/models/stats/pitching.py +++ b/mlbstatsapi/models/stats/pitching.py @@ -45,30 +45,32 @@ class SimplePitchingSplit(MLBBaseModel): The number of hits given up by the pitcher. hit_by_pitch : int The number of batters hit by the pitcher. - avg : str + avg : float The batting avg against the pitcher. at_bats : int The at bats pitched by the pitcher. - obp : str + obp : float The on base percentage against the pitcher. - slg : str + slg : float The slugging percentage against the pitcher. - ops : str + ops : float The on base slugging against the pitcher. caught_stealing : int The number of runners caught stealing against the pitcher. stolen_bases : int The number of stolen bases while pitching. - stolen_base_percentage : str + stolen_base_percentage : float The stolen base percentage while pitching. ground_into_double_play : int The number of double plays hit into. number_of_pitches : int The number of pitches thrown. - era : str + era : float The earned run average of the pitcher. innings_pitched : str - The number of innings pitched by the pitcher. + The number of innings pitched by the pitcher. Represented as MLB + innings notation (e.g. "6.2" means 6 2/3 innings), not a true + decimal value. wins : int The number of wins by the pitcher. losses : int @@ -83,7 +85,7 @@ class SimplePitchingSplit(MLBBaseModel): The number of blown saves performed by the pitcher. earned_runs : int The number of earned runs given up by the pitcher. - whip : str + whip : float The number of walks and hits per inning pitched. outs : int The number of outs. @@ -97,7 +99,7 @@ class SimplePitchingSplit(MLBBaseModel): The number of strikes thrown by the pitcher. hit_batsmen : int The number of batters hit by a pitch. - strike_percentage : str + strike_percentage : float The strike percentage thrown by the pitcher. wild_pitches : int The number of wild pitches thrown by the pitcher. @@ -107,25 +109,25 @@ class SimplePitchingSplit(MLBBaseModel): The total bases given up by the pitcher. pickoffs : int The number of pick offs performed by the pitcher. - win_percentage : str + win_percentage : float The win percentage of the pitcher. - groundouts_to_airouts : str + groundouts_to_airouts : float The groundout-to-airout ratio of the pitcher. games_finished : int The number of games finished by the pitcher. - pitches_per_inning : str + pitches_per_inning : float The number of pitches thrown per inning by the pitcher. - strikeouts_per_9_inn : str + strikeouts_per_9_inn : float The number of strike outs per 9 innings by the pitcher. - strikeout_walk_ratio : str + strikeout_walk_ratio : float The strike out to walk ratio of the pitcher. - hits_per_9_inn : str + hits_per_9_inn : float The number of hits per 9 innings pitched. - walks_per_9_inn : str + walks_per_9_inn : float The number of walks per 9 innings pitched. - home_runs_per_9 : str + home_runs_per_9 : float The number of home runs per 9 innings pitched. - runs_scored_per_9 : str + runs_scored_per_9 : float The number of runs scored per 9 innings pitched. sac_bunts : int The number of sac bunts given up when pitched. @@ -141,7 +143,7 @@ class SimplePitchingSplit(MLBBaseModel): The number of inherited runners for the pitcher. age : int The age of the pitcher. - caught_stealing_percentage : str + caught_stealing_percentage : float The caught stealing percentage for the pitcher. """ summary: Optional[str] = None @@ -160,18 +162,18 @@ class SimplePitchingSplit(MLBBaseModel): intentional_walks: Optional[int] = Field(default=None, alias="intentionalWalks") hits: Optional[int] = None hit_by_pitch: Optional[int] = Field(default=None, alias="hitByPitch") - avg: Optional[str] = None + avg: Optional[float] = None at_bats: Optional[int] = Field(default=None, alias="atBats") - obp: Optional[str] = None - slg: Optional[str] = None - ops: Optional[str] = None + obp: Optional[float] = None + slg: Optional[float] = None + ops: Optional[float] = None caught_stealing: Optional[int] = Field(default=None, alias="caughtStealing") - caught_stealing_percentage: Optional[str] = Field(default=None, alias="caughtStealingPercentage") + caught_stealing_percentage: Optional[float] = Field(default=None, alias="caughtStealingPercentage") stolen_bases: Optional[int] = Field(default=None, alias="stolenBases") - stolen_base_percentage: Optional[str] = Field(default=None, alias="stolenBasePercentage") + stolen_base_percentage: Optional[float] = Field(default=None, alias="stolenBasePercentage") ground_into_double_play: Optional[int] = Field(default=None, alias="groundIntoDoublePlay") number_of_pitches: Optional[int] = Field(default=None, alias="numberOfPitches") - era: Optional[str] = None + era: Optional[float] = None innings_pitched: Optional[str] = Field(default=None, alias="inningsPitched") wins: Optional[int] = None losses: Optional[int] = None @@ -180,28 +182,28 @@ class SimplePitchingSplit(MLBBaseModel): holds: Optional[int] = None blown_saves: Optional[int] = Field(default=None, alias="blownSaves") earned_runs: Optional[int] = Field(default=None, alias="earnedRuns") - whip: Optional[str] = None + whip: Optional[float] = None outs: Optional[int] = None games_pitched: Optional[int] = Field(default=None, alias="gamesPitched") complete_games: Optional[int] = Field(default=None, alias="completeGames") shutouts: Optional[int] = None strikes: Optional[int] = None - strike_percentage: Optional[str] = Field(default=None, alias="strikePercentage") + strike_percentage: Optional[float] = Field(default=None, alias="strikePercentage") hit_batsmen: Optional[int] = Field(default=None, alias="hitBatsmen") balks: Optional[int] = None wild_pitches: Optional[int] = Field(default=None, alias="wildPitches") pickoffs: Optional[int] = None total_bases: Optional[int] = Field(default=None, alias="totalBases") - groundouts_to_airouts: Optional[str] = Field(default=None, alias="groundoutsToAirouts") - win_percentage: Optional[str] = Field(default=None, alias="winPercentage") - pitches_per_inning: Optional[str] = Field(default=None, alias="pitchesPerInning") + groundouts_to_airouts: Optional[float] = Field(default=None, alias="groundoutsToAirouts") + win_percentage: Optional[float] = Field(default=None, alias="winPercentage") + pitches_per_inning: Optional[float] = Field(default=None, alias="pitchesPerInning") games_finished: Optional[int] = Field(default=None, alias="gamesFinished") - strikeout_walk_ratio: Optional[str] = Field(default=None, alias="strikeoutWalkRatio") - strikeouts_per_9_inn: Optional[str] = Field(default=None, alias="strikeoutsPer9Inn") - walks_per_9_inn: Optional[str] = Field(default=None, alias="walksPer9Inn") - hits_per_9_inn: Optional[str] = Field(default=None, alias="hitsPer9Inn") - runs_scored_per_9: Optional[str] = Field(default=None, alias="runsScoredPer9") - home_runs_per_9: Optional[str] = Field(default=None, alias="homeRunsPer9") + strikeout_walk_ratio: Optional[float] = Field(default=None, alias="strikeoutWalkRatio") + strikeouts_per_9_inn: Optional[float] = Field(default=None, alias="strikeoutsPer9Inn") + walks_per_9_inn: Optional[float] = Field(default=None, alias="walksPer9Inn") + hits_per_9_inn: Optional[float] = Field(default=None, alias="hitsPer9Inn") + runs_scored_per_9: Optional[float] = Field(default=None, alias="runsScoredPer9") + home_runs_per_9: Optional[float] = Field(default=None, alias="homeRunsPer9") catchers_interference: Optional[int] = Field(default=None, alias="catchersInterference") sac_bunts: Optional[int] = Field(default=None, alias="sacBunts") sac_flies: Optional[int] = Field(default=None, alias="sacFlies") @@ -221,29 +223,29 @@ class AdvancedPitchingSplit(MLBBaseModel): ---------- age : int The age of the pitcher. - winning_percentage : str + winning_percentage : float The winning percentage of the pitcher. - runs_scored_per_9 : str + runs_scored_per_9 : float The number of runs scored per 9 innings. batters_faced : int The number of batters faced. - babip : str + babip : float The BABIP of the pitcher. - obp : str + obp : float The on base percentage against the pitcher. - slg : str + slg : float The slugging percentage against the pitcher. - ops : str + ops : float The on base slugging against the pitcher. - strikeouts_per_9 : str + strikeouts_per_9 : float The number of strike outs per 9 innings. - base_on_balls_per_9 : str + base_on_balls_per_9 : float The number of base on balls per 9 innings. - home_runs_per_9 : str + home_runs_per_9 : float The number of home runs per 9 innings. - hits_per_9 : str + hits_per_9 : float The number of hits per 9 innings. - strikeouts_to_walks : str + strikeouts_to_walks : float The strike out to walk ratio. stolen_bases : int The number of stolen bases while pitching. @@ -275,21 +277,21 @@ class AdvancedPitchingSplit(MLBBaseModel): The number of balls put into play. run_support : int The number of run support. - strike_percentage : str + strike_percentage : float The strike percentage thrown. - pitches_per_inning : str + pitches_per_inning : float The number of pitches per inning. - pitches_per_plate_appearance : str + pitches_per_plate_appearance : float The avg number of pitches per plate appearance. - walks_per_plate_appearance : str + walks_per_plate_appearance : float The number of walks per plate appearance. - strikeouts_per_plate_appearance : str + strikeouts_per_plate_appearance : float The strike outs per plate appearance. - home_runs_per_plate_appearance : str + home_runs_per_plate_appearance : float The home runs per plate appearance. - walks_per_strikeout : str + walks_per_strikeout : float The walk per strike out ratio. - iso : str + iso : float Isolated power. flyouts : int The number of fly outs given up. @@ -317,18 +319,18 @@ class AdvancedPitchingSplit(MLBBaseModel): The number of bequeathed runners scored. """ age: Optional[int] = None - winning_percentage: Optional[str] = Field(default=None, alias="winningPercentage") - runs_scored_per_9: Optional[str] = Field(default=None, alias="runsScoredPer9") + winning_percentage: Optional[float] = Field(default=None, alias="winningPercentage") + runs_scored_per_9: Optional[float] = Field(default=None, alias="runsScoredPer9") batters_faced: Optional[int] = Field(default=None, alias="battersFaced") - babip: Optional[str] = None - obp: Optional[str] = None - slg: Optional[str] = None - ops: Optional[str] = None - strikeouts_per_9: Optional[str] = Field(default=None, alias="strikeoutsPer9") - base_on_balls_per_9: Optional[str] = Field(default=None, alias="baseOnBallsPer9") - home_runs_per_9: Optional[str] = Field(default=None, alias="homeRunsPer9") - hits_per_9: Optional[str] = Field(default=None, alias="hitsPer9") - strikeouts_to_walks: Optional[str] = Field(default=None, alias="strikeoutsToWalks") + babip: Optional[float] = None + obp: Optional[float] = None + slg: Optional[float] = None + ops: Optional[float] = None + strikeouts_per_9: Optional[float] = Field(default=None, alias="strikeoutsPer9") + base_on_balls_per_9: Optional[float] = Field(default=None, alias="baseOnBallsPer9") + home_runs_per_9: Optional[float] = Field(default=None, alias="homeRunsPer9") + hits_per_9: Optional[float] = Field(default=None, alias="hitsPer9") + strikeouts_to_walks: Optional[float] = Field(default=None, alias="strikeoutsToWalks") stolen_bases: Optional[int] = Field(default=None, alias="stolenBases") caught_stealing: Optional[int] = Field(default=None, alias="caughtStealing") quality_starts: Optional[int] = Field(default=None, alias="qualityStarts") @@ -342,22 +344,22 @@ class AdvancedPitchingSplit(MLBBaseModel): pickoffs: Optional[int] = None total_swings: Optional[int] = Field(default=None, alias="totalSwings") swing_and_misses: Optional[int] = Field(default=None, alias="swingAndMisses") - strikeouts_minus_walks_percentage: Optional[str] = Field(default=None, alias="strikeoutsMinusWalksPercentage") - gidp_percentage: Optional[str] = Field(default=None, alias="gidpPercentage") - batters_faced_per_game: Optional[str] = Field(default=None, alias="battersFacedPerGame") + strikeouts_minus_walks_percentage: Optional[float] = Field(default=None, alias="strikeoutsMinusWalksPercentage") + gidp_percentage: Optional[float] = Field(default=None, alias="gidpPercentage") + batters_faced_per_game: Optional[float] = Field(default=None, alias="battersFacedPerGame") bunts_failed: Optional[int] = Field(default=None, alias="buntsFailed") bunts_missed_tipped: Optional[int] = Field(default=None, alias="buntsMissedTipped") - whiff_percentage: Optional[str] = Field(default=None, alias="whiffPercentage") + whiff_percentage: Optional[float] = Field(default=None, alias="whiffPercentage") balls_in_play: Optional[int] = Field(default=None, alias="ballsInPlay") run_support: Optional[int] = Field(default=None, alias="runSupport") - strike_percentage: Optional[str] = Field(default=None, alias="strikePercentage") - pitches_per_inning: Optional[str] = Field(default=None, alias="pitchesPerInning") - pitches_per_plate_appearance: Optional[str] = Field(default=None, alias="pitchesPerPlateAppearance") - walks_per_plate_appearance: Optional[str] = Field(default=None, alias="walksPerPlateAppearance") - strikeouts_per_plate_appearance: Optional[str] = Field(default=None, alias="strikeoutsPerPlateAppearance") - home_runs_per_plate_appearance: Optional[str] = Field(default=None, alias="homeRunsPerPlateAppearance") - walks_per_strikeout: Optional[str] = Field(default=None, alias="walksPerStrikeout") - iso: Optional[str] = None + strike_percentage: Optional[float] = Field(default=None, alias="strikePercentage") + pitches_per_inning: Optional[float] = Field(default=None, alias="pitchesPerInning") + pitches_per_plate_appearance: Optional[float] = Field(default=None, alias="pitchesPerPlateAppearance") + walks_per_plate_appearance: Optional[float] = Field(default=None, alias="walksPerPlateAppearance") + strikeouts_per_plate_appearance: Optional[float] = Field(default=None, alias="strikeoutsPerPlateAppearance") + home_runs_per_plate_appearance: Optional[float] = Field(default=None, alias="homeRunsPerPlateAppearance") + walks_per_strikeout: Optional[float] = Field(default=None, alias="walksPerStrikeout") + iso: Optional[float] = None flyouts: Optional[int] = None popouts: Optional[int] = None lineouts: Optional[int] = None @@ -371,7 +373,7 @@ class AdvancedPitchingSplit(MLBBaseModel): bequeathed_runners: Optional[int] = Field(default=None, alias="bequeathedRunners") bequeathed_runners_scored: Optional[int] = Field(default=None, alias="bequeathedRunnersScored") innings_pitched_per_game: Optional[str] = Field(default=None, alias="inningsPitchedPerGame") - flyball_percentage: Optional[str] = Field(default=None, alias="flyballPercentage") + flyball_percentage: Optional[float] = Field(default=None, alias="flyballPercentage") class PitchingSabermetrics(Split): diff --git a/mlbstatsapi/models/stats/stats.py b/mlbstatsapi/models/stats/stats.py index ce118c34..7f084fc6 100644 --- a/mlbstatsapi/models/stats/stats.py +++ b/mlbstatsapi/models/stats/stats.py @@ -38,19 +38,19 @@ class ExpectedStatistics(MLBBaseModel): Attributes ---------- - avg : str + avg : float Expected batting average. - slg : str + slg : float Expected slugging. - woba : str + woba : float Expected wOBA. - wobacon : str + wobacon : float Expected wOBA on contact. """ - avg: str - slg: str - woba: str - wobacon: str = Field(alias="wobaCon") + avg: Optional[float] + slg: Optional[float] + woba: Optional[float] + wobacon: Optional[float] = Field(alias="wobaCon") class Sabermetrics(MLBBaseModel): diff --git a/tests/test_stat_float_coercion.py b/tests/test_stat_float_coercion.py new file mode 100644 index 00000000..84379f93 --- /dev/null +++ b/tests/test_stat_float_coercion.py @@ -0,0 +1,163 @@ +"""Regression tests for issue #340: Pydantic's native float coercion replaces +the string-typed stat fields that PR #241 proposed handling with a custom +``BeforeValidator``. These tests confirm ``Optional[float]`` alone converts +numeric strings, passes floats through, and treats ``None`` as ``None`` -- +with no custom validator involved. +""" + +import pytest +from pydantic import ValidationError + +from mlbstatsapi.models.stats.catching import SimpleCatchingSplit +from mlbstatsapi.models.stats.fielding import SimpleFieldingSplit +from mlbstatsapi.models.stats.hitting import AdvancedHittingSplit, SimpleHittingSplit +from mlbstatsapi.models.stats.pitching import AdvancedPitchingSplit, SimplePitchingSplit +from mlbstatsapi.models.stats.stats import ExpectedStatistics + + +def test_numeric_string_avg_converts_to_float(): + split = SimpleHittingSplit(avg=".287") + assert split.avg == pytest.approx(0.287) + assert isinstance(split.avg, float) + + +def test_numeric_string_era_converts_to_float(): + split = SimplePitchingSplit(era="3.42") + assert split.era == pytest.approx(3.42) + assert isinstance(split.era, float) + + +def test_float_input_stays_float(): + split = SimpleHittingSplit(avg=0.287) + assert split.avg == pytest.approx(0.287) + assert isinstance(split.avg, float) + + +def test_none_stays_none(): + split = SimpleHittingSplit(avg=None) + assert split.avg is None + + +def test_field_omitted_defaults_to_none(): + split = SimpleHittingSplit() + assert split.avg is None + + +def test_model_dump_serializes_as_float_not_string(): + split = SimpleHittingSplit(avg=".287") + dumped = split.model_dump(include={"avg"}) + assert dumped == {"avg": 0.287} + assert not isinstance(dumped["avg"], str) + + +@pytest.mark.parametrize( + "field, value", + [ + ("obp", ".366"), + ("slg", ".411"), + ("ops", ".777"), + ("caught_stealing_percentage", "45.5"), + ("stolen_base_percentage", "80.0"), + ("babip", ".310"), + ("groundouts_to_airouts", "1.24"), + ("at_bats_per_home_run", "18.5"), + ], +) +def test_simple_hitting_split_numeric_fields_convert(field, value): + split = SimpleHittingSplit(**{field: value}) + assert getattr(split, field) == pytest.approx(float(value)) + + +@pytest.mark.parametrize( + "field, value", + [ + ("whip", "1.09"), + ("strike_percentage", "64.9"), + ("win_percentage", "0.625"), + ("pitches_per_inning", "15.3"), + ("strikeout_walk_ratio", "5.9"), + ("strikeouts_per_9_inn", "11.9"), + ("walks_per_9_inn", "2.1"), + ("hits_per_9_inn", "6.7"), + ("runs_scored_per_9", "2.4"), + ("home_runs_per_9", "0.9"), + ], +) +def test_simple_pitching_split_numeric_fields_convert(field, value): + split = SimplePitchingSplit(**{field: value}) + assert getattr(split, field) == pytest.approx(float(value)) + + +def test_advanced_hitting_split_iso_converts(): + split = AdvancedHittingSplit(iso=".212") + assert split.iso == pytest.approx(0.212) + + +def test_advanced_pitching_split_babip_converts(): + split = AdvancedPitchingSplit(babip=".290") + assert split.babip == pytest.approx(0.290) + + +def test_simple_catching_split_stolen_base_percentage_converts(): + split = SimpleCatchingSplit(stolen_base_percentage="72.0") + assert split.stolen_base_percentage == pytest.approx(72.0) + + +def test_simple_fielding_split_fielding_percentage_converts(): + split = SimpleFieldingSplit(fielding="1.000") + assert split.fielding == pytest.approx(1.0) + + +def test_expected_statistics_converts_all_fields(): + stat = ExpectedStatistics(avg=".301", slg=".512", woba=".360", wobaCon=".400") + assert stat.avg == pytest.approx(0.301) + assert stat.slg == pytest.approx(0.512) + assert stat.woba == pytest.approx(0.360) + assert stat.wobacon == pytest.approx(0.400) + + +def test_expected_statistics_accepts_none(): + stat = ExpectedStatistics(avg=None, slg=None, woba=None, wobaCon=None) + assert stat.avg is None + assert stat.slg is None + assert stat.woba is None + assert stat.wobacon is None + + +@pytest.mark.parametrize("garbage", ["garbage", "N/A", "1.2.3"]) +def test_malformed_non_numeric_values_raise_validation_error(garbage): + """We deliberately do not swallow arbitrary malformed values into None. + + No MLB fixture in this repository demonstrates a sentinel string for these + fields, so unexpected malformed input should surface as a validation + error rather than being silently discarded. + """ + with pytest.raises(ValidationError): + SimpleHittingSplit(avg=garbage) + + +class TestInningsNotationFieldsRemainStrings: + """MLB innings notation (e.g. "6.2" == 6 2/3 innings) is not a decimal + value, so these fields are intentionally excluded from float conversion. + """ + + def test_simple_fielding_split_innings_stays_string(self): + split = SimpleFieldingSplit(innings="6.2") + assert split.innings == "6.2" + assert isinstance(split.innings, str) + + def test_simple_pitching_split_innings_pitched_stays_string(self): + split = SimplePitchingSplit(innings_pitched="6.2") + assert split.innings_pitched == "6.2" + assert isinstance(split.innings_pitched, str) + + def test_advanced_pitching_split_innings_pitched_per_game_stays_string(self): + split = AdvancedPitchingSplit(innings_pitched_per_game="6.2") + assert split.innings_pitched_per_game == "6.2" + assert isinstance(split.innings_pitched_per_game, str) + + def test_innings_notation_serializes_as_string_in_model_dump(self): + split = SimplePitchingSplit(innings_pitched="6.2") + dumped = split.model_dump(include={"innings_pitched"}) + assert dumped == {"innings_pitched": "6.2"} + assert isinstance(dumped["innings_pitched"], str) From 290d4a45595cb339d85b3aa68f836f854ed12f62 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 2 Sep 2026 18:25:27 -0700 Subject: [PATCH 2/4] removing test_stat_float_coercion --- tests/test_stat_float_coercion.py | 163 ------------------------------ 1 file changed, 163 deletions(-) delete mode 100644 tests/test_stat_float_coercion.py diff --git a/tests/test_stat_float_coercion.py b/tests/test_stat_float_coercion.py deleted file mode 100644 index 84379f93..00000000 --- a/tests/test_stat_float_coercion.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Regression tests for issue #340: Pydantic's native float coercion replaces -the string-typed stat fields that PR #241 proposed handling with a custom -``BeforeValidator``. These tests confirm ``Optional[float]`` alone converts -numeric strings, passes floats through, and treats ``None`` as ``None`` -- -with no custom validator involved. -""" - -import pytest -from pydantic import ValidationError - -from mlbstatsapi.models.stats.catching import SimpleCatchingSplit -from mlbstatsapi.models.stats.fielding import SimpleFieldingSplit -from mlbstatsapi.models.stats.hitting import AdvancedHittingSplit, SimpleHittingSplit -from mlbstatsapi.models.stats.pitching import AdvancedPitchingSplit, SimplePitchingSplit -from mlbstatsapi.models.stats.stats import ExpectedStatistics - - -def test_numeric_string_avg_converts_to_float(): - split = SimpleHittingSplit(avg=".287") - assert split.avg == pytest.approx(0.287) - assert isinstance(split.avg, float) - - -def test_numeric_string_era_converts_to_float(): - split = SimplePitchingSplit(era="3.42") - assert split.era == pytest.approx(3.42) - assert isinstance(split.era, float) - - -def test_float_input_stays_float(): - split = SimpleHittingSplit(avg=0.287) - assert split.avg == pytest.approx(0.287) - assert isinstance(split.avg, float) - - -def test_none_stays_none(): - split = SimpleHittingSplit(avg=None) - assert split.avg is None - - -def test_field_omitted_defaults_to_none(): - split = SimpleHittingSplit() - assert split.avg is None - - -def test_model_dump_serializes_as_float_not_string(): - split = SimpleHittingSplit(avg=".287") - dumped = split.model_dump(include={"avg"}) - assert dumped == {"avg": 0.287} - assert not isinstance(dumped["avg"], str) - - -@pytest.mark.parametrize( - "field, value", - [ - ("obp", ".366"), - ("slg", ".411"), - ("ops", ".777"), - ("caught_stealing_percentage", "45.5"), - ("stolen_base_percentage", "80.0"), - ("babip", ".310"), - ("groundouts_to_airouts", "1.24"), - ("at_bats_per_home_run", "18.5"), - ], -) -def test_simple_hitting_split_numeric_fields_convert(field, value): - split = SimpleHittingSplit(**{field: value}) - assert getattr(split, field) == pytest.approx(float(value)) - - -@pytest.mark.parametrize( - "field, value", - [ - ("whip", "1.09"), - ("strike_percentage", "64.9"), - ("win_percentage", "0.625"), - ("pitches_per_inning", "15.3"), - ("strikeout_walk_ratio", "5.9"), - ("strikeouts_per_9_inn", "11.9"), - ("walks_per_9_inn", "2.1"), - ("hits_per_9_inn", "6.7"), - ("runs_scored_per_9", "2.4"), - ("home_runs_per_9", "0.9"), - ], -) -def test_simple_pitching_split_numeric_fields_convert(field, value): - split = SimplePitchingSplit(**{field: value}) - assert getattr(split, field) == pytest.approx(float(value)) - - -def test_advanced_hitting_split_iso_converts(): - split = AdvancedHittingSplit(iso=".212") - assert split.iso == pytest.approx(0.212) - - -def test_advanced_pitching_split_babip_converts(): - split = AdvancedPitchingSplit(babip=".290") - assert split.babip == pytest.approx(0.290) - - -def test_simple_catching_split_stolen_base_percentage_converts(): - split = SimpleCatchingSplit(stolen_base_percentage="72.0") - assert split.stolen_base_percentage == pytest.approx(72.0) - - -def test_simple_fielding_split_fielding_percentage_converts(): - split = SimpleFieldingSplit(fielding="1.000") - assert split.fielding == pytest.approx(1.0) - - -def test_expected_statistics_converts_all_fields(): - stat = ExpectedStatistics(avg=".301", slg=".512", woba=".360", wobaCon=".400") - assert stat.avg == pytest.approx(0.301) - assert stat.slg == pytest.approx(0.512) - assert stat.woba == pytest.approx(0.360) - assert stat.wobacon == pytest.approx(0.400) - - -def test_expected_statistics_accepts_none(): - stat = ExpectedStatistics(avg=None, slg=None, woba=None, wobaCon=None) - assert stat.avg is None - assert stat.slg is None - assert stat.woba is None - assert stat.wobacon is None - - -@pytest.mark.parametrize("garbage", ["garbage", "N/A", "1.2.3"]) -def test_malformed_non_numeric_values_raise_validation_error(garbage): - """We deliberately do not swallow arbitrary malformed values into None. - - No MLB fixture in this repository demonstrates a sentinel string for these - fields, so unexpected malformed input should surface as a validation - error rather than being silently discarded. - """ - with pytest.raises(ValidationError): - SimpleHittingSplit(avg=garbage) - - -class TestInningsNotationFieldsRemainStrings: - """MLB innings notation (e.g. "6.2" == 6 2/3 innings) is not a decimal - value, so these fields are intentionally excluded from float conversion. - """ - - def test_simple_fielding_split_innings_stays_string(self): - split = SimpleFieldingSplit(innings="6.2") - assert split.innings == "6.2" - assert isinstance(split.innings, str) - - def test_simple_pitching_split_innings_pitched_stays_string(self): - split = SimplePitchingSplit(innings_pitched="6.2") - assert split.innings_pitched == "6.2" - assert isinstance(split.innings_pitched, str) - - def test_advanced_pitching_split_innings_pitched_per_game_stays_string(self): - split = AdvancedPitchingSplit(innings_pitched_per_game="6.2") - assert split.innings_pitched_per_game == "6.2" - assert isinstance(split.innings_pitched_per_game, str) - - def test_innings_notation_serializes_as_string_in_model_dump(self): - split = SimplePitchingSplit(innings_pitched="6.2") - dumped = split.model_dump(include={"innings_pitched"}) - assert dumped == {"innings_pitched": "6.2"} - assert isinstance(dumped["innings_pitched"], str) From 244a694205bb303336dabce0c74f652052f3d4c3 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 2 Sep 2026 19:57:02 -0700 Subject: [PATCH 3/4] Normalize MLB float sentinel strings before Pydantic coercion The live MLB Stats API returns ".---" and "-.--" as placeholder values for rate/ratio stats that don't apply (e.g. caught-stealing percentage when no steal attempts occurred). Plain Optional[float] rejects these, breaking several external tests added since #340 converted these fields from str. Adds a shared normalize_mlb_float_sentinel() helper and field_validator (mode="before") declarations on SimpleCatchingSplit, SimpleFieldingSplit, SimpleHittingSplit, AdvancedHittingSplit, SimplePitchingSplit, AdvancedPitchingSplit, and ExpectedStatistics that map only those two known sentinels to None, leaving any other malformed string to raise ValidationError as before. Field annotations stay plain Optional[float]; innings-notation fields remain untouched. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Rf8xvVc7a1TrPZEKaF8vb --- docs/stats.md | 8 + mlbstatsapi/models/stats/catching.py | 17 +- mlbstatsapi/models/stats/fielding.py | 14 ++ mlbstatsapi/models/stats/hitting.py | 31 +++ mlbstatsapi/models/stats/pitching.py | 57 ++++++ mlbstatsapi/models/stats/sentinels.py | 17 ++ mlbstatsapi/models/stats/stats.py | 6 + tests/test_stat_float_coercion.py | 263 ++++++++++++++++++++++++++ 8 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 mlbstatsapi/models/stats/sentinels.py create mode 100644 tests/test_stat_float_coercion.py diff --git a/docs/stats.md b/docs/stats.md index 8af14a6c..90fd844e 100644 --- a/docs/stats.md +++ b/docs/stats.md @@ -279,6 +279,14 @@ This is a behavioral change from earlier releases where these fields were `str`. Code relying on string operations (`avg.startswith(".")`) or on `avg == ".287"` needs to switch to numeric comparisons. +The MLB Stats API also uses two placeholder strings, `".---"` and `"-.--"`, +for these rate stats when the underlying value is not applicable (for +example a caught-stealing percentage when nobody has attempted a steal). +These two known sentinels are normalized to `None` before conversion, so +`split.stat.avg` is `None` rather than raising a validation error. Any other +non-numeric string still raises a `ValidationError`, since it isn't a +sentinel MLB is known to send. + Fields that use MLB's innings notation remain `str`, because values like `"6.2"` mean 6 2/3 innings rather than the decimal 6.2: diff --git a/mlbstatsapi/models/stats/catching.py b/mlbstatsapi/models/stats/catching.py index ddc69334..11964cd8 100644 --- a/mlbstatsapi/models/stats/catching.py +++ b/mlbstatsapi/models/stats/catching.py @@ -1,9 +1,10 @@ from typing import Optional, List, ClassVar -from pydantic import Field +from pydantic import Field, field_validator from mlbstatsapi.models.base import MLBBaseModel from mlbstatsapi.models.teams import Team from mlbstatsapi.models.game import Game from .stats import Split +from .sentinels import normalize_mlb_float_sentinel class SimpleCatchingSplit(MLBBaseModel): @@ -107,6 +108,20 @@ class SimpleCatchingSplit(MLBBaseModel): passed_ball: Optional[int] = Field(default=None, alias="passedBall") pickoff_attempts: Optional[int] = Field(default=None, alias="pickoffAttempts") + @field_validator( + "avg", + "obp", + "slg", + "ops", + "caught_stealing_percentage", + "stolen_base_percentage", + "strikeout_walk_ratio", + mode="before", + ) + @classmethod + def normalize_float_sentinels(cls, value): + return normalize_mlb_float_sentinel(value) + class CatchingSeason(Split): """ diff --git a/mlbstatsapi/models/stats/fielding.py b/mlbstatsapi/models/stats/fielding.py index 5b663053..a8ed72d9 100644 --- a/mlbstatsapi/models/stats/fielding.py +++ b/mlbstatsapi/models/stats/fielding.py @@ -5,6 +5,7 @@ from mlbstatsapi.models.teams import Team from mlbstatsapi.models.game import Game from .stats import Split +from .sentinels import normalize_mlb_float_sentinel class SimpleFieldingSplit(MLBBaseModel): @@ -97,6 +98,19 @@ def empty_dict_to_none(cls, v: Any) -> Any: return None return v + @field_validator( + "caught_stealing_percentage", + "stolen_base_percentage", + "fielding", + "range_factor_per_game", + "range_factor_per_9_inn", + "catcher_era", + mode="before", + ) + @classmethod + def normalize_float_sentinels(cls, value: Any) -> Any: + return normalize_mlb_float_sentinel(value) + class FieldingSeasonAdvanced(Split): """ diff --git a/mlbstatsapi/models/stats/hitting.py b/mlbstatsapi/models/stats/hitting.py index 0b34b1f9..a48b93ad 100644 --- a/mlbstatsapi/models/stats/hitting.py +++ b/mlbstatsapi/models/stats/hitting.py @@ -6,6 +6,7 @@ from mlbstatsapi.models.game import Game from mlbstatsapi.models.data import PitchData, HitData, Count, PlayDetails from .stats import Sabermetrics, ExpectedStatistics, Split +from .sentinels import normalize_mlb_float_sentinel class AdvancedHittingSplit(MLBBaseModel): @@ -109,6 +110,20 @@ class AdvancedHittingSplit(MLBBaseModel): ground_hits: Optional[int] = Field(default=None, alias="groundHits") line_hits: Optional[int] = Field(default=None, alias="lineHits") + @field_validator( + "babip", + "pitches_per_plate_appearance", + "walks_per_plate_appearance", + "strikeouts_per_plate_appearance", + "home_runs_per_plate_appearance", + "walks_per_strikeout", + "iso", + mode="before", + ) + @classmethod + def normalize_float_sentinels(cls, value): + return normalize_mlb_float_sentinel(value) + class SimpleHittingSplit(MLBBaseModel): """ @@ -226,6 +241,22 @@ class SimpleHittingSplit(MLBBaseModel): catchers_interference: Optional[int] = Field(default=None, alias="catchersInterference") at_bats_per_home_run: Optional[float] = Field(default=None, alias="atBatsPerHomeRun") + @field_validator( + "avg", + "obp", + "slg", + "ops", + "caught_stealing_percentage", + "stolen_base_percentage", + "babip", + "groundouts_to_airouts", + "at_bats_per_home_run", + mode="before", + ) + @classmethod + def normalize_float_sentinels(cls, value): + return normalize_mlb_float_sentinel(value) + class HittingWinLoss(Split): """ diff --git a/mlbstatsapi/models/stats/pitching.py b/mlbstatsapi/models/stats/pitching.py index c64807dc..467c612d 100644 --- a/mlbstatsapi/models/stats/pitching.py +++ b/mlbstatsapi/models/stats/pitching.py @@ -7,6 +7,7 @@ from mlbstatsapi.models.data import Count, PlayDetails from .stats import Sabermetrics, ExpectedStatistics, Split from .hitting import SimpleHittingSplit +from .sentinels import normalize_mlb_float_sentinel class SimplePitchingSplit(MLBBaseModel): @@ -214,6 +215,31 @@ class SimplePitchingSplit(MLBBaseModel): outs_pitched: Optional[int] = Field(default=None, alias="outsPitched") rbi: Optional[int] = None + @field_validator( + "avg", + "obp", + "slg", + "ops", + "caught_stealing_percentage", + "stolen_base_percentage", + "era", + "whip", + "strike_percentage", + "groundouts_to_airouts", + "win_percentage", + "pitches_per_inning", + "strikeout_walk_ratio", + "strikeouts_per_9_inn", + "walks_per_9_inn", + "hits_per_9_inn", + "runs_scored_per_9", + "home_runs_per_9", + mode="before", + ) + @classmethod + def normalize_float_sentinels(cls, value: Any) -> Any: + return normalize_mlb_float_sentinel(value) + class AdvancedPitchingSplit(MLBBaseModel): """ @@ -375,6 +401,37 @@ class AdvancedPitchingSplit(MLBBaseModel): innings_pitched_per_game: Optional[str] = Field(default=None, alias="inningsPitchedPerGame") flyball_percentage: Optional[float] = Field(default=None, alias="flyballPercentage") + @field_validator( + "winning_percentage", + "runs_scored_per_9", + "babip", + "obp", + "slg", + "ops", + "strikeouts_per_9", + "base_on_balls_per_9", + "home_runs_per_9", + "hits_per_9", + "strikeouts_to_walks", + "strikeouts_minus_walks_percentage", + "gidp_percentage", + "batters_faced_per_game", + "whiff_percentage", + "strike_percentage", + "pitches_per_inning", + "pitches_per_plate_appearance", + "walks_per_plate_appearance", + "strikeouts_per_plate_appearance", + "home_runs_per_plate_appearance", + "walks_per_strikeout", + "iso", + "flyball_percentage", + mode="before", + ) + @classmethod + def normalize_float_sentinels(cls, value: Any) -> Any: + return normalize_mlb_float_sentinel(value) + class PitchingSabermetrics(Split): """ diff --git a/mlbstatsapi/models/stats/sentinels.py b/mlbstatsapi/models/stats/sentinels.py new file mode 100644 index 00000000..b1e49ec8 --- /dev/null +++ b/mlbstatsapi/models/stats/sentinels.py @@ -0,0 +1,17 @@ +"""Normalization helper for MLB Stats API's non-numeric sentinel strings. + +The live API represents "not applicable" rate/ratio stats (e.g. a caught +stealing percentage when nobody has attempted a steal) with placeholder +strings instead of omitting the field or returning null. These are the only +two sentinel values observed so far; any other non-numeric string is left +alone so Pydantic's float coercion raises a ValidationError instead of +silently discarding unexpected malformed input. +""" + +MLB_FLOAT_SENTINELS = {".---", "-.--"} + + +def normalize_mlb_float_sentinel(value): + if value in MLB_FLOAT_SENTINELS: + return None + return value diff --git a/mlbstatsapi/models/stats/stats.py b/mlbstatsapi/models/stats/stats.py index 7f084fc6..22ac6274 100644 --- a/mlbstatsapi/models/stats/stats.py +++ b/mlbstatsapi/models/stats/stats.py @@ -6,6 +6,7 @@ from mlbstatsapi.models.sports import Sport from mlbstatsapi.models.leagues import League from mlbstatsapi.models.data import CodeDesc +from .sentinels import normalize_mlb_float_sentinel class PitchArsenalSplit(MLBBaseModel): @@ -52,6 +53,11 @@ class ExpectedStatistics(MLBBaseModel): woba: Optional[float] wobacon: Optional[float] = Field(alias="wobaCon") + @field_validator("avg", "slg", "woba", "wobacon", mode="before") + @classmethod + def normalize_float_sentinels(cls, value: Any) -> Any: + return normalize_mlb_float_sentinel(value) + class Sabermetrics(MLBBaseModel): """ diff --git a/tests/test_stat_float_coercion.py b/tests/test_stat_float_coercion.py new file mode 100644 index 00000000..4f379f88 --- /dev/null +++ b/tests/test_stat_float_coercion.py @@ -0,0 +1,263 @@ +"""Regression tests for issue #340: numeric stat fields are typed as plain +``Optional[float]``. Pydantic's native coercion handles ordinary numeric +strings (e.g. ".287") with no custom validator, but the live MLB Stats API +also returns non-numeric sentinel strings (".---", "-.--") for rate stats +that are not applicable (e.g. a caught-stealing percentage when nobody has +attempted a steal). ``field_validator(mode="before")`` validators on the +affected models normalize those two known sentinels to ``None`` before +Pydantic's float coercion runs, while leaving any other malformed string +alone so it still raises a ``ValidationError``. +""" + +import pytest +from pydantic import ValidationError + +from mlbstatsapi.models.stats.catching import SimpleCatchingSplit +from mlbstatsapi.models.stats.fielding import SimpleFieldingSplit +from mlbstatsapi.models.stats.hitting import AdvancedHittingSplit, SimpleHittingSplit +from mlbstatsapi.models.stats.pitching import AdvancedPitchingSplit, SimplePitchingSplit +from mlbstatsapi.models.stats.sentinels import normalize_mlb_float_sentinel +from mlbstatsapi.models.stats.stats import ExpectedStatistics + + +def test_numeric_string_avg_converts_to_float(): + split = SimpleHittingSplit(avg=".287") + assert split.avg == pytest.approx(0.287) + assert isinstance(split.avg, float) + + +def test_numeric_string_era_converts_to_float(): + split = SimplePitchingSplit(era="3.42") + assert split.era == pytest.approx(3.42) + assert isinstance(split.era, float) + + +def test_float_input_stays_float(): + split = SimpleHittingSplit(avg=0.287) + assert split.avg == pytest.approx(0.287) + assert isinstance(split.avg, float) + + +def test_none_stays_none(): + split = SimpleHittingSplit(avg=None) + assert split.avg is None + + +def test_field_omitted_defaults_to_none(): + split = SimpleHittingSplit() + assert split.avg is None + + +def test_model_dump_serializes_as_float_not_string(): + split = SimpleHittingSplit(avg=".287") + dumped = split.model_dump(include={"avg"}) + assert dumped == {"avg": 0.287} + assert not isinstance(dumped["avg"], str) + + +@pytest.mark.parametrize( + "field, value", + [ + ("obp", ".366"), + ("slg", ".411"), + ("ops", ".777"), + ("caught_stealing_percentage", "45.5"), + ("stolen_base_percentage", "80.0"), + ("babip", ".310"), + ("groundouts_to_airouts", "1.24"), + ("at_bats_per_home_run", "18.5"), + ], +) +def test_simple_hitting_split_numeric_fields_convert(field, value): + split = SimpleHittingSplit(**{field: value}) + assert getattr(split, field) == pytest.approx(float(value)) + + +@pytest.mark.parametrize( + "field, value", + [ + ("whip", "1.09"), + ("strike_percentage", "64.9"), + ("win_percentage", "0.625"), + ("pitches_per_inning", "15.3"), + ("strikeout_walk_ratio", "5.9"), + ("strikeouts_per_9_inn", "11.9"), + ("walks_per_9_inn", "2.1"), + ("hits_per_9_inn", "6.7"), + ("runs_scored_per_9", "2.4"), + ("home_runs_per_9", "0.9"), + ], +) +def test_simple_pitching_split_numeric_fields_convert(field, value): + split = SimplePitchingSplit(**{field: value}) + assert getattr(split, field) == pytest.approx(float(value)) + + +def test_advanced_hitting_split_iso_converts(): + split = AdvancedHittingSplit(iso=".212") + assert split.iso == pytest.approx(0.212) + + +def test_advanced_pitching_split_babip_converts(): + split = AdvancedPitchingSplit(babip=".290") + assert split.babip == pytest.approx(0.290) + + +def test_simple_catching_split_stolen_base_percentage_converts(): + split = SimpleCatchingSplit(stolen_base_percentage="72.0") + assert split.stolen_base_percentage == pytest.approx(72.0) + + +def test_simple_fielding_split_fielding_percentage_converts(): + split = SimpleFieldingSplit(fielding="1.000") + assert split.fielding == pytest.approx(1.0) + + +def test_expected_statistics_converts_all_fields(): + stat = ExpectedStatistics(avg=".301", slg=".512", woba=".360", wobaCon=".400") + assert stat.avg == pytest.approx(0.301) + assert stat.slg == pytest.approx(0.512) + assert stat.woba == pytest.approx(0.360) + assert stat.wobacon == pytest.approx(0.400) + + +def test_expected_statistics_accepts_none(): + stat = ExpectedStatistics(avg=None, slg=None, woba=None, wobaCon=None) + assert stat.avg is None + assert stat.slg is None + assert stat.woba is None + assert stat.wobacon is None + + +@pytest.mark.parametrize("garbage", ["garbage", "N/A", "1.2.3"]) +def test_malformed_non_numeric_values_raise_validation_error(garbage): + """Unexpected malformed values are not swallowed into None. + + Only the two confirmed MLB sentinel strings (".---", "-.--") are + normalized to None. Any other malformed string should surface as a + validation error rather than being silently discarded. + """ + with pytest.raises(ValidationError): + SimpleHittingSplit(avg=garbage) + + +class TestMlbFloatSentinelNormalization: + """Regression coverage for the two confirmed MLB sentinel strings.""" + + @pytest.mark.parametrize("sentinel", [".---", "-.--"]) + def test_normalize_mlb_float_sentinel_returns_none(self, sentinel): + assert normalize_mlb_float_sentinel(sentinel) is None + + def test_normalize_mlb_float_sentinel_leaves_other_values_unchanged(self): + assert normalize_mlb_float_sentinel(".287") == ".287" + assert normalize_mlb_float_sentinel(3.42) == 3.42 + assert normalize_mlb_float_sentinel(None) is None + assert normalize_mlb_float_sentinel("banana") == "banana" + + @pytest.mark.parametrize("sentinel", [".---", "-.--"]) + def test_simple_hitting_split_sentinel_fields_become_none(self, sentinel): + split = SimpleHittingSplit( + avg=sentinel, + obp=sentinel, + slg=sentinel, + ops=sentinel, + caughtStealingPercentage=sentinel, + stolenBasePercentage=sentinel, + babip=sentinel, + groundOutsToAirouts=sentinel, + atBatsPerHomeRun=sentinel, + ) + assert split.avg is None + assert split.obp is None + assert split.slg is None + assert split.ops is None + assert split.caught_stealing_percentage is None + assert split.stolen_base_percentage is None + assert split.babip is None + assert split.groundouts_to_airouts is None + assert split.at_bats_per_home_run is None + + @pytest.mark.parametrize("sentinel", [".---", "-.--"]) + def test_simple_pitching_split_sentinel_fields_become_none(self, sentinel): + split = SimplePitchingSplit( + era=sentinel, + whip=sentinel, + winPercentage=sentinel, + strikeoutWalkRatio=sentinel, + groundoutsToAirouts=sentinel, + ) + assert split.era is None + assert split.whip is None + assert split.win_percentage is None + assert split.strikeout_walk_ratio is None + assert split.groundouts_to_airouts is None + + def test_simple_pitching_split_sentinel_model_dump_is_none(self): + split = SimplePitchingSplit(era=".---") + dumped = split.model_dump(include={"era"}) + assert dumped == {"era": None} + + def test_advanced_pitching_split_sentinel_fields_become_none(self): + split = AdvancedPitchingSplit(babip=".---", winningPercentage="-.--") + assert split.babip is None + assert split.winning_percentage is None + + def test_advanced_hitting_split_sentinel_fields_become_none(self): + split = AdvancedHittingSplit(babip=".---", iso="-.--") + assert split.babip is None + assert split.iso is None + + def test_simple_catching_split_sentinel_fields_become_none(self): + split = SimpleCatchingSplit( + caughtStealingPercentage=".---", + stolenBasePercentage="-.--", + ) + assert split.caught_stealing_percentage is None + assert split.stolen_base_percentage is None + + def test_simple_fielding_split_sentinel_fields_become_none(self): + split = SimpleFieldingSplit( + rangeFactorPer9Inn="-.--", + caughtStealingPercentage=".---", + ) + assert split.range_factor_per_9_inn is None + assert split.caught_stealing_percentage is None + + def test_expected_statistics_sentinel_fields_become_none(self): + stat = ExpectedStatistics(avg=".---", slg="-.--", woba=None, wobaCon=None) + assert stat.avg is None + assert stat.slg is None + + def test_unrelated_string_field_is_not_normalized(self): + """Sanity check that sentinel normalization is scoped to float + fields, not applied broadly to every string field on the model.""" + split = SimplePitchingSplit(summary=".---") + assert split.summary == ".---" + + +class TestInningsNotationFieldsRemainStrings: + """MLB innings notation (e.g. "6.2" == 6 2/3 innings) is not a decimal + value, so these fields are intentionally excluded from float conversion + and from sentinel normalization. + """ + + def test_simple_fielding_split_innings_stays_string(self): + split = SimpleFieldingSplit(innings="6.2") + assert split.innings == "6.2" + assert isinstance(split.innings, str) + + def test_simple_pitching_split_innings_pitched_stays_string(self): + split = SimplePitchingSplit(innings_pitched="6.2") + assert split.innings_pitched == "6.2" + assert isinstance(split.innings_pitched, str) + + def test_advanced_pitching_split_innings_pitched_per_game_stays_string(self): + split = AdvancedPitchingSplit(innings_pitched_per_game="6.2") + assert split.innings_pitched_per_game == "6.2" + assert isinstance(split.innings_pitched_per_game, str) + + def test_innings_notation_serializes_as_string_in_model_dump(self): + split = SimplePitchingSplit(innings_pitched="6.2") + dumped = split.model_dump(include={"innings_pitched"}) + assert dumped == {"innings_pitched": "6.2"} + assert isinstance(dumped["innings_pitched"], str) From 4ef622315a8a64c9a11b587ff5189c4a4b0fc5e8 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 2 Sep 2026 20:11:11 -0700 Subject: [PATCH 4/4] Guard sentinel normalization against unhashable input normalize_mlb_float_sentinel() used `value in MLB_FLOAT_SENTINELS`, which raises TypeError for unhashable values like dicts or lists before Pydantic gets a chance to validate. Restrict the membership check to strings so unexpected shapes pass through untouched and surface as a normal ValidationError instead of crashing inside the helper. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Rf8xvVc7a1TrPZEKaF8vb --- mlbstatsapi/models/stats/sentinels.py | 2 +- tests/test_stat_float_coercion.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/mlbstatsapi/models/stats/sentinels.py b/mlbstatsapi/models/stats/sentinels.py index b1e49ec8..ed827dc9 100644 --- a/mlbstatsapi/models/stats/sentinels.py +++ b/mlbstatsapi/models/stats/sentinels.py @@ -12,6 +12,6 @@ def normalize_mlb_float_sentinel(value): - if value in MLB_FLOAT_SENTINELS: + if isinstance(value, str) and value in MLB_FLOAT_SENTINELS: return None return value diff --git a/tests/test_stat_float_coercion.py b/tests/test_stat_float_coercion.py index 4f379f88..65617565 100644 --- a/tests/test_stat_float_coercion.py +++ b/tests/test_stat_float_coercion.py @@ -154,6 +154,21 @@ def test_normalize_mlb_float_sentinel_leaves_other_values_unchanged(self): assert normalize_mlb_float_sentinel(None) is None assert normalize_mlb_float_sentinel("banana") == "banana" + @pytest.mark.parametrize("unhashable", [{"unexpected": "value"}, ["unexpected"]]) + def test_normalize_mlb_float_sentinel_does_not_raise_on_unhashable_input(self, unhashable): + """dicts/lists can't be checked with `in` against a set of strings. + + The helper must not raise TypeError itself -- unexpected shapes + should pass through unchanged so Pydantic's own validation raises + the ValidationError, rather than the helper crashing first. + """ + assert normalize_mlb_float_sentinel(unhashable) is unhashable + + @pytest.mark.parametrize("unhashable", [{"unexpected": "value"}, ["unexpected"]]) + def test_simple_hitting_split_unhashable_avg_raises_validation_error(self, unhashable): + with pytest.raises(ValidationError): + SimpleHittingSplit(avg=unhashable) + @pytest.mark.parametrize("sentinel", [".---", "-.--"]) def test_simple_hitting_split_sentinel_fields_become_none(self, sentinel): split = SimpleHittingSplit(