diff --git a/docs/stats.md b/docs/stats.md index 0a301842..90fd844e 100644 --- a/docs/stats.md +++ b/docs/stats.md @@ -263,6 +263,37 @@ 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. + +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: + +- `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..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): @@ -30,23 +31,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 +63,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 +85,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,13 +101,27 @@ 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") 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 d13eeaf7..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): @@ -21,11 +22,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 +36,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 +53,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 +69,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") @@ -96,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 ede8cdeb..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): @@ -26,7 +27,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 +39,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 +84,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") @@ -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): """ @@ -144,23 +159,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 +195,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 +218,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 +236,26 @@ 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") + + @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 3736c83f..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): @@ -45,30 +46,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 +86,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 +100,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 +110,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 +144,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 +163,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 +183,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") @@ -212,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): """ @@ -221,29 +249,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 +303,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 +345,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 +370,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 +399,38 @@ 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") + + @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..ed827dc9 --- /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 isinstance(value, str) and value in MLB_FLOAT_SENTINELS: + return None + return value diff --git a/mlbstatsapi/models/stats/stats.py b/mlbstatsapi/models/stats/stats.py index ce118c34..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): @@ -38,19 +39,24 @@ 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") + + @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..65617565 --- /dev/null +++ b/tests/test_stat_float_coercion.py @@ -0,0 +1,278 @@ +"""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("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( + 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)