cfb-data 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. cfb_data/__init__.py +234 -0
  2. cfb_data/_dataframes.py +279 -0
  3. cfb_data/_executor.py +129 -0
  4. cfb_data/_parquet.py +197 -0
  5. cfb_data/_request_rules.py +41 -0
  6. cfb_data/_requests.py +36 -0
  7. cfb_data/_tabular.py +676 -0
  8. cfb_data/_transport.py +452 -0
  9. cfb_data/adjusted_metrics/__init__.py +29 -0
  10. cfb_data/adjusted_metrics/models/__init__.py +1 -0
  11. cfb_data/adjusted_metrics/models/pydantic/__init__.py +29 -0
  12. cfb_data/adjusted_metrics/models/pydantic/requests.py +45 -0
  13. cfb_data/adjusted_metrics/models/pydantic/responses.py +88 -0
  14. cfb_data/adjusted_metrics/resource.py +220 -0
  15. cfb_data/base/__init__.py +6 -0
  16. cfb_data/base/types.py +112 -0
  17. cfb_data/betting/__init__.py +15 -0
  18. cfb_data/betting/models/__init__.py +1 -0
  19. cfb_data/betting/models/pydantic/__init__.py +6 -0
  20. cfb_data/betting/models/pydantic/requests.py +35 -0
  21. cfb_data/betting/models/pydantic/responses.py +62 -0
  22. cfb_data/betting/resource.py +91 -0
  23. cfb_data/client.py +305 -0
  24. cfb_data/coaches/__init__.py +57 -0
  25. cfb_data/coaches/models/__init__.py +1 -0
  26. cfb_data/coaches/models/pydantic/__init__.py +57 -0
  27. cfb_data/coaches/models/pydantic/requests.py +75 -0
  28. cfb_data/coaches/models/pydantic/responses.py +261 -0
  29. cfb_data/coaches/resource.py +216 -0
  30. cfb_data/conferences/__init__.py +23 -0
  31. cfb_data/conferences/models/__init__.py +1 -0
  32. cfb_data/conferences/models/pydantic/__init__.py +23 -0
  33. cfb_data/conferences/models/pydantic/requests.py +69 -0
  34. cfb_data/conferences/models/pydantic/responses.py +64 -0
  35. cfb_data/conferences/resource.py +175 -0
  36. cfb_data/draft/__init__.py +19 -0
  37. cfb_data/draft/models/__init__.py +1 -0
  38. cfb_data/draft/models/pydantic/__init__.py +12 -0
  39. cfb_data/draft/models/pydantic/requests.py +18 -0
  40. cfb_data/draft/models/pydantic/responses.py +65 -0
  41. cfb_data/draft/resource.py +132 -0
  42. cfb_data/drives/__init__.py +13 -0
  43. cfb_data/drives/models/__init__.py +1 -0
  44. cfb_data/drives/models/pydantic/__init__.py +17 -0
  45. cfb_data/drives/models/pydantic/requests.py +42 -0
  46. cfb_data/drives/models/pydantic/responses.py +46 -0
  47. cfb_data/drives/resource.py +94 -0
  48. cfb_data/enums.py +93 -0
  49. cfb_data/errors.py +234 -0
  50. cfb_data/games/__init__.py +42 -0
  51. cfb_data/games/models/__init__.py +1 -0
  52. cfb_data/games/models/pydantic/__init__.py +106 -0
  53. cfb_data/games/models/pydantic/requests.py +266 -0
  54. cfb_data/games/models/pydantic/responses.py +495 -0
  55. cfb_data/games/resource.py +486 -0
  56. cfb_data/info/__init__.py +25 -0
  57. cfb_data/info/models/__init__.py +1 -0
  58. cfb_data/info/models/pydantic/__init__.py +23 -0
  59. cfb_data/info/models/pydantic/requests.py +18 -0
  60. cfb_data/info/models/pydantic/responses.py +103 -0
  61. cfb_data/info/resource.py +88 -0
  62. cfb_data/metrics/__init__.py +53 -0
  63. cfb_data/metrics/models/__init__.py +1 -0
  64. cfb_data/metrics/models/pydantic/__init__.py +49 -0
  65. cfb_data/metrics/models/pydantic/requests.py +121 -0
  66. cfb_data/metrics/models/pydantic/responses.py +182 -0
  67. cfb_data/metrics/resource.py +371 -0
  68. cfb_data/players/__init__.py +44 -0
  69. cfb_data/players/models/__init__.py +1 -0
  70. cfb_data/players/models/pydantic/__init__.py +41 -0
  71. cfb_data/players/models/pydantic/requests.py +70 -0
  72. cfb_data/players/models/pydantic/responses.py +171 -0
  73. cfb_data/players/resource.py +258 -0
  74. cfb_data/playoffs/__init__.py +41 -0
  75. cfb_data/playoffs/models/__init__.py +1 -0
  76. cfb_data/playoffs/models/pydantic/__init__.py +37 -0
  77. cfb_data/playoffs/models/pydantic/requests.py +30 -0
  78. cfb_data/playoffs/models/pydantic/responses.py +173 -0
  79. cfb_data/playoffs/resource.py +149 -0
  80. cfb_data/plays/__init__.py +43 -0
  81. cfb_data/plays/models/__init__.py +1 -0
  82. cfb_data/plays/models/pydantic/__init__.py +35 -0
  83. cfb_data/plays/models/pydantic/requests.py +85 -0
  84. cfb_data/plays/models/pydantic/responses.py +231 -0
  85. cfb_data/plays/resource.py +249 -0
  86. cfb_data/py.typed +0 -0
  87. cfb_data/rankings/__init__.py +16 -0
  88. cfb_data/rankings/models/__init__.py +1 -0
  89. cfb_data/rankings/models/pydantic/__init__.py +6 -0
  90. cfb_data/rankings/models/pydantic/requests.py +36 -0
  91. cfb_data/rankings/models/pydantic/responses.py +42 -0
  92. cfb_data/rankings/resource.py +89 -0
  93. cfb_data/ratings/__init__.py +59 -0
  94. cfb_data/ratings/models/__init__.py +1 -0
  95. cfb_data/ratings/models/pydantic/__init__.py +55 -0
  96. cfb_data/ratings/models/pydantic/requests.py +87 -0
  97. cfb_data/ratings/models/pydantic/responses.py +215 -0
  98. cfb_data/ratings/resource.py +342 -0
  99. cfb_data/recruiting/__init__.py +26 -0
  100. cfb_data/recruiting/models/__init__.py +1 -0
  101. cfb_data/recruiting/models/pydantic/__init__.py +23 -0
  102. cfb_data/recruiting/models/pydantic/requests.py +69 -0
  103. cfb_data/recruiting/models/pydantic/responses.py +70 -0
  104. cfb_data/recruiting/resource.py +180 -0
  105. cfb_data/retry.py +49 -0
  106. cfb_data/stats/__init__.py +69 -0
  107. cfb_data/stats/models/__init__.py +1 -0
  108. cfb_data/stats/models/pydantic/__init__.py +65 -0
  109. cfb_data/stats/models/pydantic/requests.py +167 -0
  110. cfb_data/stats/models/pydantic/responses.py +291 -0
  111. cfb_data/stats/resource.py +400 -0
  112. cfb_data/teams/__init__.py +40 -0
  113. cfb_data/teams/models/__init__.py +1 -0
  114. cfb_data/teams/models/pydantic/__init__.py +26 -0
  115. cfb_data/teams/models/pydantic/requests.py +96 -0
  116. cfb_data/teams/models/pydantic/responses.py +116 -0
  117. cfb_data/teams/resource.py +270 -0
  118. cfb_data/venues/__init__.py +6 -0
  119. cfb_data/venues/models/__init__.py +1 -0
  120. cfb_data/venues/models/pydantic/__init__.py +5 -0
  121. cfb_data/venues/models/pydantic/responses.py +24 -0
  122. cfb_data/venues/resource.py +51 -0
  123. cfb_data-0.4.1.dist-info/METADATA +414 -0
  124. cfb_data-0.4.1.dist-info/RECORD +127 -0
  125. cfb_data-0.4.1.dist-info/WHEEL +5 -0
  126. cfb_data-0.4.1.dist-info/licenses/LICENSE +21 -0
  127. cfb_data-0.4.1.dist-info/top_level.txt +1 -0
cfb_data/__init__.py ADDED
@@ -0,0 +1,234 @@
1
+ """Access validated CollegeFootballData endpoints as pandas or Polars frames."""
2
+
3
+ from .adjusted_metrics.models.pydantic.requests import (
4
+ AdjustedPlayerPassingRequest,
5
+ AdjustedPlayerRushingRequest,
6
+ AdjustedTeamMetricsRequest,
7
+ KickerPAARRequest,
8
+ )
9
+ from .betting.models.pydantic.requests import BettingLinesRequest
10
+ from .client import CFBDClient, DataFrameBackend
11
+ from .coaches.models.pydantic.requests import (
12
+ CoachesRequest,
13
+ CoachProfileRequest,
14
+ CoachSeasonsRequest,
15
+ CoachTenuresRequest,
16
+ )
17
+ from .conferences.models.pydantic.requests import (
18
+ ConferenceAffiliationsRequest,
19
+ ConferenceChangesRequest,
20
+ ConferencesRequest,
21
+ )
22
+ from .conferences.models.pydantic.responses import ConferenceClassification
23
+ from .draft.models.pydantic.requests import DraftPicksRequest
24
+ from .drives.models.pydantic.requests import DrivesRequest
25
+ from .enums import (
26
+ Classification,
27
+ MediaType,
28
+ PlayoffCompetition,
29
+ PlayoffRound,
30
+ RankingPoll,
31
+ RecruitClassification,
32
+ SeasonType,
33
+ TransferEligibility,
34
+ UserUsageApi,
35
+ )
36
+ from .errors import (
37
+ CFBDAuthenticationError,
38
+ CFBDAuthorizationError,
39
+ CFBDClientStateError,
40
+ CFBDConfigurationError,
41
+ CFBDDataFrameConversionError,
42
+ CFBDError,
43
+ CFBDHTTPError,
44
+ CFBDOptionalDependencyError,
45
+ CFBDRateLimitError,
46
+ CFBDRequestValidationError,
47
+ CFBDResponseDecodeError,
48
+ CFBDResponseValidationError,
49
+ CFBDServerError,
50
+ CFBDTimeoutError,
51
+ CFBDTLSError,
52
+ CFBDTransportError,
53
+ )
54
+ from .games.models.pydantic.requests import (
55
+ AdvancedBoxScoreRequest,
56
+ CalendarRequest,
57
+ GameMediaRequest,
58
+ GamesRequest,
59
+ GameWeatherRequest,
60
+ PlayerGameStatsRequest,
61
+ RecordsRequest,
62
+ ScoreboardRequest,
63
+ TeamGameStatsRequest,
64
+ )
65
+ from .games.models.pydantic.responses import AdvancedBoxScore
66
+ from .info.models.pydantic.requests import InfoUsageRequest
67
+ from .info.models.pydantic.responses import UserInfo, UserUsage
68
+ from .metrics.models.pydantic.requests import (
69
+ PlayerGamePPARequest,
70
+ PlayerSeasonPPARequest,
71
+ PredictedPointsRequest,
72
+ PregameWinProbabilityRequest,
73
+ TeamGamePPARequest,
74
+ TeamSeasonPPARequest,
75
+ WinProbabilityRequest,
76
+ )
77
+ from .players.models.pydantic.requests import (
78
+ PlayerSearchRequest,
79
+ PlayerSeasonOverviewRequest,
80
+ PlayerUsageRequest,
81
+ ReturningProductionRequest,
82
+ TransferPortalRequest,
83
+ )
84
+ from .playoffs.models.pydantic.requests import (
85
+ CfpGamesRequest,
86
+ CfpParticipantsRequest,
87
+ CfpPlayoffRequest,
88
+ )
89
+ from .playoffs.models.pydantic.responses import CfpPlayoff
90
+ from .plays.models.pydantic.requests import (
91
+ LivePlaysRequest,
92
+ PlaysRequest,
93
+ PlayStatsRequest,
94
+ )
95
+ from .plays.models.pydantic.responses import DownType, HomeAway, LiveGame, RushPass
96
+ from .rankings.models.pydantic.requests import RankingsRequest
97
+ from .ratings.models.pydantic.requests import (
98
+ ConferenceSPRatingsRequest,
99
+ CoreRatingsRequest,
100
+ EloRatingsRequest,
101
+ ExpandedSRSRatingsRequest,
102
+ FPIRatingsRequest,
103
+ SPRatingsRequest,
104
+ SRSRatingsRequest,
105
+ )
106
+ from .recruiting.models.pydantic.requests import (
107
+ RecruitingGroupsRequest,
108
+ RecruitingPlayersRequest,
109
+ RecruitingTeamsRequest,
110
+ )
111
+ from .retry import RetryPolicy
112
+ from .stats.models.pydantic.requests import (
113
+ AdvancedGameStatsRequest,
114
+ AdvancedSeasonStatsRequest,
115
+ GameHavocRequest,
116
+ PlayerGameSuccessRequest,
117
+ PlayerSeasonStatsRequest,
118
+ PlayerSeasonSuccessRequest,
119
+ TeamSeasonStatsRequest,
120
+ )
121
+ from .teams.models.pydantic.requests import (
122
+ FBSTeamsRequest,
123
+ RosterRequest,
124
+ TalentRequest,
125
+ TeamATSRequest,
126
+ TeamMatchupRequest,
127
+ TeamsRequest,
128
+ )
129
+
130
+ __all__ = [
131
+ "AdjustedPlayerPassingRequest",
132
+ "AdjustedPlayerRushingRequest",
133
+ "AdjustedTeamMetricsRequest",
134
+ "AdvancedBoxScoreRequest",
135
+ "AdvancedBoxScore",
136
+ "AdvancedGameStatsRequest",
137
+ "AdvancedSeasonStatsRequest",
138
+ "BettingLinesRequest",
139
+ "CalendarRequest",
140
+ "CfpGamesRequest",
141
+ "CfpParticipantsRequest",
142
+ "CfpPlayoff",
143
+ "CfpPlayoffRequest",
144
+ "CFBDAuthenticationError",
145
+ "CFBDAuthorizationError",
146
+ "CFBDClient",
147
+ "CFBDClientStateError",
148
+ "CFBDConfigurationError",
149
+ "CFBDDataFrameConversionError",
150
+ "CFBDError",
151
+ "CFBDHTTPError",
152
+ "CFBDOptionalDependencyError",
153
+ "CFBDRateLimitError",
154
+ "CFBDRequestValidationError",
155
+ "CFBDResponseDecodeError",
156
+ "CFBDResponseValidationError",
157
+ "CFBDServerError",
158
+ "CFBDTimeoutError",
159
+ "CFBDTLSError",
160
+ "CFBDTransportError",
161
+ "Classification",
162
+ "CoachesRequest",
163
+ "CoachProfileRequest",
164
+ "CoachSeasonsRequest",
165
+ "CoachTenuresRequest",
166
+ "ConferenceAffiliationsRequest",
167
+ "ConferenceChangesRequest",
168
+ "ConferenceClassification",
169
+ "ConferencesRequest",
170
+ "ConferenceSPRatingsRequest",
171
+ "CoreRatingsRequest",
172
+ "DataFrameBackend",
173
+ "DraftPicksRequest",
174
+ "DrivesRequest",
175
+ "EloRatingsRequest",
176
+ "ExpandedSRSRatingsRequest",
177
+ "FPIRatingsRequest",
178
+ "GameMediaRequest",
179
+ "GamesRequest",
180
+ "GameWeatherRequest",
181
+ "GameHavocRequest",
182
+ "FBSTeamsRequest",
183
+ "HomeAway",
184
+ "InfoUsageRequest",
185
+ "KickerPAARRequest",
186
+ "LiveGame",
187
+ "LivePlaysRequest",
188
+ "MediaType",
189
+ "PlayerGameStatsRequest",
190
+ "PlayerGamePPARequest",
191
+ "PlayerGameSuccessRequest",
192
+ "PlayoffCompetition",
193
+ "PlayoffRound",
194
+ "PlaysRequest",
195
+ "PlayStatsRequest",
196
+ "PlayerSeasonStatsRequest",
197
+ "PlayerSearchRequest",
198
+ "PlayerSeasonOverviewRequest",
199
+ "PlayerSeasonPPARequest",
200
+ "PlayerSeasonSuccessRequest",
201
+ "PlayerUsageRequest",
202
+ "PredictedPointsRequest",
203
+ "PregameWinProbabilityRequest",
204
+ "RecordsRequest",
205
+ "RankingPoll",
206
+ "RankingsRequest",
207
+ "RecruitClassification",
208
+ "RecruitingGroupsRequest",
209
+ "RecruitingPlayersRequest",
210
+ "RecruitingTeamsRequest",
211
+ "RetryPolicy",
212
+ "RosterRequest",
213
+ "ReturningProductionRequest",
214
+ "RushPass",
215
+ "ScoreboardRequest",
216
+ "SeasonType",
217
+ "SPRatingsRequest",
218
+ "SRSRatingsRequest",
219
+ "TeamGamePPARequest",
220
+ "TeamGameStatsRequest",
221
+ "TeamSeasonStatsRequest",
222
+ "TeamSeasonPPARequest",
223
+ "TalentRequest",
224
+ "TeamATSRequest",
225
+ "TeamMatchupRequest",
226
+ "TeamsRequest",
227
+ "TransferPortalRequest",
228
+ "TransferEligibility",
229
+ "UserInfo",
230
+ "UserUsage",
231
+ "UserUsageApi",
232
+ "WinProbabilityRequest",
233
+ "DownType",
234
+ ]
@@ -0,0 +1,279 @@
1
+ """Materialize canonical Arrow tables as pandas or Polars DataFrames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import TYPE_CHECKING, Protocol, TypeVar
7
+
8
+ import pandas as pd
9
+ import pyarrow as pa
10
+ from pydantic import BaseModel
11
+
12
+ from cfb_data._tabular import (
13
+ _arrow_table_from_models,
14
+ _assert_canonical_arrow_table,
15
+ _logical_records_from_arrow_table,
16
+ _logical_schema,
17
+ _LogicalType,
18
+ )
19
+ from cfb_data.errors import (
20
+ CFBDDataFrameConversionError,
21
+ CFBDOptionalDependencyError,
22
+ _sanitized_cause,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ import polars as pl
27
+
28
+ _FrameT_co = TypeVar("_FrameT_co", covariant=True)
29
+
30
+
31
+ class _DataFrameAdapter(Protocol[_FrameT_co]):
32
+ """Convert canonical tabular values to one eager DataFrame type."""
33
+
34
+ def from_models(
35
+ self,
36
+ *,
37
+ endpoint: str,
38
+ row_model: type[BaseModel],
39
+ models: Sequence[BaseModel],
40
+ ) -> _FrameT_co:
41
+ """Return a frame preserving all validated rows and columns."""
42
+
43
+ def from_table(
44
+ self,
45
+ *,
46
+ endpoint: str,
47
+ row_model: type[BaseModel],
48
+ table: pa.Table,
49
+ ) -> _FrameT_co:
50
+ """Return a frame materialized from a canonical Arrow table."""
51
+
52
+
53
+ class _PandasAdapter:
54
+ """Create pandas DataFrames with explicit native and nullable dtypes."""
55
+
56
+ def from_models(
57
+ self,
58
+ *,
59
+ endpoint: str,
60
+ row_model: type[BaseModel],
61
+ models: Sequence[BaseModel],
62
+ ) -> pd.DataFrame:
63
+ """Return a pandas frame for validated model rows.
64
+
65
+ :param endpoint: Endpoint producing the rows.
66
+ :param row_model: Authoritative Pydantic row model.
67
+ :param models: Validated rows in API order.
68
+ :return: DataFrame with a normal :class:`pandas.RangeIndex`.
69
+ :raises CFBDDataFrameConversionError: If conversion loses the contract.
70
+ """
71
+ try:
72
+ table = _arrow_table_from_models(row_model=row_model, models=models)
73
+ return self.from_table(
74
+ endpoint=endpoint,
75
+ row_model=row_model,
76
+ table=table,
77
+ )
78
+ except CFBDDataFrameConversionError:
79
+ raise
80
+ except Exception as exc:
81
+ safe_cause = _sanitized_cause(exc)
82
+ raise CFBDDataFrameConversionError(
83
+ endpoint=endpoint,
84
+ backend="pandas",
85
+ ) from safe_cause
86
+
87
+ def from_table(
88
+ self,
89
+ *,
90
+ endpoint: str,
91
+ row_model: type[BaseModel],
92
+ table: pa.Table,
93
+ ) -> pd.DataFrame:
94
+ """Return a pandas frame from a canonical Arrow table.
95
+
96
+ :param endpoint: Endpoint associated with the table.
97
+ :param row_model: Authoritative Pydantic row model.
98
+ :param table: Canonical Arrow table in source row order.
99
+ :return: DataFrame with explicit pandas dtypes and nested objects.
100
+ :raises CFBDDataFrameConversionError: If conversion loses the contract.
101
+ """
102
+ try:
103
+ schema = _logical_schema(row_model)
104
+ records = _logical_records_from_arrow_table(
105
+ row_model=row_model,
106
+ table=table,
107
+ )
108
+ columns = {
109
+ field.name: pd.Series(
110
+ [record[field.name] for record in records],
111
+ dtype=_pandas_dtype(field.type),
112
+ )
113
+ for field in schema.fields
114
+ }
115
+ frame = pd.DataFrame(columns)
116
+ _assert_frame_shape(
117
+ columns=list(frame.columns),
118
+ row_count=len(frame),
119
+ expected_columns=[field.name for field in schema.fields],
120
+ expected_rows=table.num_rows,
121
+ )
122
+ if not frame.index.equals(pd.RangeIndex(table.num_rows)):
123
+ raise ValueError("pandas conversion did not preserve a RangeIndex")
124
+ return frame
125
+ except CFBDDataFrameConversionError:
126
+ raise
127
+ except Exception as exc:
128
+ safe_cause = _sanitized_cause(exc)
129
+ raise CFBDDataFrameConversionError(
130
+ endpoint=endpoint,
131
+ backend="pandas",
132
+ ) from safe_cause
133
+
134
+
135
+ class _PolarsAdapter:
136
+ """Create strict Polars DataFrames with native nested columns."""
137
+
138
+ def from_models(
139
+ self,
140
+ *,
141
+ endpoint: str,
142
+ row_model: type[BaseModel],
143
+ models: Sequence[BaseModel],
144
+ ) -> pl.DataFrame:
145
+ """Return a Polars frame for validated model rows.
146
+
147
+ :param endpoint: Endpoint producing the rows.
148
+ :param row_model: Authoritative Pydantic row model.
149
+ :param models: Validated rows in API order.
150
+ :return: Strict eager Polars DataFrame.
151
+ :raises CFBDOptionalDependencyError: If Polars is not installed.
152
+ :raises CFBDDataFrameConversionError: If conversion loses the contract.
153
+ """
154
+ self._require_polars()
155
+ try:
156
+ table = _arrow_table_from_models(row_model=row_model, models=models)
157
+ return self.from_table(
158
+ endpoint=endpoint,
159
+ row_model=row_model,
160
+ table=table,
161
+ )
162
+ except CFBDDataFrameConversionError:
163
+ raise
164
+ except Exception as exc:
165
+ safe_cause = _sanitized_cause(exc)
166
+ raise CFBDDataFrameConversionError(
167
+ endpoint=endpoint,
168
+ backend="polars",
169
+ ) from safe_cause
170
+
171
+ def from_table(
172
+ self,
173
+ *,
174
+ endpoint: str,
175
+ row_model: type[BaseModel],
176
+ table: pa.Table,
177
+ ) -> pl.DataFrame:
178
+ """Return a Polars frame from a canonical Arrow table.
179
+
180
+ :param endpoint: Endpoint associated with the table.
181
+ :param row_model: Authoritative Pydantic row model.
182
+ :param table: Canonical Arrow table in source row order.
183
+ :return: DataFrame with Arrow-native nesting and decoded mixed scalars.
184
+ :raises CFBDOptionalDependencyError: If Polars is not installed.
185
+ :raises CFBDDataFrameConversionError: If conversion loses the contract.
186
+ """
187
+ try:
188
+ import polars as pl
189
+ except ModuleNotFoundError as exc:
190
+ if exc.name == "polars":
191
+ raise CFBDOptionalDependencyError(
192
+ 'Polars support requires pip install "cfb-data[polars]"'
193
+ ) from exc
194
+ raise
195
+
196
+ try:
197
+ schema = _logical_schema(row_model)
198
+ _assert_canonical_arrow_table(row_model=row_model, table=table)
199
+ frame = pl.from_arrow(table, rechunk=True)
200
+ if not isinstance(frame, pl.DataFrame):
201
+ raise TypeError("Arrow table did not produce a Polars DataFrame")
202
+ scalar_fields = [
203
+ (index, field)
204
+ for index, field in enumerate(schema.fields)
205
+ if field.type.kind == "scalar"
206
+ ]
207
+ if scalar_fields:
208
+ records = _logical_records_from_arrow_table(
209
+ row_model=row_model,
210
+ table=table,
211
+ )
212
+ for index, field in scalar_fields:
213
+ frame.replace_column(
214
+ index,
215
+ pl.Series(
216
+ field.name,
217
+ [record[field.name] for record in records],
218
+ dtype=pl.Object,
219
+ strict=True,
220
+ ),
221
+ )
222
+ _assert_frame_shape(
223
+ columns=frame.columns,
224
+ row_count=frame.height,
225
+ expected_columns=[field.name for field in schema.fields],
226
+ expected_rows=table.num_rows,
227
+ )
228
+ return frame
229
+ except CFBDOptionalDependencyError:
230
+ raise
231
+ except Exception as exc:
232
+ safe_cause = _sanitized_cause(exc)
233
+ raise CFBDDataFrameConversionError(
234
+ endpoint=endpoint,
235
+ backend="polars",
236
+ ) from safe_cause
237
+
238
+ @staticmethod
239
+ def _require_polars() -> None:
240
+ """Fail with install guidance before doing backend-independent work."""
241
+ try:
242
+ import polars # noqa: F401
243
+ except ModuleNotFoundError as exc:
244
+ if exc.name == "polars":
245
+ raise CFBDOptionalDependencyError(
246
+ 'Polars support requires pip install "cfb-data[polars]"'
247
+ ) from exc
248
+ raise
249
+
250
+
251
+ def _pandas_dtype(logical_type: _LogicalType) -> object:
252
+ """Return the exact pandas dtype for a logical type."""
253
+ if logical_type.kind == "integer":
254
+ return "Int64" if logical_type.nullable else "int64"
255
+ if logical_type.kind == "float":
256
+ return "Float64" if logical_type.nullable else "float64"
257
+ if logical_type.kind == "boolean":
258
+ return "boolean" if logical_type.nullable else "bool"
259
+ if logical_type.kind == "string":
260
+ return "string"
261
+ if logical_type.kind == "datetime":
262
+ return "datetime64[ns, UTC]"
263
+ if logical_type.kind in {"scalar", "struct", "list"}:
264
+ return object
265
+ raise AssertionError(f"Unreachable logical kind: {logical_type.kind}")
266
+
267
+
268
+ def _assert_frame_shape(
269
+ *,
270
+ columns: Sequence[str],
271
+ row_count: int,
272
+ expected_columns: Sequence[str],
273
+ expected_rows: int,
274
+ ) -> None:
275
+ """Reject conversions that changed column order or lost rows."""
276
+ if list(columns) != list(expected_columns):
277
+ raise ValueError("DataFrame columns do not match the logical schema")
278
+ if row_count != expected_rows:
279
+ raise ValueError("DataFrame row count does not match validated rows")
cfb_data/_executor.py ADDED
@@ -0,0 +1,129 @@
1
+ """Execute endpoints through request serialization and Pydantic validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypeVar
6
+
7
+ from pydantic import BaseModel, TypeAdapter, ValidationError
8
+
9
+ from cfb_data._transport import _HTTPTransport
10
+ from cfb_data.base.types import (
11
+ json_list,
12
+ json_object,
13
+ json_object_list,
14
+ query_parameters,
15
+ )
16
+ from cfb_data.errors import (
17
+ CFBDRequestValidationError,
18
+ CFBDResponseValidationError,
19
+ _sanitized_cause,
20
+ )
21
+
22
+ _ModelT = TypeVar("_ModelT", bound=BaseModel)
23
+ _ValueT = TypeVar("_ValueT")
24
+
25
+
26
+ class _EndpointExecutor:
27
+ """Return validated models without depending on DataFrame presentation."""
28
+
29
+ def __init__(self, transport: _HTTPTransport) -> None:
30
+ """Bind endpoint execution to one owned transport.
31
+
32
+ :param transport: Context-managed transport used by all endpoint calls.
33
+ """
34
+ self._transport = transport
35
+
36
+ async def fetch_many(
37
+ self,
38
+ *,
39
+ endpoint: str,
40
+ request: BaseModel,
41
+ response_adapter: TypeAdapter[list[_ModelT]],
42
+ ) -> list[_ModelT]:
43
+ """Fetch and validate a list response in API order.
44
+
45
+ :param endpoint: Fixed endpoint path.
46
+ :param request: Validated endpoint request model.
47
+ :param response_adapter: Typed list-response validator.
48
+ :return: Validated models in upstream row order.
49
+ :raises CFBDResponseValidationError: If response shape or values fail.
50
+ """
51
+ raw = await self._transport.get_json(
52
+ endpoint,
53
+ _serialize_request(endpoint, request),
54
+ )
55
+ try:
56
+ payload = json_object_list(raw)
57
+ return response_adapter.validate_python(payload)
58
+ except (TypeError, ValidationError) as exc:
59
+ safe_cause = _sanitized_cause(exc)
60
+ raise CFBDResponseValidationError(endpoint=endpoint) from safe_cause
61
+
62
+ async def fetch_one(
63
+ self,
64
+ *,
65
+ endpoint: str,
66
+ request: BaseModel,
67
+ response_adapter: TypeAdapter[_ModelT],
68
+ ) -> _ModelT:
69
+ """Fetch and validate one model response.
70
+
71
+ :param endpoint: Fixed endpoint path.
72
+ :param request: Validated endpoint request model.
73
+ :param response_adapter: Typed object-response validator.
74
+ :return: Validated response model.
75
+ :raises CFBDResponseValidationError: If response shape or values fail.
76
+ """
77
+ raw = await self._transport.get_json(
78
+ endpoint,
79
+ _serialize_request(endpoint, request),
80
+ )
81
+ try:
82
+ payload = json_object(raw)
83
+ return response_adapter.validate_python(payload)
84
+ except (TypeError, ValidationError) as exc:
85
+ safe_cause = _sanitized_cause(exc)
86
+ raise CFBDResponseValidationError(endpoint=endpoint) from safe_cause
87
+
88
+ async def fetch_values(
89
+ self,
90
+ *,
91
+ endpoint: str,
92
+ request: BaseModel,
93
+ response_adapter: TypeAdapter[list[_ValueT]],
94
+ ) -> list[_ValueT]:
95
+ """Fetch and validate a JSON-array response in API order.
96
+
97
+ :param endpoint: Fixed endpoint path.
98
+ :param request: Validated endpoint request model.
99
+ :param response_adapter: Typed array-response validator.
100
+ :return: Validated values in upstream order.
101
+ :raises CFBDResponseValidationError: If response shape or values fail.
102
+ """
103
+ raw = await self._transport.get_json(
104
+ endpoint,
105
+ _serialize_request(endpoint, request),
106
+ )
107
+ try:
108
+ payload = json_list(raw)
109
+ return response_adapter.validate_python(payload)
110
+ except (TypeError, ValidationError) as exc:
111
+ safe_cause = _sanitized_cause(exc)
112
+ raise CFBDResponseValidationError(endpoint=endpoint) from safe_cause
113
+
114
+
115
+ def _serialize_request(
116
+ endpoint: str, request: BaseModel
117
+ ) -> dict[str, str | int | float | bool]:
118
+ """Serialize one validated request using its upstream aliases."""
119
+ try:
120
+ parameters = query_parameters(
121
+ request.model_dump(mode="json", by_alias=True, exclude_none=True)
122
+ )
123
+ return {
124
+ key: str(value).lower() if isinstance(value, bool) else value
125
+ for key, value in parameters.items()
126
+ }
127
+ except TypeError as exc:
128
+ safe_cause = _sanitized_cause(exc)
129
+ raise CFBDRequestValidationError(endpoint=endpoint) from safe_cause