py-understat 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.
- py_understat/__init__.py +36 -0
- py_understat/client.py +327 -0
- py_understat/exceptions.py +27 -0
- py_understat/models.py +228 -0
- py_understat-0.1.0.dist-info/METADATA +96 -0
- py_understat-0.1.0.dist-info/RECORD +8 -0
- py_understat-0.1.0.dist-info/WHEEL +4 -0
- py_understat-0.1.0.dist-info/licenses/LICENSE +21 -0
py_understat/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""An asynchronous, typed client for Understat football statistics."""
|
|
2
|
+
|
|
3
|
+
from .client import League, RetryPolicy, Season, UnderstatClient
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
InvalidIdentifierError,
|
|
6
|
+
PayloadValidationError,
|
|
7
|
+
RateLimitError,
|
|
8
|
+
ResourceNotFoundError,
|
|
9
|
+
UnderstatError,
|
|
10
|
+
UpstreamError,
|
|
11
|
+
)
|
|
12
|
+
from .models import (
|
|
13
|
+
LeagueSnapshot,
|
|
14
|
+
MatchSnapshot,
|
|
15
|
+
PlayerSnapshot,
|
|
16
|
+
TeamSnapshot,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"InvalidIdentifierError",
|
|
21
|
+
"League",
|
|
22
|
+
"LeagueSnapshot",
|
|
23
|
+
"MatchSnapshot",
|
|
24
|
+
"PayloadValidationError",
|
|
25
|
+
"PlayerSnapshot",
|
|
26
|
+
"RateLimitError",
|
|
27
|
+
"ResourceNotFoundError",
|
|
28
|
+
"RetryPolicy",
|
|
29
|
+
"Season",
|
|
30
|
+
"TeamSnapshot",
|
|
31
|
+
"UnderstatClient",
|
|
32
|
+
"UnderstatError",
|
|
33
|
+
"UpstreamError",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
py_understat/client.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""The asynchronous client and resource query API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from email.utils import parsedate_to_datetime
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
from typing import Any, Self, TypeVar
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from pydantic import ValidationError
|
|
17
|
+
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
InvalidIdentifierError,
|
|
20
|
+
PayloadValidationError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
ResourceNotFoundError,
|
|
23
|
+
UpstreamError,
|
|
24
|
+
)
|
|
25
|
+
from .models import LeagueSnapshot, MatchSnapshot, PlayerSnapshot, TeamSnapshot
|
|
26
|
+
|
|
27
|
+
_BASE_URL = "https://understat.com/"
|
|
28
|
+
_AJAX_HEADERS = {
|
|
29
|
+
"Accept": "application/json, text/javascript, */*; q=0.01",
|
|
30
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
31
|
+
}
|
|
32
|
+
_SEASON_PATTERN = re.compile(r"(?P<start>\d{4})/(?P<end>\d{4})")
|
|
33
|
+
_Snapshot = TypeVar(
|
|
34
|
+
"_Snapshot", LeagueSnapshot, TeamSnapshot, PlayerSnapshot, MatchSnapshot
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _package_version() -> str:
|
|
39
|
+
try:
|
|
40
|
+
return version("py-understat")
|
|
41
|
+
except PackageNotFoundError:
|
|
42
|
+
return "0.1.0"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class League(StrEnum):
|
|
46
|
+
"""The competitions available through Understat."""
|
|
47
|
+
|
|
48
|
+
EPL = "EPL"
|
|
49
|
+
LA_LIGA = "La_Liga"
|
|
50
|
+
BUNDESLIGA = "Bundesliga"
|
|
51
|
+
SERIE_A = "Serie_A"
|
|
52
|
+
LIGUE_1 = "Ligue_1"
|
|
53
|
+
RFPL = "RFPL"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True, init=False)
|
|
57
|
+
class Season:
|
|
58
|
+
"""A competition season represented as ``YYYY/YYYY+1``."""
|
|
59
|
+
|
|
60
|
+
start_year: int
|
|
61
|
+
|
|
62
|
+
def __init__(self, value: str) -> None:
|
|
63
|
+
match = _SEASON_PATTERN.fullmatch(value)
|
|
64
|
+
if match is None:
|
|
65
|
+
raise InvalidIdentifierError("season must use the YYYY/YYYY+1 format")
|
|
66
|
+
|
|
67
|
+
start_year = int(match["start"])
|
|
68
|
+
if int(match["end"]) != start_year + 1:
|
|
69
|
+
raise InvalidIdentifierError("season must end in the year after it starts")
|
|
70
|
+
if start_year < 2014:
|
|
71
|
+
raise InvalidIdentifierError(
|
|
72
|
+
"Understat data starts with the 2014/2015 season"
|
|
73
|
+
)
|
|
74
|
+
object.__setattr__(self, "start_year", start_year)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def label(self) -> str:
|
|
78
|
+
"""The public season label."""
|
|
79
|
+
return f"{self.start_year}/{self.start_year + 1}"
|
|
80
|
+
|
|
81
|
+
def __str__(self) -> str:
|
|
82
|
+
return self.label
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class RetryPolicy:
|
|
87
|
+
"""Retry configuration for transient, idempotent GET requests."""
|
|
88
|
+
|
|
89
|
+
max_attempts: int = 3
|
|
90
|
+
initial_delay: float = 0.25
|
|
91
|
+
max_delay: float = 4.0
|
|
92
|
+
|
|
93
|
+
def __post_init__(self) -> None:
|
|
94
|
+
if self.max_attempts < 1:
|
|
95
|
+
raise ValueError("max_attempts must be at least one")
|
|
96
|
+
if self.initial_delay < 0 or self.max_delay < 0:
|
|
97
|
+
raise ValueError("retry delays cannot be negative")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_DEFAULT_RETRY_POLICY = RetryPolicy()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class UnderstatClient:
|
|
104
|
+
"""Own an asynchronous HTTP client for querying Understat data.
|
|
105
|
+
|
|
106
|
+
Use as an async context manager to deterministically release HTTP resources.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
*,
|
|
112
|
+
timeout: float | httpx.Timeout | None = 20.0,
|
|
113
|
+
retry_policy: RetryPolicy | None = None,
|
|
114
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
self._retry_policy = retry_policy or _DEFAULT_RETRY_POLICY
|
|
117
|
+
self._http = httpx.AsyncClient(
|
|
118
|
+
base_url=_BASE_URL,
|
|
119
|
+
follow_redirects=True,
|
|
120
|
+
headers={
|
|
121
|
+
**_AJAX_HEADERS,
|
|
122
|
+
"User-Agent": f"py-understat/{_package_version()} (+https://github.com/sercan/py-understat)",
|
|
123
|
+
},
|
|
124
|
+
timeout=timeout,
|
|
125
|
+
transport=transport,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def __aenter__(self) -> Self:
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
async def __aexit__(self, *_: object) -> None:
|
|
132
|
+
await self.aclose()
|
|
133
|
+
|
|
134
|
+
async def aclose(self) -> None:
|
|
135
|
+
"""Close the underlying :class:`httpx.AsyncClient`."""
|
|
136
|
+
await self._http.aclose()
|
|
137
|
+
|
|
138
|
+
def league(self, league: League) -> _LeagueResource:
|
|
139
|
+
"""Select a League resource."""
|
|
140
|
+
if not isinstance(league, League):
|
|
141
|
+
raise InvalidIdentifierError("league must be a League enum member")
|
|
142
|
+
return _LeagueResource(self, league)
|
|
143
|
+
|
|
144
|
+
def team(self, handle: str) -> _TeamResource:
|
|
145
|
+
"""Select a Team resource by its exact Understat handle."""
|
|
146
|
+
_validate_team_handle(handle)
|
|
147
|
+
return _TeamResource(self, handle)
|
|
148
|
+
|
|
149
|
+
def player(self, player_id: int) -> _PlayerResource:
|
|
150
|
+
"""Select a Player resource by its positive Understat ID."""
|
|
151
|
+
return _PlayerResource(self, _validate_id(player_id, "player_id"))
|
|
152
|
+
|
|
153
|
+
def match(self, match_id: int) -> _MatchResource:
|
|
154
|
+
"""Select a Match resource by its positive Understat ID."""
|
|
155
|
+
return _MatchResource(self, _validate_id(match_id, "match_id"))
|
|
156
|
+
|
|
157
|
+
async def _get_snapshot(self, path: str, model: type[_Snapshot]) -> _Snapshot:
|
|
158
|
+
payload = await self._get_json(path)
|
|
159
|
+
try:
|
|
160
|
+
return model.model_validate(payload)
|
|
161
|
+
except ValidationError as error:
|
|
162
|
+
raise PayloadValidationError(
|
|
163
|
+
f"Understat returned an incompatible {model.__name__} payload"
|
|
164
|
+
) from error
|
|
165
|
+
|
|
166
|
+
async def _get_json(self, path: str) -> dict[str, Any]:
|
|
167
|
+
response: httpx.Response | None = None
|
|
168
|
+
last_error: Exception | None = None
|
|
169
|
+
|
|
170
|
+
for attempt in range(self._retry_policy.max_attempts):
|
|
171
|
+
try:
|
|
172
|
+
response = await self._http.get(path)
|
|
173
|
+
except httpx.RequestError as error:
|
|
174
|
+
last_error = error
|
|
175
|
+
if attempt + 1 == self._retry_policy.max_attempts:
|
|
176
|
+
break
|
|
177
|
+
await asyncio.sleep(self._backoff_delay(attempt))
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
if response.status_code == 404:
|
|
181
|
+
raise ResourceNotFoundError(
|
|
182
|
+
f"Understat has no resource at {response.request.url}"
|
|
183
|
+
)
|
|
184
|
+
if response.status_code == 429:
|
|
185
|
+
last_error = _response_error(response)
|
|
186
|
+
if attempt + 1 < self._retry_policy.max_attempts:
|
|
187
|
+
await asyncio.sleep(self._retry_delay(response, attempt))
|
|
188
|
+
continue
|
|
189
|
+
raise RateLimitError(
|
|
190
|
+
"Understat rate-limited the request after retries"
|
|
191
|
+
) from last_error
|
|
192
|
+
if response.status_code >= 500:
|
|
193
|
+
last_error = _response_error(response)
|
|
194
|
+
if attempt + 1 < self._retry_policy.max_attempts:
|
|
195
|
+
await asyncio.sleep(self._retry_delay(response, attempt))
|
|
196
|
+
continue
|
|
197
|
+
break
|
|
198
|
+
if response.is_error:
|
|
199
|
+
raise UpstreamError(
|
|
200
|
+
f"Understat returned HTTP {response.status_code}"
|
|
201
|
+
) from _response_error(response)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
payload = response.json()
|
|
205
|
+
except ValueError as error:
|
|
206
|
+
raise PayloadValidationError(
|
|
207
|
+
"Understat returned a non-JSON response"
|
|
208
|
+
) from error
|
|
209
|
+
if not isinstance(payload, dict):
|
|
210
|
+
raise PayloadValidationError(
|
|
211
|
+
"Understat returned a JSON payload other than an object"
|
|
212
|
+
)
|
|
213
|
+
return payload
|
|
214
|
+
|
|
215
|
+
raise UpstreamError(
|
|
216
|
+
"Understat could not fulfill the request after retries"
|
|
217
|
+
) from last_error
|
|
218
|
+
|
|
219
|
+
def _backoff_delay(self, attempt: int) -> float:
|
|
220
|
+
return min(
|
|
221
|
+
self._retry_policy.initial_delay * (2**attempt),
|
|
222
|
+
self._retry_policy.max_delay,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def _retry_delay(self, response: httpx.Response, attempt: int) -> float:
|
|
226
|
+
retry_after = response.headers.get("Retry-After")
|
|
227
|
+
if retry_after is None:
|
|
228
|
+
return self._backoff_delay(attempt)
|
|
229
|
+
try:
|
|
230
|
+
return min(float(retry_after), self._retry_policy.max_delay)
|
|
231
|
+
except ValueError:
|
|
232
|
+
try:
|
|
233
|
+
retry_at = parsedate_to_datetime(retry_after)
|
|
234
|
+
except TypeError, ValueError:
|
|
235
|
+
return self._backoff_delay(attempt)
|
|
236
|
+
if retry_at.tzinfo is None:
|
|
237
|
+
retry_at = retry_at.replace(tzinfo=UTC)
|
|
238
|
+
return min(
|
|
239
|
+
max(0.0, (retry_at - datetime.now(UTC)).total_seconds()),
|
|
240
|
+
self._retry_policy.max_delay,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True, slots=True)
|
|
245
|
+
class _LeagueResource:
|
|
246
|
+
client: UnderstatClient
|
|
247
|
+
league: League
|
|
248
|
+
|
|
249
|
+
async def get(self, season: Season | str) -> LeagueSnapshot:
|
|
250
|
+
"""Retrieve the League snapshot for ``season``."""
|
|
251
|
+
normalized_season = _as_season(season)
|
|
252
|
+
return await self.client._get_snapshot(
|
|
253
|
+
f"getLeagueData/{quote(self.league.value, safe='_')}/{normalized_season.start_year}",
|
|
254
|
+
LeagueSnapshot,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass(frozen=True, slots=True)
|
|
259
|
+
class _TeamResource:
|
|
260
|
+
client: UnderstatClient
|
|
261
|
+
handle: str
|
|
262
|
+
|
|
263
|
+
async def get(self, season: Season | str) -> TeamSnapshot:
|
|
264
|
+
"""Retrieve the Team snapshot for ``season``."""
|
|
265
|
+
normalized_season = _as_season(season)
|
|
266
|
+
return await self.client._get_snapshot(
|
|
267
|
+
f"getTeamData/{quote(self.handle, safe='_')}/{normalized_season.start_year}",
|
|
268
|
+
TeamSnapshot,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass(frozen=True, slots=True)
|
|
273
|
+
class _PlayerResource:
|
|
274
|
+
client: UnderstatClient
|
|
275
|
+
player_id: int
|
|
276
|
+
|
|
277
|
+
async def get(self) -> PlayerSnapshot:
|
|
278
|
+
"""Retrieve the Player snapshot."""
|
|
279
|
+
return await self.client._get_snapshot(
|
|
280
|
+
f"getPlayerData/{self.player_id}", PlayerSnapshot
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass(frozen=True, slots=True)
|
|
285
|
+
class _MatchResource:
|
|
286
|
+
client: UnderstatClient
|
|
287
|
+
match_id: int
|
|
288
|
+
|
|
289
|
+
async def get(self) -> MatchSnapshot:
|
|
290
|
+
"""Retrieve the Match snapshot."""
|
|
291
|
+
return await self.client._get_snapshot(
|
|
292
|
+
f"getMatchData/{self.match_id}", MatchSnapshot
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _as_season(value: Season | str) -> Season:
|
|
297
|
+
if isinstance(value, Season):
|
|
298
|
+
return value
|
|
299
|
+
if isinstance(value, str):
|
|
300
|
+
return Season(value)
|
|
301
|
+
raise InvalidIdentifierError("season must be a Season or YYYY/YYYY+1 string")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _validate_team_handle(handle: str) -> None:
|
|
305
|
+
if (
|
|
306
|
+
not isinstance(handle, str)
|
|
307
|
+
or not handle
|
|
308
|
+
or handle != handle.strip()
|
|
309
|
+
or "/" in handle
|
|
310
|
+
):
|
|
311
|
+
raise InvalidIdentifierError(
|
|
312
|
+
"team handle must be a non-empty, exact Understat URL handle"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _validate_id(value: int, name: str) -> int:
|
|
317
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
318
|
+
raise InvalidIdentifierError(f"{name} must be a positive integer")
|
|
319
|
+
return value
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _response_error(response: httpx.Response) -> httpx.HTTPStatusError:
|
|
323
|
+
try:
|
|
324
|
+
response.raise_for_status()
|
|
325
|
+
except httpx.HTTPStatusError as error:
|
|
326
|
+
return error
|
|
327
|
+
raise RuntimeError("response was expected to be an error")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Exceptions raised by :mod:`py_understat`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UnderstatError(Exception):
|
|
7
|
+
"""Base class for all package-level failures."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidIdentifierError(UnderstatError, ValueError):
|
|
11
|
+
"""A resource identifier or competition season is invalid locally."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ResourceNotFoundError(UnderstatError):
|
|
15
|
+
"""Understat has no resource for the requested identifier."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RateLimitError(UnderstatError):
|
|
19
|
+
"""Understat kept rate-limiting a request after retries."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UpstreamError(UnderstatError):
|
|
23
|
+
"""Understat or the network could not fulfill a request."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PayloadValidationError(UnderstatError):
|
|
27
|
+
"""Understat returned a payload incompatible with the public models."""
|
py_understat/models.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Immutable models for data published by Understat."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SourceModel(BaseModel):
|
|
14
|
+
"""Base model retaining fields added by Understat after this release."""
|
|
15
|
+
|
|
16
|
+
model_config = ConfigDict(extra="allow", frozen=True, populate_by_name=True)
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def extra(self) -> Mapping[str, Any]:
|
|
20
|
+
"""Source fields not represented by a named model attribute."""
|
|
21
|
+
return MappingProxyType(self.model_extra or {})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TeamReference(SourceModel):
|
|
25
|
+
"""A team referenced from a match."""
|
|
26
|
+
|
|
27
|
+
id: int
|
|
28
|
+
title: str
|
|
29
|
+
short_title: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Score(SourceModel):
|
|
33
|
+
"""A value split by home and away team."""
|
|
34
|
+
|
|
35
|
+
home: int = Field(alias="h")
|
|
36
|
+
away: int = Field(alias="a")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ExpectedGoals(SourceModel):
|
|
40
|
+
"""Expected goals split by home and away team."""
|
|
41
|
+
|
|
42
|
+
home: float = Field(alias="h")
|
|
43
|
+
away: float = Field(alias="a")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Forecast(SourceModel):
|
|
47
|
+
"""Pre-match home win, draw, and away win probabilities."""
|
|
48
|
+
|
|
49
|
+
home_win: float = Field(alias="w")
|
|
50
|
+
draw: float = Field(alias="d")
|
|
51
|
+
away_win: float = Field(alias="l")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MatchRecord(SourceModel):
|
|
55
|
+
"""A match listed in a League or Team snapshot."""
|
|
56
|
+
|
|
57
|
+
id: int
|
|
58
|
+
is_result: bool = Field(alias="isResult")
|
|
59
|
+
home: TeamReference = Field(alias="h")
|
|
60
|
+
away: TeamReference = Field(alias="a")
|
|
61
|
+
goals: Score
|
|
62
|
+
expected_goals: ExpectedGoals = Field(alias="xG")
|
|
63
|
+
played_at: datetime = Field(alias="datetime")
|
|
64
|
+
forecast: Forecast
|
|
65
|
+
side: str | None = None
|
|
66
|
+
result: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PlayerStatistic(SourceModel):
|
|
70
|
+
"""A player's aggregate statistics for one source view."""
|
|
71
|
+
|
|
72
|
+
position: str
|
|
73
|
+
games: int
|
|
74
|
+
goals: int
|
|
75
|
+
shots: int
|
|
76
|
+
minutes: int = Field(alias="time")
|
|
77
|
+
expected_goals: float = Field(alias="xG")
|
|
78
|
+
assists: int
|
|
79
|
+
expected_assists: float = Field(alias="xA")
|
|
80
|
+
key_passes: int
|
|
81
|
+
non_penalty_goals: int = Field(alias="npg")
|
|
82
|
+
non_penalty_expected_goals: float = Field(alias="npxG")
|
|
83
|
+
expected_goals_chain: float = Field(alias="xGChain")
|
|
84
|
+
expected_goals_buildup: float = Field(alias="xGBuildup")
|
|
85
|
+
id: int | None = None
|
|
86
|
+
player_name: str | None = None
|
|
87
|
+
team_title: str | None = None
|
|
88
|
+
season: int | None = None
|
|
89
|
+
team: str | None = None
|
|
90
|
+
yellow_cards: int | None = None
|
|
91
|
+
red_cards: int | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PlayerMatch(SourceModel):
|
|
95
|
+
"""A player's statistics from one match."""
|
|
96
|
+
|
|
97
|
+
id: int
|
|
98
|
+
season: int
|
|
99
|
+
date: date
|
|
100
|
+
position: str
|
|
101
|
+
goals: int
|
|
102
|
+
shots: int
|
|
103
|
+
minutes: int = Field(alias="time")
|
|
104
|
+
expected_goals: float = Field(alias="xG")
|
|
105
|
+
assists: int
|
|
106
|
+
expected_assists: float = Field(alias="xA")
|
|
107
|
+
key_passes: int
|
|
108
|
+
non_penalty_goals: int = Field(alias="npg")
|
|
109
|
+
non_penalty_expected_goals: float = Field(alias="npxG")
|
|
110
|
+
expected_goals_chain: float = Field(alias="xGChain")
|
|
111
|
+
expected_goals_buildup: float = Field(alias="xGBuildup")
|
|
112
|
+
home_team: str = Field(alias="h_team")
|
|
113
|
+
away_team: str = Field(alias="a_team")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TeamHistoryRecord(SourceModel):
|
|
117
|
+
"""One historical team performance used in a League snapshot."""
|
|
118
|
+
|
|
119
|
+
date: datetime
|
|
120
|
+
home_or_away: str = Field(alias="h_a")
|
|
121
|
+
result: str
|
|
122
|
+
scored: int
|
|
123
|
+
missed: int
|
|
124
|
+
expected_goals: float = Field(alias="xG")
|
|
125
|
+
expected_goals_against: float = Field(alias="xGA")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TeamSeason(SourceModel):
|
|
129
|
+
"""A team's season data inside a League snapshot."""
|
|
130
|
+
|
|
131
|
+
id: int
|
|
132
|
+
title: str
|
|
133
|
+
history: tuple[TeamHistoryRecord, ...]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class AgainstStatistics(SourceModel):
|
|
137
|
+
"""The opponent side of one team statistic category."""
|
|
138
|
+
|
|
139
|
+
shots: int
|
|
140
|
+
goals: int
|
|
141
|
+
expected_goals: float = Field(alias="xG")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TeamStatistics(SourceModel):
|
|
145
|
+
"""One Team statistic category, such as ``OpenPlay``."""
|
|
146
|
+
|
|
147
|
+
shots: int
|
|
148
|
+
goals: int
|
|
149
|
+
expected_goals: float = Field(alias="xG")
|
|
150
|
+
against: AgainstStatistics
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class PlayerProfile(SourceModel):
|
|
154
|
+
"""The identity information Understat publishes for a player."""
|
|
155
|
+
|
|
156
|
+
id: int
|
|
157
|
+
name: str
|
|
158
|
+
favorite_position: str | None = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Shot(SourceModel):
|
|
162
|
+
"""One shot event."""
|
|
163
|
+
|
|
164
|
+
id: int
|
|
165
|
+
minute: int
|
|
166
|
+
result: str
|
|
167
|
+
x: float = Field(alias="X")
|
|
168
|
+
y: float = Field(alias="Y")
|
|
169
|
+
expected_goals: float = Field(alias="xG")
|
|
170
|
+
player: str
|
|
171
|
+
home_or_away: str = Field(alias="h_a")
|
|
172
|
+
player_id: int
|
|
173
|
+
situation: str
|
|
174
|
+
season: int
|
|
175
|
+
shot_type: str = Field(alias="shotType")
|
|
176
|
+
match_id: int
|
|
177
|
+
home_team: str = Field(alias="h_team")
|
|
178
|
+
away_team: str = Field(alias="a_team")
|
|
179
|
+
date: datetime
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class RosterEntry(SourceModel):
|
|
183
|
+
"""A player's appearance record in a Match snapshot."""
|
|
184
|
+
|
|
185
|
+
id: int
|
|
186
|
+
player_id: int
|
|
187
|
+
team_id: int
|
|
188
|
+
player: str
|
|
189
|
+
position: str
|
|
190
|
+
minutes: int = Field(alias="time")
|
|
191
|
+
goals: int
|
|
192
|
+
expected_goals: float = Field(alias="xG")
|
|
193
|
+
assists: int
|
|
194
|
+
expected_assists: float = Field(alias="xA")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class LeagueSnapshot(SourceModel):
|
|
198
|
+
"""The complete League resource snapshot for one competition season."""
|
|
199
|
+
|
|
200
|
+
teams: Mapping[int, TeamSeason]
|
|
201
|
+
players: tuple[PlayerStatistic, ...]
|
|
202
|
+
matches: tuple[MatchRecord, ...] = Field(alias="dates")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class TeamSnapshot(SourceModel):
|
|
206
|
+
"""The complete Team resource snapshot for one competition season."""
|
|
207
|
+
|
|
208
|
+
players: tuple[PlayerStatistic, ...]
|
|
209
|
+
matches: tuple[MatchRecord, ...] = Field(alias="dates")
|
|
210
|
+
statistics: Mapping[str, Mapping[str, TeamStatistics]]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class PlayerSnapshot(SourceModel):
|
|
214
|
+
"""The complete Player resource snapshot."""
|
|
215
|
+
|
|
216
|
+
player: PlayerProfile
|
|
217
|
+
matches: tuple[PlayerMatch, ...]
|
|
218
|
+
groups: Mapping[str, Any]
|
|
219
|
+
shots: tuple[Shot, ...]
|
|
220
|
+
positions: tuple[str, ...] = Field(default=(), alias="positionsList")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class MatchSnapshot(SourceModel):
|
|
224
|
+
"""The complete Match resource snapshot."""
|
|
225
|
+
|
|
226
|
+
rosters: Mapping[str, Mapping[int, RosterEntry]]
|
|
227
|
+
shots: Mapping[str, tuple[Shot, ...]]
|
|
228
|
+
template: Mapping[str, Any] = Field(alias="tmpl")
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: py-understat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An asynchronous client for Understat football statistics
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.14
|
|
7
|
+
Requires-Dist: httpx>=0.28.1
|
|
8
|
+
Requires-Dist: pydantic>=2.13.5
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# py-understat
|
|
12
|
+
|
|
13
|
+
An asynchronous, typed client for football statistics published by [Understat](https://understat.com/). This is an unofficial client and is not affiliated with Understat.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv add py-understat
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
From a checkout, install the package and its development tools with:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv sync --all-groups
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Query data
|
|
28
|
+
|
|
29
|
+
`UnderstatClient` owns its HTTP resources. Use it as an async context manager.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import asyncio
|
|
33
|
+
|
|
34
|
+
from py_understat import League, UnderstatClient
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def main() -> None:
|
|
38
|
+
async with UnderstatClient() as client:
|
|
39
|
+
premier_league = await client.league(League.EPL).get("2025/2026")
|
|
40
|
+
|
|
41
|
+
top_scorer = max(premier_league.players, key=lambda player: player.goals)
|
|
42
|
+
print(top_scorer.player_name, top_scorer.goals)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
asyncio.run(main())
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Each `get()` call returns the complete snapshot Understat supplies for that resource:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
async with UnderstatClient() as client:
|
|
52
|
+
league = await client.league(League.BUNDESLIGA).get("2025/2026")
|
|
53
|
+
team = await client.team("Bayern_Munich").get("2025/2026")
|
|
54
|
+
player = await client.player(8260).get()
|
|
55
|
+
match = await client.match(28778).get()
|
|
56
|
+
|
|
57
|
+
league.players # PlayerStatistic records
|
|
58
|
+
league.matches # MatchRecord records
|
|
59
|
+
league.teams # TeamSeason records keyed by Understat team ID
|
|
60
|
+
team.statistics # Typed team statistic categories
|
|
61
|
+
player.matches # PlayerMatch records
|
|
62
|
+
player.shots # Shot records
|
|
63
|
+
match.rosters # RosterEntry records grouped by home/away side
|
|
64
|
+
match.shots # Shot records grouped by home/away side
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Identifiers
|
|
68
|
+
|
|
69
|
+
- `League` is an enum containing Understat's six supported competitions.
|
|
70
|
+
- Team queries take the exact Understat team handle, such as `"Manchester_United"`.
|
|
71
|
+
- Player and Match queries take a positive Understat integer ID.
|
|
72
|
+
- League and Team queries take a competition season written as `"YYYY/YYYY+1"`, for example `"2025/2026"`. `Season("2025/2026")` is available when a reusable value is useful.
|
|
73
|
+
|
|
74
|
+
Invalid identifiers fail locally with `InvalidIdentifierError` before making a request.
|
|
75
|
+
|
|
76
|
+
## Data and failures
|
|
77
|
+
|
|
78
|
+
Known fields are normalized to native Python values: IDs and counts become `int`, expected-goal values become `float`, and source timestamps become `datetime`. Models are frozen, and source fields that the package does not yet name are available through each model's read-only `extra` mapping.
|
|
79
|
+
|
|
80
|
+
The client retries temporary transport failures, `429`, and `5xx` responses with bounded exponential backoff. It honors `Retry-After` up to the configured maximum delay. Catch package-level errors rather than HTTPX implementation errors:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from py_understat import RateLimitError, ResourceNotFoundError, UnderstatError
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
async with UnderstatClient() as client:
|
|
87
|
+
snapshot = await client.player(8260).get()
|
|
88
|
+
except ResourceNotFoundError:
|
|
89
|
+
print("Unknown Understat player")
|
|
90
|
+
except RateLimitError:
|
|
91
|
+
print("Understat still rate-limited the request after retries")
|
|
92
|
+
except UnderstatError as error:
|
|
93
|
+
print(f"Understat is unavailable: {error}")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The client sends an identifying user agent. It intentionally has no automatic cache or global rate limiter; callers needing either should apply them around immutable snapshots.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
py_understat/__init__.py,sha256=vPYyx4R1qk_Jf_rEOZ7EoOToWFRMA5eX7X8516-0nd4,755
|
|
2
|
+
py_understat/client.py,sha256=5wuLQlJeBCbU1bkEFqE6ObuLMl0zobTohA93cMCct9U,10851
|
|
3
|
+
py_understat/exceptions.py,sha256=4fvkFU1kB8P21AOPpRE-v17f_-CVsk3G0cDIFO8KMeE,750
|
|
4
|
+
py_understat/models.py,sha256=zRtKWBqDYWoKs5M1Ml6hKAf0rb2TtIp6lWugXmuL_fk,6084
|
|
5
|
+
py_understat-0.1.0.dist-info/METADATA,sha256=RtE4GVH6kMMH7anI31b5TasoulNtqSR_v20yvpSdAHI,3492
|
|
6
|
+
py_understat-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
7
|
+
py_understat-0.1.0.dist-info/licenses/LICENSE,sha256=-Ye8EqYK9M6k8m0N23dg1NA_6jO0_Wb1RhaYmHTvhDY,1069
|
|
8
|
+
py_understat-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 buenavista62
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|