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.
- cfb_data/__init__.py +234 -0
- cfb_data/_dataframes.py +279 -0
- cfb_data/_executor.py +129 -0
- cfb_data/_parquet.py +197 -0
- cfb_data/_request_rules.py +41 -0
- cfb_data/_requests.py +36 -0
- cfb_data/_tabular.py +676 -0
- cfb_data/_transport.py +452 -0
- cfb_data/adjusted_metrics/__init__.py +29 -0
- cfb_data/adjusted_metrics/models/__init__.py +1 -0
- cfb_data/adjusted_metrics/models/pydantic/__init__.py +29 -0
- cfb_data/adjusted_metrics/models/pydantic/requests.py +45 -0
- cfb_data/adjusted_metrics/models/pydantic/responses.py +88 -0
- cfb_data/adjusted_metrics/resource.py +220 -0
- cfb_data/base/__init__.py +6 -0
- cfb_data/base/types.py +112 -0
- cfb_data/betting/__init__.py +15 -0
- cfb_data/betting/models/__init__.py +1 -0
- cfb_data/betting/models/pydantic/__init__.py +6 -0
- cfb_data/betting/models/pydantic/requests.py +35 -0
- cfb_data/betting/models/pydantic/responses.py +62 -0
- cfb_data/betting/resource.py +91 -0
- cfb_data/client.py +305 -0
- cfb_data/coaches/__init__.py +57 -0
- cfb_data/coaches/models/__init__.py +1 -0
- cfb_data/coaches/models/pydantic/__init__.py +57 -0
- cfb_data/coaches/models/pydantic/requests.py +75 -0
- cfb_data/coaches/models/pydantic/responses.py +261 -0
- cfb_data/coaches/resource.py +216 -0
- cfb_data/conferences/__init__.py +23 -0
- cfb_data/conferences/models/__init__.py +1 -0
- cfb_data/conferences/models/pydantic/__init__.py +23 -0
- cfb_data/conferences/models/pydantic/requests.py +69 -0
- cfb_data/conferences/models/pydantic/responses.py +64 -0
- cfb_data/conferences/resource.py +175 -0
- cfb_data/draft/__init__.py +19 -0
- cfb_data/draft/models/__init__.py +1 -0
- cfb_data/draft/models/pydantic/__init__.py +12 -0
- cfb_data/draft/models/pydantic/requests.py +18 -0
- cfb_data/draft/models/pydantic/responses.py +65 -0
- cfb_data/draft/resource.py +132 -0
- cfb_data/drives/__init__.py +13 -0
- cfb_data/drives/models/__init__.py +1 -0
- cfb_data/drives/models/pydantic/__init__.py +17 -0
- cfb_data/drives/models/pydantic/requests.py +42 -0
- cfb_data/drives/models/pydantic/responses.py +46 -0
- cfb_data/drives/resource.py +94 -0
- cfb_data/enums.py +93 -0
- cfb_data/errors.py +234 -0
- cfb_data/games/__init__.py +42 -0
- cfb_data/games/models/__init__.py +1 -0
- cfb_data/games/models/pydantic/__init__.py +106 -0
- cfb_data/games/models/pydantic/requests.py +266 -0
- cfb_data/games/models/pydantic/responses.py +495 -0
- cfb_data/games/resource.py +486 -0
- cfb_data/info/__init__.py +25 -0
- cfb_data/info/models/__init__.py +1 -0
- cfb_data/info/models/pydantic/__init__.py +23 -0
- cfb_data/info/models/pydantic/requests.py +18 -0
- cfb_data/info/models/pydantic/responses.py +103 -0
- cfb_data/info/resource.py +88 -0
- cfb_data/metrics/__init__.py +53 -0
- cfb_data/metrics/models/__init__.py +1 -0
- cfb_data/metrics/models/pydantic/__init__.py +49 -0
- cfb_data/metrics/models/pydantic/requests.py +121 -0
- cfb_data/metrics/models/pydantic/responses.py +182 -0
- cfb_data/metrics/resource.py +371 -0
- cfb_data/players/__init__.py +44 -0
- cfb_data/players/models/__init__.py +1 -0
- cfb_data/players/models/pydantic/__init__.py +41 -0
- cfb_data/players/models/pydantic/requests.py +70 -0
- cfb_data/players/models/pydantic/responses.py +171 -0
- cfb_data/players/resource.py +258 -0
- cfb_data/playoffs/__init__.py +41 -0
- cfb_data/playoffs/models/__init__.py +1 -0
- cfb_data/playoffs/models/pydantic/__init__.py +37 -0
- cfb_data/playoffs/models/pydantic/requests.py +30 -0
- cfb_data/playoffs/models/pydantic/responses.py +173 -0
- cfb_data/playoffs/resource.py +149 -0
- cfb_data/plays/__init__.py +43 -0
- cfb_data/plays/models/__init__.py +1 -0
- cfb_data/plays/models/pydantic/__init__.py +35 -0
- cfb_data/plays/models/pydantic/requests.py +85 -0
- cfb_data/plays/models/pydantic/responses.py +231 -0
- cfb_data/plays/resource.py +249 -0
- cfb_data/py.typed +0 -0
- cfb_data/rankings/__init__.py +16 -0
- cfb_data/rankings/models/__init__.py +1 -0
- cfb_data/rankings/models/pydantic/__init__.py +6 -0
- cfb_data/rankings/models/pydantic/requests.py +36 -0
- cfb_data/rankings/models/pydantic/responses.py +42 -0
- cfb_data/rankings/resource.py +89 -0
- cfb_data/ratings/__init__.py +59 -0
- cfb_data/ratings/models/__init__.py +1 -0
- cfb_data/ratings/models/pydantic/__init__.py +55 -0
- cfb_data/ratings/models/pydantic/requests.py +87 -0
- cfb_data/ratings/models/pydantic/responses.py +215 -0
- cfb_data/ratings/resource.py +342 -0
- cfb_data/recruiting/__init__.py +26 -0
- cfb_data/recruiting/models/__init__.py +1 -0
- cfb_data/recruiting/models/pydantic/__init__.py +23 -0
- cfb_data/recruiting/models/pydantic/requests.py +69 -0
- cfb_data/recruiting/models/pydantic/responses.py +70 -0
- cfb_data/recruiting/resource.py +180 -0
- cfb_data/retry.py +49 -0
- cfb_data/stats/__init__.py +69 -0
- cfb_data/stats/models/__init__.py +1 -0
- cfb_data/stats/models/pydantic/__init__.py +65 -0
- cfb_data/stats/models/pydantic/requests.py +167 -0
- cfb_data/stats/models/pydantic/responses.py +291 -0
- cfb_data/stats/resource.py +400 -0
- cfb_data/teams/__init__.py +40 -0
- cfb_data/teams/models/__init__.py +1 -0
- cfb_data/teams/models/pydantic/__init__.py +26 -0
- cfb_data/teams/models/pydantic/requests.py +96 -0
- cfb_data/teams/models/pydantic/responses.py +116 -0
- cfb_data/teams/resource.py +270 -0
- cfb_data/venues/__init__.py +6 -0
- cfb_data/venues/models/__init__.py +1 -0
- cfb_data/venues/models/pydantic/__init__.py +5 -0
- cfb_data/venues/models/pydantic/responses.py +24 -0
- cfb_data/venues/resource.py +51 -0
- cfb_data-0.4.1.dist-info/METADATA +414 -0
- cfb_data-0.4.1.dist-info/RECORD +127 -0
- cfb_data-0.4.1.dist-info/WHEEL +5 -0
- cfb_data-0.4.1.dist-info/licenses/LICENSE +21 -0
- cfb_data-0.4.1.dist-info/top_level.txt +1 -0
cfb_data/_transport.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""Own HTTP resources and retry policy for the public client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import random
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from email.utils import parsedate_to_datetime
|
|
15
|
+
from enum import Enum, auto
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
import aiohttp
|
|
19
|
+
|
|
20
|
+
from cfb_data.base.types import QueryParameters
|
|
21
|
+
from cfb_data.errors import (
|
|
22
|
+
CFBDAuthenticationError,
|
|
23
|
+
CFBDAuthorizationError,
|
|
24
|
+
CFBDClientStateError,
|
|
25
|
+
CFBDConfigurationError,
|
|
26
|
+
CFBDHTTPError,
|
|
27
|
+
CFBDRateLimitError,
|
|
28
|
+
CFBDResponseDecodeError,
|
|
29
|
+
CFBDServerError,
|
|
30
|
+
CFBDTimeoutError,
|
|
31
|
+
CFBDTLSError,
|
|
32
|
+
CFBDTransportError,
|
|
33
|
+
_sanitized_cause,
|
|
34
|
+
_SanitizedCause,
|
|
35
|
+
)
|
|
36
|
+
from cfb_data.retry import RetryPolicy
|
|
37
|
+
|
|
38
|
+
_LOGGER = logging.getLogger(__name__)
|
|
39
|
+
_RETRYABLE_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _TransportState(Enum):
|
|
43
|
+
"""Track the one-shot session lifecycle."""
|
|
44
|
+
|
|
45
|
+
new = auto()
|
|
46
|
+
active = auto()
|
|
47
|
+
closed = auto()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class _RetryDecision:
|
|
52
|
+
"""Carry a retry outside the response context so its connection is released."""
|
|
53
|
+
|
|
54
|
+
error: CFBDHTTPError
|
|
55
|
+
delay_seconds: float
|
|
56
|
+
category: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class _TransportFailure:
|
|
61
|
+
"""Carry a safely detached transport failure outside its exception handler."""
|
|
62
|
+
|
|
63
|
+
error: CFBDTransportError
|
|
64
|
+
cause: _SanitizedCause
|
|
65
|
+
category: str
|
|
66
|
+
retryable: bool
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _HTTPTransport:
|
|
70
|
+
"""Own one reusable :class:`aiohttp.ClientSession` and its connection pool."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
api_key: str,
|
|
76
|
+
base_url: str,
|
|
77
|
+
timeout_seconds: float,
|
|
78
|
+
retry_policy: RetryPolicy,
|
|
79
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
80
|
+
random_source: Callable[[], float] = random.random,
|
|
81
|
+
utc_now: Callable[[], datetime] | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Initialize transport configuration without opening resources.
|
|
84
|
+
|
|
85
|
+
:param api_key: Non-empty CFBD bearer token.
|
|
86
|
+
:param base_url: Validated API origin and optional base path.
|
|
87
|
+
:param timeout_seconds: Finite timeout applied to each attempt.
|
|
88
|
+
:param retry_policy: Bounded GET retry configuration.
|
|
89
|
+
:param sleep: Awaitable delay function used by retries.
|
|
90
|
+
:param random_source: Uniform random source in the inclusive range 0..1.
|
|
91
|
+
:param utc_now: Clock used to interpret HTTP-date ``Retry-After`` values.
|
|
92
|
+
"""
|
|
93
|
+
self._api_key = api_key
|
|
94
|
+
self._base_url = base_url
|
|
95
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout_seconds)
|
|
96
|
+
self._retry_policy = retry_policy
|
|
97
|
+
self._sleep = sleep
|
|
98
|
+
self._random_source = random_source
|
|
99
|
+
self._utc_now = utc_now or (lambda: datetime.now(UTC))
|
|
100
|
+
self._state = _TransportState.new
|
|
101
|
+
self._session: aiohttp.ClientSession | None = None
|
|
102
|
+
|
|
103
|
+
async def open(self) -> None:
|
|
104
|
+
"""Create the one session owned by this transport.
|
|
105
|
+
|
|
106
|
+
:raises CFBDClientStateError: If the one-shot transport is not new.
|
|
107
|
+
"""
|
|
108
|
+
if self._state is not _TransportState.new:
|
|
109
|
+
raise CFBDClientStateError(
|
|
110
|
+
"CFBDClient can be entered exactly once and cannot be nested"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
session = aiohttp.ClientSession(
|
|
115
|
+
headers={
|
|
116
|
+
"Accept": "application/json",
|
|
117
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
118
|
+
},
|
|
119
|
+
timeout=self._timeout,
|
|
120
|
+
trust_env=False,
|
|
121
|
+
)
|
|
122
|
+
except Exception:
|
|
123
|
+
self._state = _TransportState.closed
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
self._session = session
|
|
127
|
+
self._state = _TransportState.active
|
|
128
|
+
|
|
129
|
+
async def close(self) -> None:
|
|
130
|
+
"""Close the owned session and permanently retire this transport.
|
|
131
|
+
|
|
132
|
+
:raises CFBDClientStateError: If no active context owns a session.
|
|
133
|
+
"""
|
|
134
|
+
if self._state is not _TransportState.active or self._session is None:
|
|
135
|
+
raise CFBDClientStateError("CFBDClient has no active session to close")
|
|
136
|
+
|
|
137
|
+
session = self._session
|
|
138
|
+
self._session = None
|
|
139
|
+
self._state = _TransportState.closed
|
|
140
|
+
await session.close()
|
|
141
|
+
|
|
142
|
+
async def get_json(
|
|
143
|
+
self,
|
|
144
|
+
endpoint: str,
|
|
145
|
+
params: QueryParameters,
|
|
146
|
+
) -> object:
|
|
147
|
+
"""Return one decoded JSON response using bounded safe-GET retries.
|
|
148
|
+
|
|
149
|
+
:param endpoint: Fixed endpoint path without query parameters.
|
|
150
|
+
:param params: Validated scalar query parameters.
|
|
151
|
+
:return: Value produced by the response JSON decoder.
|
|
152
|
+
:raises CFBDClientStateError: If used outside its active context.
|
|
153
|
+
:raises CFBDHTTPError: If the API returns an unsuccessful status.
|
|
154
|
+
:raises CFBDTransportError: If transport attempts are exhausted.
|
|
155
|
+
:raises CFBDResponseDecodeError: If a response is not valid JSON.
|
|
156
|
+
"""
|
|
157
|
+
session = self._active_session()
|
|
158
|
+
url = f"{self._base_url}{endpoint}"
|
|
159
|
+
|
|
160
|
+
for attempt in range(1, self._retry_policy.max_attempts + 1):
|
|
161
|
+
failure: _TransportFailure | None = None
|
|
162
|
+
try:
|
|
163
|
+
result = await self._request_once(
|
|
164
|
+
session=session,
|
|
165
|
+
url=url,
|
|
166
|
+
endpoint=endpoint,
|
|
167
|
+
params=params,
|
|
168
|
+
attempt=attempt,
|
|
169
|
+
)
|
|
170
|
+
except asyncio.CancelledError:
|
|
171
|
+
raise
|
|
172
|
+
except aiohttp.InvalidURL as exc:
|
|
173
|
+
failure = _TransportFailure(
|
|
174
|
+
error=CFBDTransportError(
|
|
175
|
+
endpoint=endpoint,
|
|
176
|
+
attempts=attempt,
|
|
177
|
+
category="invalid_url",
|
|
178
|
+
),
|
|
179
|
+
cause=_sanitized_cause(exc),
|
|
180
|
+
category="invalid_url",
|
|
181
|
+
retryable=False,
|
|
182
|
+
)
|
|
183
|
+
except aiohttp.ClientSSLError as exc:
|
|
184
|
+
failure = _TransportFailure(
|
|
185
|
+
error=CFBDTLSError(endpoint=endpoint, attempts=attempt),
|
|
186
|
+
cause=_sanitized_cause(exc),
|
|
187
|
+
category="tls",
|
|
188
|
+
retryable=False,
|
|
189
|
+
)
|
|
190
|
+
except TimeoutError as exc:
|
|
191
|
+
failure = _TransportFailure(
|
|
192
|
+
error=CFBDTimeoutError(endpoint=endpoint, attempts=attempt),
|
|
193
|
+
cause=_sanitized_cause(exc),
|
|
194
|
+
category="timeout",
|
|
195
|
+
retryable=True,
|
|
196
|
+
)
|
|
197
|
+
except aiohttp.ClientPayloadError as exc:
|
|
198
|
+
failure = _TransportFailure(
|
|
199
|
+
error=CFBDTransportError(
|
|
200
|
+
endpoint=endpoint,
|
|
201
|
+
attempts=attempt,
|
|
202
|
+
category="truncated_payload",
|
|
203
|
+
),
|
|
204
|
+
cause=_sanitized_cause(exc),
|
|
205
|
+
category="truncated_payload",
|
|
206
|
+
retryable=True,
|
|
207
|
+
)
|
|
208
|
+
except aiohttp.ClientConnectionError as exc:
|
|
209
|
+
failure = _TransportFailure(
|
|
210
|
+
error=CFBDTransportError(
|
|
211
|
+
endpoint=endpoint,
|
|
212
|
+
attempts=attempt,
|
|
213
|
+
category="connection",
|
|
214
|
+
),
|
|
215
|
+
cause=_sanitized_cause(exc),
|
|
216
|
+
category="connection",
|
|
217
|
+
retryable=True,
|
|
218
|
+
)
|
|
219
|
+
except aiohttp.ClientError as exc:
|
|
220
|
+
failure = _TransportFailure(
|
|
221
|
+
error=CFBDTransportError(
|
|
222
|
+
endpoint=endpoint,
|
|
223
|
+
attempts=attempt,
|
|
224
|
+
category="client",
|
|
225
|
+
),
|
|
226
|
+
cause=_sanitized_cause(exc),
|
|
227
|
+
category="client",
|
|
228
|
+
retryable=False,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if failure is not None:
|
|
232
|
+
if failure.retryable and attempt < self._retry_policy.max_attempts:
|
|
233
|
+
await self._retry_after_failure(
|
|
234
|
+
endpoint=endpoint,
|
|
235
|
+
attempt=attempt,
|
|
236
|
+
category=failure.category,
|
|
237
|
+
)
|
|
238
|
+
continue
|
|
239
|
+
raise failure.error from failure.cause
|
|
240
|
+
|
|
241
|
+
if not isinstance(result, _RetryDecision):
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
_LOGGER.debug(
|
|
245
|
+
"Retrying CFBD GET endpoint=%s category=%s attempt=%d delay=%.3f",
|
|
246
|
+
endpoint,
|
|
247
|
+
result.category,
|
|
248
|
+
attempt,
|
|
249
|
+
result.delay_seconds,
|
|
250
|
+
)
|
|
251
|
+
await self._sleep(result.delay_seconds)
|
|
252
|
+
|
|
253
|
+
raise AssertionError("retry loop exhausted without returning or raising")
|
|
254
|
+
|
|
255
|
+
async def _request_once(
|
|
256
|
+
self,
|
|
257
|
+
*,
|
|
258
|
+
session: aiohttp.ClientSession,
|
|
259
|
+
url: str,
|
|
260
|
+
endpoint: str,
|
|
261
|
+
params: QueryParameters,
|
|
262
|
+
attempt: int,
|
|
263
|
+
) -> object | _RetryDecision:
|
|
264
|
+
"""Perform one attempt and release its response before any retry delay."""
|
|
265
|
+
async with session.get(
|
|
266
|
+
url,
|
|
267
|
+
params=params,
|
|
268
|
+
allow_redirects=False,
|
|
269
|
+
) as response:
|
|
270
|
+
if response.status >= 300:
|
|
271
|
+
retry_after = self._parse_retry_after(
|
|
272
|
+
response.headers.get("Retry-After")
|
|
273
|
+
)
|
|
274
|
+
error = _http_error(
|
|
275
|
+
endpoint=endpoint,
|
|
276
|
+
status=response.status,
|
|
277
|
+
attempts=attempt,
|
|
278
|
+
retry_after_seconds=retry_after,
|
|
279
|
+
)
|
|
280
|
+
if (
|
|
281
|
+
response.status in _RETRYABLE_STATUSES
|
|
282
|
+
and retry_after is not None
|
|
283
|
+
and retry_after > self._retry_policy.max_retry_after_seconds
|
|
284
|
+
):
|
|
285
|
+
raise error
|
|
286
|
+
if (
|
|
287
|
+
response.status in _RETRYABLE_STATUSES
|
|
288
|
+
and attempt < self._retry_policy.max_attempts
|
|
289
|
+
):
|
|
290
|
+
delay = retry_after
|
|
291
|
+
if delay is None:
|
|
292
|
+
delay = self._backoff_delay(attempt)
|
|
293
|
+
return _RetryDecision(
|
|
294
|
+
error=error,
|
|
295
|
+
delay_seconds=delay,
|
|
296
|
+
category=f"http_{response.status}",
|
|
297
|
+
)
|
|
298
|
+
raise error
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
decoded: object = await response.json()
|
|
302
|
+
except (
|
|
303
|
+
aiohttp.ContentTypeError,
|
|
304
|
+
json.JSONDecodeError,
|
|
305
|
+
UnicodeDecodeError,
|
|
306
|
+
) as exc:
|
|
307
|
+
safe_cause = _sanitized_cause(exc)
|
|
308
|
+
else:
|
|
309
|
+
return decoded
|
|
310
|
+
raise CFBDResponseDecodeError(
|
|
311
|
+
endpoint=endpoint,
|
|
312
|
+
attempts=attempt,
|
|
313
|
+
) from safe_cause
|
|
314
|
+
|
|
315
|
+
async def _retry_after_failure(
|
|
316
|
+
self,
|
|
317
|
+
*,
|
|
318
|
+
endpoint: str,
|
|
319
|
+
attempt: int,
|
|
320
|
+
category: str,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""Log and apply one client-selected full-jitter delay."""
|
|
323
|
+
delay = self._backoff_delay(attempt)
|
|
324
|
+
_LOGGER.debug(
|
|
325
|
+
"Retrying CFBD GET endpoint=%s category=%s attempt=%d delay=%.3f",
|
|
326
|
+
endpoint,
|
|
327
|
+
category,
|
|
328
|
+
attempt,
|
|
329
|
+
delay,
|
|
330
|
+
)
|
|
331
|
+
await self._sleep(delay)
|
|
332
|
+
|
|
333
|
+
def _backoff_delay(self, attempt: int) -> float:
|
|
334
|
+
"""Return capped exponential full-jitter backoff after an attempt."""
|
|
335
|
+
backoff_multiplier: float = 2.0 ** (attempt - 1)
|
|
336
|
+
ceiling: float = min(
|
|
337
|
+
self._retry_policy.max_backoff_seconds,
|
|
338
|
+
self._retry_policy.base_delay_seconds * backoff_multiplier,
|
|
339
|
+
)
|
|
340
|
+
random_fraction: float = self._random_source()
|
|
341
|
+
if not 0 <= random_fraction <= 1:
|
|
342
|
+
raise ValueError("random_source must return a value from 0 through 1")
|
|
343
|
+
return random_fraction * ceiling
|
|
344
|
+
|
|
345
|
+
def _parse_retry_after(self, value: str | None) -> float | None:
|
|
346
|
+
"""Parse a numeric or HTTP-date ``Retry-After`` value."""
|
|
347
|
+
if value is None:
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
numeric = True
|
|
351
|
+
try:
|
|
352
|
+
seconds = float(value)
|
|
353
|
+
except ValueError:
|
|
354
|
+
numeric = False
|
|
355
|
+
try:
|
|
356
|
+
retry_at = parsedate_to_datetime(value)
|
|
357
|
+
except (TypeError, ValueError, OverflowError):
|
|
358
|
+
return None
|
|
359
|
+
if retry_at.tzinfo is None or retry_at.utcoffset() is None:
|
|
360
|
+
retry_at = retry_at.replace(tzinfo=UTC)
|
|
361
|
+
seconds = max(
|
|
362
|
+
0.0,
|
|
363
|
+
(retry_at.astimezone(UTC) - self._utc_now()).total_seconds(),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
if not math.isfinite(seconds) or (numeric and seconds < 0):
|
|
367
|
+
return None
|
|
368
|
+
return seconds
|
|
369
|
+
|
|
370
|
+
def _active_session(self) -> aiohttp.ClientSession:
|
|
371
|
+
"""Return the active session or reject calls outside the context."""
|
|
372
|
+
if self._state is not _TransportState.active or self._session is None:
|
|
373
|
+
raise CFBDClientStateError(
|
|
374
|
+
"Endpoint calls require an active 'async with CFBDClient(...)' context"
|
|
375
|
+
)
|
|
376
|
+
return self._session
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _resolve_api_key(api_key: str | None) -> str:
|
|
380
|
+
"""Resolve and validate explicit or environment authentication.
|
|
381
|
+
|
|
382
|
+
:param api_key: Explicit key, or ``None`` to consult ``CFBD_API_KEY``.
|
|
383
|
+
:return: Non-empty bearer token.
|
|
384
|
+
:raises CFBDConfigurationError: If the selected credential is empty or absent.
|
|
385
|
+
"""
|
|
386
|
+
if api_key is not None:
|
|
387
|
+
if not api_key.strip():
|
|
388
|
+
raise CFBDConfigurationError("Explicit api_key cannot be empty")
|
|
389
|
+
return api_key
|
|
390
|
+
|
|
391
|
+
environment_key = os.getenv("CFBD_API_KEY")
|
|
392
|
+
if environment_key is None or not environment_key.strip():
|
|
393
|
+
raise CFBDConfigurationError(
|
|
394
|
+
"Provide api_key or set a non-empty CFBD_API_KEY environment variable"
|
|
395
|
+
)
|
|
396
|
+
return environment_key
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _validate_base_url(base_url: str) -> str:
|
|
400
|
+
"""Return a normalized HTTP(S) base URL without unsafe components.
|
|
401
|
+
|
|
402
|
+
:param base_url: API origin and optional base path.
|
|
403
|
+
:return: Base URL without trailing slashes.
|
|
404
|
+
:raises CFBDConfigurationError: If the URL cannot safely identify an origin.
|
|
405
|
+
"""
|
|
406
|
+
parsed = urlsplit(base_url)
|
|
407
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
408
|
+
raise CFBDConfigurationError("base_url must be an absolute HTTP(S) URL")
|
|
409
|
+
if parsed.username is not None or parsed.password is not None:
|
|
410
|
+
raise CFBDConfigurationError("base_url must not contain credentials")
|
|
411
|
+
if parsed.query or parsed.fragment:
|
|
412
|
+
raise CFBDConfigurationError("base_url must not contain a query or fragment")
|
|
413
|
+
return base_url.rstrip("/")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _validate_timeout(timeout_seconds: float) -> float:
|
|
417
|
+
"""Return a finite positive per-attempt timeout.
|
|
418
|
+
|
|
419
|
+
:param timeout_seconds: Configured timeout in seconds.
|
|
420
|
+
:return: Validated timeout.
|
|
421
|
+
:raises CFBDConfigurationError: If the timeout is not finite and positive.
|
|
422
|
+
"""
|
|
423
|
+
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
|
|
424
|
+
raise CFBDConfigurationError("timeout_seconds must be finite and positive")
|
|
425
|
+
return timeout_seconds
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _http_error(
|
|
429
|
+
*,
|
|
430
|
+
endpoint: str,
|
|
431
|
+
status: int,
|
|
432
|
+
attempts: int,
|
|
433
|
+
retry_after_seconds: float | None,
|
|
434
|
+
) -> CFBDHTTPError:
|
|
435
|
+
"""Create the most specific safe HTTP exception for a response status."""
|
|
436
|
+
error_type: type[CFBDHTTPError]
|
|
437
|
+
if status == 401:
|
|
438
|
+
error_type = CFBDAuthenticationError
|
|
439
|
+
elif status == 403:
|
|
440
|
+
error_type = CFBDAuthorizationError
|
|
441
|
+
elif status == 429:
|
|
442
|
+
error_type = CFBDRateLimitError
|
|
443
|
+
elif status >= 500:
|
|
444
|
+
error_type = CFBDServerError
|
|
445
|
+
else:
|
|
446
|
+
error_type = CFBDHTTPError
|
|
447
|
+
return error_type(
|
|
448
|
+
endpoint=endpoint,
|
|
449
|
+
status=status,
|
|
450
|
+
attempts=attempts,
|
|
451
|
+
retry_after_seconds=retry_after_seconds,
|
|
452
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Export the supported Adjusted Metrics namespace and public contracts."""
|
|
2
|
+
|
|
3
|
+
from .models.pydantic import (
|
|
4
|
+
AdjustedPlayerPassingRequest,
|
|
5
|
+
AdjustedPlayerRushingRequest,
|
|
6
|
+
AdjustedTeamMetrics,
|
|
7
|
+
AdjustedTeamMetricsEPA,
|
|
8
|
+
AdjustedTeamMetricsRequest,
|
|
9
|
+
AdjustedTeamMetricsRushing,
|
|
10
|
+
AdjustedTeamMetricsSuccessRate,
|
|
11
|
+
KickerPAAR,
|
|
12
|
+
KickerPAARRequest,
|
|
13
|
+
PlayerWeightedEPA,
|
|
14
|
+
)
|
|
15
|
+
from .resource import AdjustedMetricsResource
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AdjustedMetricsResource",
|
|
19
|
+
"AdjustedPlayerPassingRequest",
|
|
20
|
+
"AdjustedPlayerRushingRequest",
|
|
21
|
+
"AdjustedTeamMetrics",
|
|
22
|
+
"AdjustedTeamMetricsEPA",
|
|
23
|
+
"AdjustedTeamMetricsRequest",
|
|
24
|
+
"AdjustedTeamMetricsRushing",
|
|
25
|
+
"AdjustedTeamMetricsSuccessRate",
|
|
26
|
+
"KickerPAAR",
|
|
27
|
+
"KickerPAARRequest",
|
|
28
|
+
"PlayerWeightedEPA",
|
|
29
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Export Adjusted Metrics model families."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Export validated Adjusted Metrics request and response models."""
|
|
2
|
+
|
|
3
|
+
from .requests import (
|
|
4
|
+
AdjustedPlayerPassingRequest,
|
|
5
|
+
AdjustedPlayerRushingRequest,
|
|
6
|
+
AdjustedTeamMetricsRequest,
|
|
7
|
+
KickerPAARRequest,
|
|
8
|
+
)
|
|
9
|
+
from .responses import (
|
|
10
|
+
AdjustedTeamMetrics,
|
|
11
|
+
AdjustedTeamMetricsEPA,
|
|
12
|
+
AdjustedTeamMetricsRushing,
|
|
13
|
+
AdjustedTeamMetricsSuccessRate,
|
|
14
|
+
KickerPAAR,
|
|
15
|
+
PlayerWeightedEPA,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AdjustedPlayerPassingRequest",
|
|
20
|
+
"AdjustedPlayerRushingRequest",
|
|
21
|
+
"AdjustedTeamMetrics",
|
|
22
|
+
"AdjustedTeamMetricsEPA",
|
|
23
|
+
"AdjustedTeamMetricsRequest",
|
|
24
|
+
"AdjustedTeamMetricsRushing",
|
|
25
|
+
"AdjustedTeamMetricsSuccessRate",
|
|
26
|
+
"KickerPAAR",
|
|
27
|
+
"KickerPAARRequest",
|
|
28
|
+
"PlayerWeightedEPA",
|
|
29
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Validate request parameters for implemented Adjusted Metrics endpoints."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _AdjustedMetricsRequest(BaseModel):
|
|
7
|
+
"""Apply the closed-object contract shared by Adjusted Metrics requests."""
|
|
8
|
+
|
|
9
|
+
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _TeamFilters(_AdjustedMetricsRequest):
|
|
13
|
+
"""Define team and season filters shared by adjusted metric routes."""
|
|
14
|
+
|
|
15
|
+
year: int | None = Field(default=None, ge=1869)
|
|
16
|
+
team: str | None = Field(default=None, min_length=1)
|
|
17
|
+
conference: str | None = Field(default=None, min_length=1)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AdjustedTeamMetricsRequest(_TeamFilters):
|
|
21
|
+
"""Validate filters accepted by ``GET /wepa/team/season``."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AdjustedPlayerPassingRequest(_TeamFilters):
|
|
25
|
+
"""Validate filters accepted by ``GET /wepa/players/passing``."""
|
|
26
|
+
|
|
27
|
+
position: str | None = Field(default=None, min_length=1)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AdjustedPlayerRushingRequest(_TeamFilters):
|
|
31
|
+
"""Validate filters accepted by ``GET /wepa/players/rushing``."""
|
|
32
|
+
|
|
33
|
+
position: str | None = Field(default=None, min_length=1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class KickerPAARRequest(_TeamFilters):
|
|
37
|
+
"""Validate filters accepted by ``GET /wepa/players/kicking``."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"AdjustedPlayerPassingRequest",
|
|
42
|
+
"AdjustedPlayerRushingRequest",
|
|
43
|
+
"AdjustedTeamMetricsRequest",
|
|
44
|
+
"KickerPAARRequest",
|
|
45
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Validate responses from implemented CFBD Adjusted Metrics endpoints."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _ResponseModel(BaseModel):
|
|
7
|
+
"""Apply the upstream closed-object contract to adjusted metric responses."""
|
|
8
|
+
|
|
9
|
+
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AdjustedTeamMetricsEPA(_ResponseModel):
|
|
13
|
+
"""Represent opponent-adjusted EPA by play type."""
|
|
14
|
+
|
|
15
|
+
total: float
|
|
16
|
+
passing: float
|
|
17
|
+
rushing: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AdjustedTeamMetricsSuccessRate(_ResponseModel):
|
|
21
|
+
"""Represent opponent-adjusted success rate by down context."""
|
|
22
|
+
|
|
23
|
+
total: float = Field(ge=0, le=1)
|
|
24
|
+
standard_downs: float = Field(alias="standardDowns", ge=0, le=1)
|
|
25
|
+
passing_downs: float = Field(alias="passingDowns", ge=0, le=1)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AdjustedTeamMetricsRushing(_ResponseModel):
|
|
29
|
+
"""Represent opponent-adjusted rushing yard components."""
|
|
30
|
+
|
|
31
|
+
line_yards: float = Field(alias="lineYards")
|
|
32
|
+
second_level_yards: float = Field(alias="secondLevelYards")
|
|
33
|
+
open_field_yards: float = Field(alias="openFieldYards")
|
|
34
|
+
highlight_yards: float = Field(alias="highlightYards")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AdjustedTeamMetrics(_ResponseModel):
|
|
38
|
+
"""Represent opponent-adjusted team metrics for one season."""
|
|
39
|
+
|
|
40
|
+
year: int = Field(ge=1869)
|
|
41
|
+
team_id: int = Field(alias="teamId", gt=0)
|
|
42
|
+
team: str
|
|
43
|
+
conference: str
|
|
44
|
+
epa: AdjustedTeamMetricsEPA
|
|
45
|
+
epa_allowed: AdjustedTeamMetricsEPA = Field(alias="epaAllowed")
|
|
46
|
+
success_rate: AdjustedTeamMetricsSuccessRate = Field(alias="successRate")
|
|
47
|
+
success_rate_allowed: AdjustedTeamMetricsSuccessRate = Field(
|
|
48
|
+
alias="successRateAllowed"
|
|
49
|
+
)
|
|
50
|
+
rushing: AdjustedTeamMetricsRushing
|
|
51
|
+
rushing_allowed: AdjustedTeamMetricsRushing = Field(alias="rushingAllowed")
|
|
52
|
+
explosiveness: float
|
|
53
|
+
explosiveness_allowed: float = Field(alias="explosivenessAllowed")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PlayerWeightedEPA(_ResponseModel):
|
|
57
|
+
"""Represent opponent-adjusted player EPA for one season."""
|
|
58
|
+
|
|
59
|
+
year: int = Field(ge=1869)
|
|
60
|
+
athlete_id: str = Field(alias="athleteId", min_length=1)
|
|
61
|
+
athlete_name: str = Field(alias="athleteName")
|
|
62
|
+
position: str
|
|
63
|
+
team: str
|
|
64
|
+
conference: str
|
|
65
|
+
wepa: float
|
|
66
|
+
plays: int = Field(ge=0)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class KickerPAAR(_ResponseModel):
|
|
70
|
+
"""Represent a kicker's Points Added Above Replacement rating."""
|
|
71
|
+
|
|
72
|
+
year: int = Field(ge=1869)
|
|
73
|
+
athlete_id: str = Field(alias="athleteId", min_length=1)
|
|
74
|
+
athlete_name: str = Field(alias="athleteName")
|
|
75
|
+
team: str
|
|
76
|
+
conference: str
|
|
77
|
+
paar: float
|
|
78
|
+
attempts: int = Field(ge=0)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
"AdjustedTeamMetrics",
|
|
83
|
+
"AdjustedTeamMetricsEPA",
|
|
84
|
+
"AdjustedTeamMetricsRushing",
|
|
85
|
+
"AdjustedTeamMetricsSuccessRate",
|
|
86
|
+
"KickerPAAR",
|
|
87
|
+
"PlayerWeightedEPA",
|
|
88
|
+
]
|