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/_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
+ ]