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 +44 -0
- pitchapi/_transport.py +101 -0
- pitchapi/client.py +184 -0
- pitchapi/errors.py +128 -0
- pitchapi/models/__init__.py +127 -0
- pitchapi/models/_convert.py +79 -0
- pitchapi/models/advanced.py +279 -0
- pitchapi/models/common.py +51 -0
- pitchapi/models/event.py +27 -0
- pitchapi/models/h2h.py +30 -0
- pitchapi/models/league.py +62 -0
- pitchapi/models/lineup.py +41 -0
- pitchapi/models/match.py +48 -0
- pitchapi/models/momentum.py +16 -0
- pitchapi/models/player.py +42 -0
- pitchapi/models/shot.py +54 -0
- pitchapi/models/stats.py +35 -0
- pitchapi/models/team.py +13 -0
- pitchapi/py.typed +0 -0
- pitchapi/resources/__init__.py +20 -0
- pitchapi/resources/_base.py +52 -0
- pitchapi/resources/date.py +40 -0
- pitchapi/resources/leagues.py +61 -0
- pitchapi/resources/matches.py +163 -0
- pitchapi/resources/players.py +21 -0
- pitchapi/resources/teams.py +17 -0
- pitchapi-0.1.0.dist-info/METADATA +178 -0
- pitchapi-0.1.0.dist-info/RECORD +30 -0
- pitchapi-0.1.0.dist-info/WHEEL +4 -0
- pitchapi-0.1.0.dist-info/licenses/LICENSE +21 -0
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))
|