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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.