pitchapi 0.1.0__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.
pitchapi/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """PitchAPI — Python SDK for the PitchAPI football-data API.
2
+
3
+ Example:
4
+ from pitchapi import PitchAPI
5
+
6
+ with PitchAPI(api_key="pk_live_...") as client:
7
+ for m in client.date.get("2026-08-27").matches:
8
+ print(m.home_team.name, m.score_home, "-", m.score_away, m.away_team.name)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ from . import models
16
+ from .client import AsyncPitchAPI, PitchAPI
17
+ from .errors import (
18
+ APIConnectionError,
19
+ AuthError,
20
+ InvalidParameterError,
21
+ NotFoundError,
22
+ PitchAPIError,
23
+ PlanUpgradeRequiredError,
24
+ RateLimitError,
25
+ ServerError,
26
+ SubscriptionSuspendedError,
27
+ )
28
+
29
+ __all__ = [
30
+ "__version__",
31
+ "PitchAPI",
32
+ "AsyncPitchAPI",
33
+ "models",
34
+ # errors
35
+ "PitchAPIError",
36
+ "APIConnectionError",
37
+ "InvalidParameterError",
38
+ "AuthError",
39
+ "SubscriptionSuspendedError",
40
+ "PlanUpgradeRequiredError",
41
+ "NotFoundError",
42
+ "RateLimitError",
43
+ "ServerError",
44
+ ]
pitchapi/_transport.py ADDED
@@ -0,0 +1,101 @@
1
+ """Transport-level helpers shared by the sync and async clients.
2
+
3
+ Keeping envelope handling, error mapping, and the retry policy here means the
4
+ two clients differ only in how they run the HTTP call itself, not in how they
5
+ interpret the result.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import random
11
+ from typing import Any, Optional
12
+
13
+ from . import __version__
14
+ from .errors import (
15
+ PitchAPIError,
16
+ RateLimitError,
17
+ ServerError,
18
+ exception_for_code,
19
+ )
20
+
21
+ DEFAULT_BASE_URL = "https://api.pitchapi.dev"
22
+ API_PREFIX = "/v1"
23
+
24
+ # Statuses that a GET may safely be replayed on. Everything else (400/401/403/
25
+ # 404) is a deterministic client error and is surfaced immediately.
26
+ _RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
27
+
28
+
29
+ def user_agent() -> str:
30
+ return f"pitchapi-python/{__version__}"
31
+
32
+
33
+ def build_headers(api_key: str, extra: Optional[dict[str, str]] = None) -> dict[str, str]:
34
+ headers = {
35
+ "X-API-KEY": api_key,
36
+ "Accept": "application/json",
37
+ "User-Agent": user_agent(),
38
+ }
39
+ if extra:
40
+ headers.update(extra)
41
+ return headers
42
+
43
+
44
+ def should_retry(status: int, attempt: int, max_retries: int) -> bool:
45
+ return attempt < max_retries and status in _RETRY_STATUSES
46
+
47
+
48
+ def backoff_seconds(attempt: int, retry_after: Optional[int]) -> float:
49
+ """Delay before the next attempt.
50
+
51
+ Honours a server-provided ``Retry-After`` exactly; otherwise falls back to
52
+ exponential backoff with full jitter so retried clients do not synchronise.
53
+ """
54
+ if retry_after is not None and retry_after >= 0:
55
+ return float(retry_after)
56
+ base = min(2.0**attempt, 8.0)
57
+ return random.uniform(0.0, base)
58
+
59
+
60
+ def parse_retry_after(value: Optional[str]) -> Optional[int]:
61
+ if not value:
62
+ return None
63
+ try:
64
+ return int(value)
65
+ except ValueError:
66
+ return None
67
+
68
+
69
+ def unwrap(status: int, payload: Any, request_id: Optional[str], retry_after: Optional[int]) -> Any:
70
+ """Return the ``data`` object on success, or raise the mapped exception.
71
+
72
+ ``payload`` is the already-decoded JSON body, or ``None`` when the body was
73
+ absent or not JSON (which is itself an error for this API).
74
+ """
75
+ if 200 <= status < 300:
76
+ if isinstance(payload, dict) and "data" in payload:
77
+ return payload["data"]
78
+ # A 2xx without the envelope should not happen; surface it rather than
79
+ # hand back a shape the models cannot parse.
80
+ raise PitchAPIError(
81
+ "malformed success response: missing 'data' envelope",
82
+ status_code=status,
83
+ request_id=request_id,
84
+ )
85
+
86
+ code: Optional[str] = None
87
+ message = f"HTTP {status}"
88
+ if isinstance(payload, dict):
89
+ err = payload.get("error")
90
+ if isinstance(err, dict):
91
+ code = err.get("code")
92
+ message = err.get("message", message)
93
+
94
+ exc_cls = exception_for_code(code, status)
95
+ if exc_cls is RateLimitError:
96
+ raise RateLimitError(
97
+ message, retry_after=retry_after, code=code, status_code=status, request_id=request_id
98
+ )
99
+ if exc_cls is ServerError and code is None:
100
+ message = f"server error (HTTP {status})"
101
+ raise exc_cls(message, code=code, status_code=status, request_id=request_id)
pitchapi/client.py ADDED
@@ -0,0 +1,184 @@
1
+ """Synchronous and asynchronous entry-point clients.
2
+
3
+ Both wrap an :mod:`httpx` client and share every policy decision through
4
+ :mod:`pitchapi._transport`. Resource namespaces hang off each instance:
5
+
6
+ >>> from pitchapi import PitchAPI
7
+ >>> client = PitchAPI(api_key="pk_live_...")
8
+ >>> match = client.matches.get("m_4DP2fy")
9
+
10
+ Prefer a context manager (or call :meth:`close` / :meth:`aclose`) so the
11
+ underlying connection pool is released.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import os
18
+ import time
19
+ from typing import Any, Optional
20
+
21
+ import httpx
22
+
23
+ from . import resources
24
+ from ._transport import (
25
+ API_PREFIX,
26
+ DEFAULT_BASE_URL,
27
+ backoff_seconds,
28
+ build_headers,
29
+ parse_retry_after,
30
+ should_retry,
31
+ unwrap,
32
+ )
33
+ from .errors import APIConnectionError
34
+
35
+ _ENV_API_KEY = "PITCHAPI_API_KEY"
36
+ _ENV_BASE_URL = "PITCHAPI_BASE_URL"
37
+
38
+
39
+ def _resolve_api_key(api_key: Optional[str]) -> str:
40
+ key = api_key or os.environ.get(_ENV_API_KEY)
41
+ if not key:
42
+ raise ValueError(
43
+ "an API key is required: pass api_key=... or set the "
44
+ f"{_ENV_API_KEY} environment variable"
45
+ )
46
+ return key
47
+
48
+
49
+ def _resolve_base_url(base_url: Optional[str]) -> str:
50
+ return (base_url or os.environ.get(_ENV_BASE_URL) or DEFAULT_BASE_URL).rstrip("/")
51
+
52
+
53
+ def _decode_json(response: httpx.Response) -> Any:
54
+ try:
55
+ return response.json()
56
+ except ValueError:
57
+ return None
58
+
59
+
60
+ class PitchAPI:
61
+ """Synchronous PitchAPI client.
62
+
63
+ Args:
64
+ api_key: A ``pk_live_`` / ``pk_test_`` key. Falls back to the
65
+ ``PITCHAPI_API_KEY`` environment variable.
66
+ base_url: Override the API host (mainly for testing / self-hosting).
67
+ timeout: Per-request timeout in seconds.
68
+ max_retries: How many times to replay a request on 429/5xx and network
69
+ errors. ``0`` disables retries.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ api_key: Optional[str] = None,
75
+ *,
76
+ base_url: Optional[str] = None,
77
+ timeout: float = 30.0,
78
+ max_retries: int = 2,
79
+ _http_client: Optional[httpx.Client] = None,
80
+ ) -> None:
81
+ self._api_key = _resolve_api_key(api_key)
82
+ self._base_url = _resolve_base_url(base_url)
83
+ self._max_retries = max_retries
84
+ self._http = _http_client or httpx.Client(
85
+ timeout=timeout, headers=build_headers(self._api_key)
86
+ )
87
+
88
+ self.matches = resources.Matches(self)
89
+ self.leagues = resources.Leagues(self)
90
+ self.teams = resources.Teams(self)
91
+ self.players = resources.Players(self)
92
+ self.date = resources.Date(self)
93
+
94
+ # ── transport ──────────────────────────────────────────
95
+ def _get(self, path: str, params: Optional[dict[str, str]] = None) -> Any:
96
+ url = self._base_url + API_PREFIX + path
97
+ attempt = 0
98
+ while True:
99
+ try:
100
+ response = self._http.get(url, params=params)
101
+ except httpx.RequestError as exc:
102
+ if attempt < self._max_retries:
103
+ time.sleep(backoff_seconds(attempt, None))
104
+ attempt += 1
105
+ continue
106
+ raise APIConnectionError(f"request failed: {exc}") from exc
107
+
108
+ status = response.status_code
109
+ retry_after = parse_retry_after(response.headers.get("Retry-After"))
110
+ if should_retry(status, attempt, self._max_retries):
111
+ time.sleep(backoff_seconds(attempt, retry_after))
112
+ attempt += 1
113
+ continue
114
+
115
+ request_id = response.headers.get("X-Request-ID")
116
+ return unwrap(status, _decode_json(response), request_id, retry_after)
117
+
118
+ # ── lifecycle ──────────────────────────────────────────
119
+ def close(self) -> None:
120
+ self._http.close()
121
+
122
+ def __enter__(self) -> "PitchAPI":
123
+ return self
124
+
125
+ def __exit__(self, *exc: object) -> None:
126
+ self.close()
127
+
128
+
129
+ class AsyncPitchAPI:
130
+ """Asynchronous PitchAPI client — the async twin of :class:`PitchAPI`."""
131
+
132
+ def __init__(
133
+ self,
134
+ api_key: Optional[str] = None,
135
+ *,
136
+ base_url: Optional[str] = None,
137
+ timeout: float = 30.0,
138
+ max_retries: int = 2,
139
+ _http_client: Optional[httpx.AsyncClient] = None,
140
+ ) -> None:
141
+ self._api_key = _resolve_api_key(api_key)
142
+ self._base_url = _resolve_base_url(base_url)
143
+ self._max_retries = max_retries
144
+ self._http = _http_client or httpx.AsyncClient(
145
+ timeout=timeout, headers=build_headers(self._api_key)
146
+ )
147
+
148
+ self.matches = resources.AsyncMatches(self)
149
+ self.leagues = resources.AsyncLeagues(self)
150
+ self.teams = resources.AsyncTeams(self)
151
+ self.players = resources.AsyncPlayers(self)
152
+ self.date = resources.AsyncDate(self)
153
+
154
+ async def _get(self, path: str, params: Optional[dict[str, str]] = None) -> Any:
155
+ url = self._base_url + API_PREFIX + path
156
+ attempt = 0
157
+ while True:
158
+ try:
159
+ response = await self._http.get(url, params=params)
160
+ except httpx.RequestError as exc:
161
+ if attempt < self._max_retries:
162
+ await asyncio.sleep(backoff_seconds(attempt, None))
163
+ attempt += 1
164
+ continue
165
+ raise APIConnectionError(f"request failed: {exc}") from exc
166
+
167
+ status = response.status_code
168
+ retry_after = parse_retry_after(response.headers.get("Retry-After"))
169
+ if should_retry(status, attempt, self._max_retries):
170
+ await asyncio.sleep(backoff_seconds(attempt, retry_after))
171
+ attempt += 1
172
+ continue
173
+
174
+ request_id = response.headers.get("X-Request-ID")
175
+ return unwrap(status, _decode_json(response), request_id, retry_after)
176
+
177
+ async def aclose(self) -> None:
178
+ await self._http.aclose()
179
+
180
+ async def __aenter__(self) -> "AsyncPitchAPI":
181
+ return self
182
+
183
+ async def __aexit__(self, *exc: object) -> None:
184
+ await self.aclose()
pitchapi/errors.py ADDED
@@ -0,0 +1,128 @@
1
+ """Exception hierarchy for the PitchAPI SDK.
2
+
3
+ Every error the API returns carries a stable ``error.code`` string in its
4
+ envelope. Exceptions are mapped from that code first and the HTTP status second,
5
+ so callers can branch on a semantic type rather than parsing status numbers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+
13
+ class PitchAPIError(Exception):
14
+ """Base class for every error raised by the SDK.
15
+
16
+ Attributes:
17
+ message: Human-readable description from the API (or the SDK).
18
+ code: The stable ``error.code`` string, when the failure came from the
19
+ API envelope. ``None`` for transport-level failures.
20
+ status_code: The HTTP status, when there was a response.
21
+ request_id: The ``X-Request-ID`` echoed by the API, useful in support.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ message: str,
27
+ *,
28
+ code: Optional[str] = None,
29
+ status_code: Optional[int] = None,
30
+ request_id: Optional[str] = None,
31
+ ) -> None:
32
+ super().__init__(message)
33
+ self.message = message
34
+ self.code = code
35
+ self.status_code = status_code
36
+ self.request_id = request_id
37
+
38
+ def __str__(self) -> str:
39
+ parts = [self.message]
40
+ if self.code:
41
+ parts.append(f"code={self.code}")
42
+ if self.status_code:
43
+ parts.append(f"status={self.status_code}")
44
+ if self.request_id:
45
+ parts.append(f"request_id={self.request_id}")
46
+ return " ".join(parts) if len(parts) == 1 else f"{parts[0]} ({', '.join(parts[1:])})"
47
+
48
+
49
+ class APIConnectionError(PitchAPIError):
50
+ """The request never produced a response (network error, timeout, DNS)."""
51
+
52
+
53
+ class InvalidParameterError(PitchAPIError):
54
+ """400 INVALID_PARAMETER — a malformed ID, date, or season."""
55
+
56
+
57
+ class AuthError(PitchAPIError):
58
+ """401 UNAUTHORIZED — the API key is missing, malformed, or revoked."""
59
+
60
+
61
+ class SubscriptionSuspendedError(PitchAPIError):
62
+ """403 SUBSCRIPTION_SUSPENDED — the account's subscription is not active."""
63
+
64
+
65
+ class PlanUpgradeRequiredError(PitchAPIError):
66
+ """403 PLAN_UPGRADE_REQUIRED — the resource's league is Pro-only."""
67
+
68
+
69
+ class NotFoundError(PitchAPIError):
70
+ """404 — the resource does not exist, or its analytics are unavailable.
71
+
72
+ ``code`` distinguishes ``RESOURCE_NOT_FOUND`` from ``ANALYTICS_UNAVAILABLE``.
73
+ """
74
+
75
+
76
+ class RateLimitError(PitchAPIError):
77
+ """429 RATE_LIMIT_EXCEEDED — the per-user fair-use ceiling was hit.
78
+
79
+ ``retry_after`` is the number of seconds the API asked the client to wait,
80
+ taken from the ``Retry-After`` header. ``None`` if the header was absent.
81
+ """
82
+
83
+ def __init__(
84
+ self, message: str, *, retry_after: Optional[int] = None, **kwargs: object
85
+ ) -> None:
86
+ super().__init__(message, **kwargs) # type: ignore[arg-type]
87
+ self.retry_after = retry_after
88
+
89
+
90
+ class ServerError(PitchAPIError):
91
+ """5xx — the API failed to serve the request."""
92
+
93
+
94
+ # Maps the API's stable error.code onto an exception class. HTTP status is the
95
+ # fallback when a code is absent or unrecognised (see _transport.raise_for).
96
+ _CODE_MAP = {
97
+ "INVALID_PARAMETER": InvalidParameterError,
98
+ "UNAUTHORIZED": AuthError,
99
+ "SUBSCRIPTION_SUSPENDED": SubscriptionSuspendedError,
100
+ "PLAN_UPGRADE_REQUIRED": PlanUpgradeRequiredError,
101
+ "RESOURCE_NOT_FOUND": NotFoundError,
102
+ "ANALYTICS_UNAVAILABLE": NotFoundError,
103
+ "RATE_LIMIT_EXCEEDED": RateLimitError,
104
+ "INTERNAL_SERVER_ERROR": ServerError,
105
+ }
106
+
107
+
108
+ def exception_for_code(code: Optional[str], status_code: int) -> type[PitchAPIError]:
109
+ """Pick the exception class for an error response.
110
+
111
+ Prefers the semantic ``code``; falls back to a coarse mapping by HTTP status
112
+ so an unrecognised code still lands on a sensible type.
113
+ """
114
+ if code and code in _CODE_MAP:
115
+ return _CODE_MAP[code]
116
+ if status_code == 400:
117
+ return InvalidParameterError
118
+ if status_code == 401:
119
+ return AuthError
120
+ if status_code == 403:
121
+ return PlanUpgradeRequiredError
122
+ if status_code == 404:
123
+ return NotFoundError
124
+ if status_code == 429:
125
+ return RateLimitError
126
+ if status_code >= 500:
127
+ return ServerError
128
+ return PitchAPIError
@@ -0,0 +1,127 @@
1
+ """Response dataclasses, a declarative mirror of the API's JSON shapes.
2
+
3
+ Build one from a raw ``data`` object with :func:`from_dict`, though most callers
4
+ never do so directly — the resource methods return fully built models.
5
+ """
6
+
7
+ from ._convert import from_dict
8
+ from .advanced import (
9
+ AdvancedNetworks,
10
+ AdvancedPlayers,
11
+ AdvancedPlayerStat,
12
+ AdvancedTeams,
13
+ AdvancedTeamStat,
14
+ CarryingStats,
15
+ Goalkeeping,
16
+ NetworkWindow,
17
+ PassingStats,
18
+ PassNetwork,
19
+ PassNetworkEdge,
20
+ PassNetworkNode,
21
+ PlayerCreation,
22
+ PlayerDefending,
23
+ PlayerSCABreakdown,
24
+ PossessionValue,
25
+ TeamCreation,
26
+ TeamDefending,
27
+ TeamSCABreakdown,
28
+ TempoStats,
29
+ TerritoryStats,
30
+ )
31
+ from .common import (
32
+ AdvancedPlayerRef,
33
+ CoachRef,
34
+ LeagueRef,
35
+ PlayerRef,
36
+ TeamNameRef,
37
+ TeamRef,
38
+ )
39
+ from .event import Event, Events
40
+ from .h2h import H2H, H2HMatch
41
+ from .league import (
42
+ League,
43
+ LeagueMatches,
44
+ LeagueMatchSummary,
45
+ LeagueSeasonRef,
46
+ LeaguesList,
47
+ LeagueSummary,
48
+ )
49
+ from .lineup import LineupPlayer, Lineups, LineupSide
50
+ from .match import DateMatches, Match, MatchSummary
51
+ from .momentum import Momentum, MomentumPoint
52
+ from .player import PlayerDetail, PlayerMatchStats, PlayerProfile
53
+ from .shot import PlayerShots, Shot, ShotPeriod, Shots
54
+ from .stats import MatchStatGroup, MatchStatItem, MatchStatPeriod, MatchStats
55
+ from .team import Team
56
+
57
+ __all__ = [
58
+ "from_dict",
59
+ # common
60
+ "LeagueRef",
61
+ "TeamRef",
62
+ "TeamNameRef",
63
+ "PlayerRef",
64
+ "AdvancedPlayerRef",
65
+ "CoachRef",
66
+ # match / date
67
+ "Match",
68
+ "MatchSummary",
69
+ "DateMatches",
70
+ # shots
71
+ "Shot",
72
+ "ShotPeriod",
73
+ "Shots",
74
+ "PlayerShots",
75
+ # events
76
+ "Event",
77
+ "Events",
78
+ # lineups
79
+ "LineupPlayer",
80
+ "LineupSide",
81
+ "Lineups",
82
+ # momentum
83
+ "MomentumPoint",
84
+ "Momentum",
85
+ # stats
86
+ "MatchStatItem",
87
+ "MatchStatGroup",
88
+ "MatchStatPeriod",
89
+ "MatchStats",
90
+ # h2h
91
+ "H2HMatch",
92
+ "H2H",
93
+ # leagues
94
+ "League",
95
+ "LeagueSummary",
96
+ "LeaguesList",
97
+ "LeagueSeasonRef",
98
+ "LeagueMatchSummary",
99
+ "LeagueMatches",
100
+ # teams / players
101
+ "Team",
102
+ "PlayerProfile",
103
+ "PlayerMatchStats",
104
+ "PlayerDetail",
105
+ # advanced
106
+ "PossessionValue",
107
+ "PassingStats",
108
+ "CarryingStats",
109
+ "TeamSCABreakdown",
110
+ "TeamCreation",
111
+ "PlayerSCABreakdown",
112
+ "PlayerCreation",
113
+ "PlayerDefending",
114
+ "TeamDefending",
115
+ "TerritoryStats",
116
+ "TempoStats",
117
+ "Goalkeeping",
118
+ "AdvancedTeamStat",
119
+ "AdvancedTeams",
120
+ "AdvancedPlayerStat",
121
+ "AdvancedPlayers",
122
+ "NetworkWindow",
123
+ "PassNetworkNode",
124
+ "PassNetworkEdge",
125
+ "PassNetwork",
126
+ "AdvancedNetworks",
127
+ ]
@@ -0,0 +1,79 @@
1
+ """Generic ``from_dict`` for the response dataclasses.
2
+
3
+ Every response model is a plain :func:`dataclasses.dataclass`. Rather than hand-
4
+ writing a constructor per model, :func:`from_dict` walks a class's fields and
5
+ their type hints and builds the object recursively. This keeps the models a
6
+ faithful, declarative mirror of the Go response structs.
7
+
8
+ Design notes:
9
+ * Annotations are real type objects (the model modules deliberately do *not*
10
+ ``from __future__ import annotations``), so ``field.type`` can be read
11
+ directly without resolving forward references.
12
+ * A JSON key that differs from the Python attribute name — e.g. the reserved
13
+ word ``from`` — is declared via ``field(metadata={"json": "from"})``.
14
+ * Unknown JSON keys are ignored and missing keys default to ``None``, so a
15
+ new field added server-side never breaks an older client.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import dataclasses
21
+ import typing
22
+ from typing import Any, TypeVar, Union, cast
23
+
24
+ _NoneType = type(None)
25
+
26
+ T = TypeVar("T")
27
+
28
+
29
+ def _unwrap_optional(tp: Any) -> tuple[Any, bool]:
30
+ """Return ``(inner, is_optional)`` for ``Optional[T]`` / ``Union[T, None]``."""
31
+ if typing.get_origin(tp) is Union:
32
+ args = [a for a in typing.get_args(tp) if a is not _NoneType]
33
+ if len(args) == 1:
34
+ return args[0], True
35
+ return tp, True
36
+ return tp, False
37
+
38
+
39
+ def _convert_value(tp: Any, value: Any) -> Any:
40
+ if value is None:
41
+ return None
42
+
43
+ tp, _ = _unwrap_optional(tp)
44
+ origin = typing.get_origin(tp)
45
+
46
+ if origin in (list, typing.List):
47
+ (elem_tp,) = typing.get_args(tp) or (Any,)
48
+ return [_convert_value(elem_tp, v) for v in value]
49
+
50
+ if origin in (dict, typing.Dict):
51
+ return value # raw pass-through (e.g. the variable player stats blob)
52
+
53
+ # ``isinstance(tp, type)`` is not redundant: it rules out the parameterised
54
+ # generics and special forms that also reach here, and it is what lets
55
+ # from_dict be typed as taking a class rather than an instance-or-class.
56
+ if isinstance(tp, type) and dataclasses.is_dataclass(tp) and isinstance(value, dict):
57
+ return from_dict(tp, value)
58
+
59
+ return value
60
+
61
+
62
+ def from_dict(cls: type[T], data: dict[str, Any]) -> T:
63
+ """Instantiate dataclass ``cls`` from an API ``data`` object.
64
+
65
+ Generic in ``cls`` so that callers get the concrete model type back rather
66
+ than ``Any`` — without it every resource method would silently widen its
67
+ own declared return type.
68
+ """
69
+ if not dataclasses.is_dataclass(cls):
70
+ raise TypeError(f"{cls!r} is not a dataclass")
71
+
72
+ kwargs: dict[str, Any] = {}
73
+ for f in dataclasses.fields(cls):
74
+ json_key = f.metadata.get("json", f.name)
75
+ if json_key in data:
76
+ kwargs[f.name] = _convert_value(f.type, data[json_key])
77
+ # The field walk above is what makes this safe; the constructor itself is
78
+ # untypeable from here, so the cast is where the generic contract is paid.
79
+ return cast(T, cast(Any, cls)(**kwargs))