tacoscore 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.
Files changed (58) hide show
  1. tacoscore/__init__.py +149 -0
  2. tacoscore/_serialize.py +61 -0
  3. tacoscore/async_client.py +411 -0
  4. tacoscore/async_http.py +5 -0
  5. tacoscore/async_rate_limiter.py +64 -0
  6. tacoscore/cli.py +583 -0
  7. tacoscore/client.py +452 -0
  8. tacoscore/coordinates.py +112 -0
  9. tacoscore/dataframe.py +315 -0
  10. tacoscore/exceptions.py +38 -0
  11. tacoscore/extraction.py +131 -0
  12. tacoscore/help_catalog.py +580 -0
  13. tacoscore/http.py +262 -0
  14. tacoscore/interactive_help.py +305 -0
  15. tacoscore/models/__init__.py +5 -0
  16. tacoscore/models/actions.py +86 -0
  17. tacoscore/models/common.py +48 -0
  18. tacoscore/models/event.py +54 -0
  19. tacoscore/models/graph.py +30 -0
  20. tacoscore/models/h2h.py +24 -0
  21. tacoscore/models/heatmap.py +52 -0
  22. tacoscore/models/incidents.py +55 -0
  23. tacoscore/models/lineups.py +85 -0
  24. tacoscore/models/match_detail.py +69 -0
  25. tacoscore/models/player.py +53 -0
  26. tacoscore/models/pregame_form.py +26 -0
  27. tacoscore/models/profile.py +48 -0
  28. tacoscore/models/shotmap.py +134 -0
  29. tacoscore/models/standings.py +51 -0
  30. tacoscore/models/statistics.py +186 -0
  31. tacoscore/models/team.py +29 -0
  32. tacoscore/models/team_rankings.py +32 -0
  33. tacoscore/models/tournament.py +230 -0
  34. tacoscore/models/transfers.py +31 -0
  35. tacoscore/parsers/__init__.py +59 -0
  36. tacoscore/parsers/actions.py +54 -0
  37. tacoscore/parsers/common.py +84 -0
  38. tacoscore/parsers/graph.py +24 -0
  39. tacoscore/parsers/h2h.py +30 -0
  40. tacoscore/parsers/heatmap.py +21 -0
  41. tacoscore/parsers/incidents.py +50 -0
  42. tacoscore/parsers/lineups.py +48 -0
  43. tacoscore/parsers/match_detail.py +82 -0
  44. tacoscore/parsers/pregame_form.py +42 -0
  45. tacoscore/parsers/profile.py +60 -0
  46. tacoscore/parsers/shotmap.py +69 -0
  47. tacoscore/parsers/standings.py +58 -0
  48. tacoscore/parsers/statistics.py +181 -0
  49. tacoscore/parsers/team_rankings.py +34 -0
  50. tacoscore/parsers/tournament.py +120 -0
  51. tacoscore/parsers/transfers.py +51 -0
  52. tacoscore/py.typed +0 -0
  53. tacoscore/rate_limiter.py +57 -0
  54. tacoscore-0.1.0.dist-info/METADATA +973 -0
  55. tacoscore-0.1.0.dist-info/RECORD +58 -0
  56. tacoscore-0.1.0.dist-info/WHEEL +4 -0
  57. tacoscore-0.1.0.dist-info/entry_points.txt +2 -0
  58. tacoscore-0.1.0.dist-info/licenses/LICENSE +21 -0
tacoscore/__init__.py ADDED
@@ -0,0 +1,149 @@
1
+ """TacosScore — football analytics from Sofascore.
2
+
3
+ Public API surface — import from here for everything you'll typically need.
4
+ """
5
+
6
+ from tacoscore.async_client import AsyncSofascoreClient
7
+ from tacoscore.client import SofascoreClient
8
+ from tacoscore.extraction import (
9
+ ANALYTICS_EVENT_ENDPOINTS,
10
+ MATCH_EXTRACTION_ORDER,
11
+ strip_ui_fields,
12
+ )
13
+ from tacoscore.help_catalog import format_help_entry, get_help_entry, list_help_entries
14
+ from tacoscore.interactive_help import show_help
15
+ from tacoscore.dataframe import (
16
+ actions_to_dataframe,
17
+ graph_to_dataframe,
18
+ heatmap_to_dataframe,
19
+ incidents_to_dataframe,
20
+ lineups_to_dataframe,
21
+ matches_to_dataframe,
22
+ player_stats_to_dataframe,
23
+ rounds_to_dataframe,
24
+ season_player_stats_to_dataframe,
25
+ shotmap_to_dataframe,
26
+ standings_to_dataframe,
27
+ team_stats_to_dataframe,
28
+ )
29
+ from tacoscore.exceptions import (
30
+ APIError,
31
+ NotFoundError,
32
+ ParseError,
33
+ RateLimitError,
34
+ SofascoreError,
35
+ )
36
+ from tacoscore.models.actions import Action, ActionStream
37
+ from tacoscore.models.common import Country, Sport
38
+ from tacoscore.models.event import FullMatch, PlayerMatchData
39
+ from tacoscore.models.graph import GraphPoint, MatchGraph
40
+ from tacoscore.models.h2h import DuelRecord, HeadToHead
41
+ from tacoscore.models.heatmap import Heatmap, HeatmapPoint
42
+ from tacoscore.models.incidents import Incident, Incidents
43
+ from tacoscore.models.lineups import LineupEntry, Lineups, LineupSide
44
+ from tacoscore.models.match_detail import EventDetail, EventManagers, Manager, Referee, Venue
45
+ from tacoscore.models.player import Player
46
+ from tacoscore.models.pregame_form import PregameForm, TeamFormSnapshot
47
+ from tacoscore.models.profile import PlayerProfile, SeasonPlayerStatistics, TeamProfile
48
+ from tacoscore.models.shotmap import Shot, Shotmap
49
+ from tacoscore.models.standings import StandingRow, Standings, StandingsTable
50
+ from tacoscore.models.statistics import (
51
+ PlayerStats,
52
+ SinglePlayerStats,
53
+ TeamStatistics,
54
+ TeamStatValue,
55
+ )
56
+ from tacoscore.models.team import Team
57
+ from tacoscore.models.team_rankings import TeamRankingEntry, TeamRankings
58
+ from tacoscore.models.transfers import Transfer, TransferHistory
59
+ from tacoscore.models.tournament import (
60
+ MatchList,
61
+ MatchScore,
62
+ MatchSummary,
63
+ Round,
64
+ RoundList,
65
+ )
66
+
67
+ __version__ = "0.1.0"
68
+
69
+ __all__ = [ # noqa: RUF022 -- grouped by category for readability
70
+ # Client
71
+ "SofascoreClient",
72
+ "AsyncSofascoreClient",
73
+ "ANALYTICS_EVENT_ENDPOINTS",
74
+ "MATCH_EXTRACTION_ORDER",
75
+ "strip_ui_fields",
76
+ "format_help_entry",
77
+ "get_help_entry",
78
+ "list_help_entries",
79
+ "show_help",
80
+ # Exceptions
81
+ "SofascoreError",
82
+ "APIError",
83
+ "RateLimitError",
84
+ "NotFoundError",
85
+ "ParseError",
86
+ # Models
87
+ "Action",
88
+ "ActionStream",
89
+ "Country",
90
+ "DuelRecord",
91
+ "EventDetail",
92
+ "EventManagers",
93
+ "FullMatch",
94
+ "GraphPoint",
95
+ "HeadToHead",
96
+ "Heatmap",
97
+ "HeatmapPoint",
98
+ "Incident",
99
+ "Incidents",
100
+ "LineupEntry",
101
+ "Lineups",
102
+ "LineupSide",
103
+ "Manager",
104
+ "MatchGraph",
105
+ "MatchList",
106
+ "MatchScore",
107
+ "MatchSummary",
108
+ "Player",
109
+ "PlayerMatchData",
110
+ "PregameForm",
111
+ "PlayerProfile",
112
+ "PlayerStats",
113
+ "Referee",
114
+ "Round",
115
+ "RoundList",
116
+ "SeasonPlayerStatistics",
117
+ "Shot",
118
+ "Shotmap",
119
+ "SinglePlayerStats",
120
+ "Sport",
121
+ "StandingRow",
122
+ "Standings",
123
+ "StandingsTable",
124
+ "Team",
125
+ "TeamFormSnapshot",
126
+ "TeamProfile",
127
+ "TeamRankingEntry",
128
+ "TeamRankings",
129
+ "Transfer",
130
+ "TransferHistory",
131
+ "TeamStatistics",
132
+ "TeamStatValue",
133
+ "Venue",
134
+ # DataFrame export (requires pandas)
135
+ "actions_to_dataframe",
136
+ "graph_to_dataframe",
137
+ "heatmap_to_dataframe",
138
+ "incidents_to_dataframe",
139
+ "lineups_to_dataframe",
140
+ "matches_to_dataframe",
141
+ "player_stats_to_dataframe",
142
+ "rounds_to_dataframe",
143
+ "season_player_stats_to_dataframe",
144
+ "shotmap_to_dataframe",
145
+ "standings_to_dataframe",
146
+ "team_stats_to_dataframe",
147
+ # Meta
148
+ "__version__",
149
+ ]
@@ -0,0 +1,61 @@
1
+ """Internal: convert dataclass models to JSON-compatible dicts.
2
+
3
+ Used by the CLI to serialize parsed models. The ``raw`` field on each model
4
+ is excluded by default to give clean output; pass ``include_raw=True`` to
5
+ preserve the original API payload nested under each model.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ from datetime import date, datetime
12
+ from typing import Any
13
+
14
+ from tacoscore.extraction import strip_ui_fields
15
+
16
+
17
+ def to_jsonable(
18
+ obj: Any,
19
+ include_raw: bool = False,
20
+ *,
21
+ analytics_only: bool = False,
22
+ ) -> Any:
23
+ """Recursively convert ``obj`` into JSON-serializable primitives.
24
+
25
+ Args:
26
+ obj: A dataclass, dict, list, tuple, date/datetime, or primitive.
27
+ include_raw: If False (default), strip ``raw`` fields from any
28
+ dataclass to give a clean typed view. If True, keep them.
29
+ analytics_only: If True, strip UI-only Sofascore keys per
30
+ ``docs/sofascore-api-reference.md`` (DROP fields).
31
+
32
+ Returns:
33
+ A structure that ``json.dumps`` can serialize directly.
34
+ """
35
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
36
+ result: dict[str, Any] = {}
37
+ for f in dataclasses.fields(obj):
38
+ if not include_raw and f.name == "raw":
39
+ continue
40
+ result[f.name] = to_jsonable(
41
+ getattr(obj, f.name), include_raw, analytics_only=analytics_only
42
+ )
43
+ return result
44
+
45
+ if isinstance(obj, dict):
46
+ cleaned = {
47
+ str(k): to_jsonable(v, include_raw, analytics_only=analytics_only)
48
+ for k, v in obj.items()
49
+ }
50
+ return strip_ui_fields(cleaned) if analytics_only else cleaned
51
+
52
+ if isinstance(obj, (list, tuple)):
53
+ items = [
54
+ to_jsonable(x, include_raw, analytics_only=analytics_only) for x in obj
55
+ ]
56
+ return strip_ui_fields(items) if analytics_only else items
57
+
58
+ if isinstance(obj, (date, datetime)):
59
+ return obj.isoformat()
60
+
61
+ return obj
@@ -0,0 +1,411 @@
1
+ """Async client for the Sofascore API.
2
+
3
+ Mirror of :class:`tacoscore.client.SofascoreClient` with ``async def``
4
+ methods and an ``httpx.AsyncClient`` under the hood. Use this when you need
5
+ to integrate with an existing asyncio application (FastAPI, Discord bots,
6
+ background jobs).
7
+
8
+ Important:
9
+ Async **does not make scraping faster** — the rate limiter is the
10
+ bottleneck and it's shared across all concurrent calls. The benefit is
11
+ that *other coroutines* can do work while waiting for the next request
12
+ slot, which matters in event-driven applications but not in a simple
13
+ extraction script. For batch extraction, the sync client is just as
14
+ fast and simpler.
15
+
16
+ Example::
17
+
18
+ import asyncio
19
+ from tacoscore import AsyncSofascoreClient
20
+
21
+ async def main():
22
+ async with AsyncSofascoreClient() as client:
23
+ stats = await client.event_statistics(15186861)
24
+ lineups = await client.event_lineups(15186861)
25
+ print(f"{lineups.home.formation} vs {lineups.away.formation}")
26
+
27
+ asyncio.run(main())
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import contextlib
34
+ from typing import Any
35
+
36
+ from tacoscore.async_http import AsyncHttpTransport
37
+ from tacoscore.async_rate_limiter import AsyncRateLimiter
38
+ from tacoscore.client import DEFAULT_BASE_URL
39
+ from tacoscore.extraction import (
40
+ should_fetch_heatmap,
41
+ should_fetch_shotmap,
42
+ should_fetch_spatial,
43
+ )
44
+ from tacoscore.exceptions import NotFoundError
45
+ from tacoscore.models.actions import ActionStream
46
+ from tacoscore.models.event import FullMatch, PlayerMatchData
47
+ from tacoscore.models.graph import MatchGraph
48
+ from tacoscore.models.h2h import HeadToHead
49
+ from tacoscore.models.heatmap import Heatmap
50
+ from tacoscore.models.incidents import Incidents
51
+ from tacoscore.models.lineups import Lineups
52
+ from tacoscore.models.match_detail import EventDetail, EventManagers
53
+ from tacoscore.models.pregame_form import PregameForm
54
+ from tacoscore.models.profile import PlayerProfile, SeasonPlayerStatistics, TeamProfile
55
+ from tacoscore.models.shotmap import Shotmap
56
+ from tacoscore.models.standings import Standings
57
+ from tacoscore.models.statistics import SinglePlayerStats, TeamStatistics
58
+ from tacoscore.models.team_rankings import TeamRankings
59
+ from tacoscore.models.tournament import MatchList, RoundList
60
+ from tacoscore.models.transfers import TransferHistory
61
+ from tacoscore.parsers import (
62
+ parse_action_stream,
63
+ parse_event_detail,
64
+ parse_event_managers,
65
+ parse_head_to_head,
66
+ parse_heatmap,
67
+ parse_incidents,
68
+ parse_lineups,
69
+ parse_match_graph,
70
+ parse_match_list,
71
+ parse_player_profile,
72
+ parse_pregame_form,
73
+ parse_round_list,
74
+ parse_season_player_statistics,
75
+ parse_shotmap,
76
+ parse_single_player_stats,
77
+ parse_standings,
78
+ parse_team_profile,
79
+ parse_team_rankings,
80
+ parse_team_statistics,
81
+ parse_transfer_history,
82
+ )
83
+
84
+
85
+ class AsyncSofascoreClient:
86
+ """Async client for the Sofascore football API.
87
+
88
+ Construction options match :class:`SofascoreClient`. Use as an async
89
+ context manager so the connection pool closes cleanly.
90
+
91
+ Example:
92
+ >>> async with AsyncSofascoreClient(rate_limit_seconds=2.0) as client:
93
+ ... heatmap = await client.player_heatmap(15186861, 868812)
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ base_url: str = DEFAULT_BASE_URL,
99
+ rate_limit_seconds: float = 1.5,
100
+ rate_jitter_seconds: float = 0.5,
101
+ timeout: float = 30.0,
102
+ max_retries: int = 3,
103
+ user_agent: str | None = None,
104
+ ) -> None:
105
+ self._rate_limiter = AsyncRateLimiter(
106
+ min_interval=rate_limit_seconds,
107
+ jitter=rate_jitter_seconds,
108
+ )
109
+ self._http = AsyncHttpTransport(
110
+ base_url=base_url,
111
+ rate_limiter=self._rate_limiter,
112
+ timeout=timeout,
113
+ max_retries=max_retries,
114
+ user_agent=user_agent,
115
+ )
116
+
117
+ # ----- low-level access -----
118
+
119
+ async def get_raw(self, path: str) -> dict[str, Any]:
120
+ """Issue a GET against the API and return the raw JSON dict."""
121
+ return await self._http.get(path)
122
+
123
+ # ----- match-level endpoints -----
124
+
125
+ async def event(self, event_id: int) -> EventDetail:
126
+ """Match metadata — teams, score, venue, referee, attendance."""
127
+ raw = await self._http.get(f"event/{event_id}")
128
+ return parse_event_detail(raw)
129
+
130
+ async def event_incidents(self, event_id: int) -> Incidents:
131
+ """Match timeline — goals, cards, substitutions, period markers."""
132
+ raw = await self._http.get(f"event/{event_id}/incidents")
133
+ return parse_incidents(raw)
134
+
135
+ async def event_graph(self, event_id: int) -> MatchGraph:
136
+ """Attack momentum graph (minute-by-minute pressure values)."""
137
+ raw = await self._http.get(f"event/{event_id}/graph")
138
+ return parse_match_graph(raw)
139
+
140
+ async def event_managers(self, event_id: int) -> EventManagers:
141
+ """Managers for both teams in a match."""
142
+ raw = await self._http.get(f"event/{event_id}/managers")
143
+ return parse_event_managers(raw)
144
+
145
+ async def event_statistics(self, event_id: int) -> TeamStatistics:
146
+ """Team statistics by period for one match."""
147
+ raw = await self._http.get(f"event/{event_id}/statistics")
148
+ return parse_team_statistics(raw)
149
+
150
+ async def event_lineups(self, event_id: int) -> Lineups:
151
+ """Lineups and per-player match statistics."""
152
+ raw = await self._http.get(f"event/{event_id}/lineups")
153
+ return parse_lineups(raw)
154
+
155
+ async def event_shotmap(self, event_id: int) -> Shotmap:
156
+ """All shots in a match (every player)."""
157
+ raw = await self._http.get(f"event/{event_id}/shotmap")
158
+ return parse_shotmap(raw)
159
+
160
+ async def event_h2h(self, event_id: int) -> HeadToHead:
161
+ """Head-to-head win/draw record."""
162
+ raw = await self._http.get(f"event/{event_id}/h2h")
163
+ return parse_head_to_head(raw)
164
+
165
+ async def event_pregame_form(self, event_id: int) -> PregameForm:
166
+ """Pre-match form for both teams."""
167
+ raw = await self._http.get(f"event/{event_id}/pregame-form")
168
+ return parse_pregame_form(raw)
169
+
170
+ # ----- player-level endpoints -----
171
+
172
+ async def player_heatmap(self, event_id: int, player_id: int) -> Heatmap:
173
+ """Touch heatmap for one player in one match."""
174
+ raw = await self._http.get(f"event/{event_id}/player/{player_id}/heatmap")
175
+ return parse_heatmap(raw)
176
+
177
+ async def player_actions(self, event_id: int, player_id: int) -> ActionStream:
178
+ """Action stream (passes, dribbles, defensive, ball-carries)."""
179
+ raw = await self._http.get(
180
+ f"event/{event_id}/player/{player_id}/rating-breakdown"
181
+ )
182
+ return parse_action_stream(raw)
183
+
184
+ async def player_shotmap(self, event_id: int, player_id: int) -> Shotmap:
185
+ """Shot map for one player in one match."""
186
+ raw = await self._http.get(f"event/{event_id}/shotmap/player/{player_id}")
187
+ return parse_shotmap(raw)
188
+
189
+ async def player_statistics(
190
+ self, event_id: int, player_id: int
191
+ ) -> SinglePlayerStats:
192
+ """Single-player match statistics."""
193
+ raw = await self._http.get(f"event/{event_id}/player/{player_id}/statistics")
194
+ return parse_single_player_stats(raw)
195
+
196
+ # ----- tournament discovery -----
197
+
198
+ async def tournament_rounds(
199
+ self, tournament_id: int, season_id: int
200
+ ) -> RoundList:
201
+ """List all rounds in a tournament season."""
202
+ raw = await self._http.get(
203
+ f"unique-tournament/{tournament_id}/season/{season_id}/rounds"
204
+ )
205
+ return parse_round_list(raw)
206
+
207
+ async def round_events(
208
+ self,
209
+ tournament_id: int,
210
+ season_id: int,
211
+ round_number: int,
212
+ slug: str | None = None,
213
+ ) -> MatchList:
214
+ """List matches in a specific round."""
215
+ if slug:
216
+ path = (
217
+ f"unique-tournament/{tournament_id}/season/{season_id}"
218
+ f"/events/round/{round_number}/slug/{slug}"
219
+ )
220
+ else:
221
+ path = (
222
+ f"unique-tournament/{tournament_id}/season/{season_id}"
223
+ f"/events/round/{round_number}"
224
+ )
225
+ raw = await self._http.get(path)
226
+ return parse_match_list(raw)
227
+
228
+ async def season_events(
229
+ self, tournament_id: int, season_id: int
230
+ ) -> dict[int, MatchList]:
231
+ """Fetch every round's matches for one season concurrently.
232
+
233
+ Per-round calls are launched via ``asyncio.gather``; the shared rate
234
+ limiter still serializes them, so total time is the same as the sync
235
+ version. Async lets *other* coroutines do work in the meantime.
236
+ """
237
+ rounds = await self.tournament_rounds(tournament_id, season_id)
238
+
239
+ async def _fetch_round(round_number: int, slug: str | None) -> tuple[int, MatchList]:
240
+ ml = await self.round_events(tournament_id, season_id, round_number, slug)
241
+ return round_number, ml
242
+
243
+ tasks = [_fetch_round(r.round, r.slug) for r in rounds.rounds]
244
+ results = await asyncio.gather(*tasks) if tasks else []
245
+ return dict(results)
246
+
247
+ # ----- standings & profiles -----
248
+
249
+ async def tournament_standings(
250
+ self,
251
+ tournament_id: int,
252
+ season_id: int,
253
+ table_type: str = "total",
254
+ ) -> Standings:
255
+ """League table for a tournament season."""
256
+ raw = await self._http.get(
257
+ f"unique-tournament/{tournament_id}/season/{season_id}/standings/{table_type}"
258
+ )
259
+ return parse_standings(raw)
260
+
261
+ async def team(self, team_id: int) -> TeamProfile:
262
+ """Team profile — logo, country, venue, manager."""
263
+ raw = await self._http.get(f"team/{team_id}")
264
+ return parse_team_profile(raw)
265
+
266
+ async def player(self, player_id: int) -> PlayerProfile:
267
+ """Player profile — bio, position, market value."""
268
+ raw = await self._http.get(f"player/{player_id}")
269
+ return parse_player_profile(raw)
270
+
271
+ async def player_season_statistics(
272
+ self,
273
+ player_id: int,
274
+ tournament_id: int,
275
+ season_id: int,
276
+ ) -> SeasonPlayerStatistics:
277
+ """Season aggregate statistics for one player in one competition."""
278
+ raw = await self._http.get(
279
+ f"player/{player_id}/unique-tournament/{tournament_id}/season/{season_id}/statistics/overall"
280
+ )
281
+ return parse_season_player_statistics(raw)
282
+
283
+ async def team_events(self, team_id: int, page: int = 0) -> MatchList:
284
+ """Recent fixtures for a team."""
285
+ raw = await self._http.get(f"team/{team_id}/events/last/{page}")
286
+ return parse_match_list(raw)
287
+
288
+ async def team_events_next(self, team_id: int, page: int = 0) -> MatchList:
289
+ """Upcoming fixtures for a team."""
290
+ raw = await self._http.get(f"team/{team_id}/events/next/{page}")
291
+ return parse_match_list(raw)
292
+
293
+ async def team_rankings(self, team_id: int) -> TeamRankings:
294
+ """FIFA-style ranking rows for a team."""
295
+ raw = await self._http.get(f"team/{team_id}/rankings")
296
+ return parse_team_rankings(raw)
297
+
298
+ async def player_transfer_history(self, player_id: int) -> TransferHistory:
299
+ """Career transfer history for a player."""
300
+ raw = await self._http.get(f"player/{player_id}/transfer-history")
301
+ return parse_transfer_history(raw)
302
+
303
+ async def player_events_last(self, player_id: int, page: int = 0) -> MatchList:
304
+ """Recent matches involving a player."""
305
+ raw = await self._http.get(f"player/{player_id}/events/last/{page}")
306
+ return parse_match_list(raw)
307
+
308
+ # ----- high-level workflow -----
309
+
310
+ async def fetch_full_match(
311
+ self,
312
+ event_id: int,
313
+ skip_no_minutes: bool = True,
314
+ skip_no_shots: bool = True,
315
+ skip_sparse_heatmap: bool = True,
316
+ min_touches_for_heatmap: int = 5,
317
+ include_match_shotmap: bool = True,
318
+ include_h2h: bool = True,
319
+ include_pregame_form: bool = True,
320
+ ) -> FullMatch:
321
+ """Fetch analytics endpoints for one match (mirrors sync client)."""
322
+ event_detail = await self.event(event_id)
323
+ lineups = await self.event_lineups(event_id)
324
+ team_stats = await self.event_statistics(event_id)
325
+
326
+ incidents: Incidents | None = None
327
+ managers: EventManagers | None = None
328
+ graph: MatchGraph | None = None
329
+ head_to_head: HeadToHead | None = None
330
+ pregame_form: PregameForm | None = None
331
+ match_shotmap: Shotmap | None = None
332
+
333
+ with contextlib.suppress(NotFoundError):
334
+ incidents = await self.event_incidents(event_id)
335
+ with contextlib.suppress(NotFoundError):
336
+ managers = await self.event_managers(event_id)
337
+ with contextlib.suppress(NotFoundError):
338
+ graph = await self.event_graph(event_id)
339
+ if include_h2h:
340
+ with contextlib.suppress(NotFoundError):
341
+ head_to_head = await self.event_h2h(event_id)
342
+ if include_pregame_form:
343
+ with contextlib.suppress(NotFoundError):
344
+ pregame_form = await self.event_pregame_form(event_id)
345
+ if include_match_shotmap:
346
+ with contextlib.suppress(NotFoundError):
347
+ match_shotmap = await self.event_shotmap(event_id)
348
+
349
+ async def _fetch_one(entry: Any) -> tuple[int, PlayerMatchData]:
350
+ stats = entry.statistics
351
+ player_id = entry.player.id
352
+ heatmap: Heatmap | None = None
353
+ actions: ActionStream | None = None
354
+ shotmap: Shotmap | None = None
355
+
356
+ if should_fetch_heatmap(
357
+ stats.touches,
358
+ skip_sparse=skip_sparse_heatmap,
359
+ min_touches=min_touches_for_heatmap,
360
+ ):
361
+ with contextlib.suppress(NotFoundError):
362
+ heatmap = await self.player_heatmap(event_id, player_id)
363
+
364
+ with contextlib.suppress(NotFoundError):
365
+ actions = await self.player_actions(event_id, player_id)
366
+
367
+ if not skip_no_shots or should_fetch_shotmap(stats.total_shots):
368
+ with contextlib.suppress(NotFoundError):
369
+ shotmap = await self.player_shotmap(event_id, player_id)
370
+
371
+ return player_id, PlayerMatchData(
372
+ player_id=player_id,
373
+ heatmap=heatmap,
374
+ actions=actions,
375
+ shotmap=shotmap,
376
+ )
377
+
378
+ tasks = [
379
+ _fetch_one(entry)
380
+ for entry in lineups.all_players()
381
+ if should_fetch_spatial(
382
+ entry.statistics.minutes_played, skip_no_minutes=skip_no_minutes
383
+ )
384
+ ]
385
+ results = await asyncio.gather(*tasks) if tasks else []
386
+
387
+ return FullMatch(
388
+ event_id=event_id,
389
+ team_statistics=team_stats,
390
+ lineups=lineups,
391
+ player_data=dict(results),
392
+ event_detail=event_detail,
393
+ incidents=incidents,
394
+ graph=graph,
395
+ managers=managers,
396
+ head_to_head=head_to_head,
397
+ pregame_form=pregame_form,
398
+ match_shotmap=match_shotmap,
399
+ )
400
+
401
+ # ----- lifecycle -----
402
+
403
+ async def close(self) -> None:
404
+ """Close the underlying HTTP connection pool."""
405
+ await self._http.close()
406
+
407
+ async def __aenter__(self) -> AsyncSofascoreClient:
408
+ return self
409
+
410
+ async def __aexit__(self, *args: object) -> None:
411
+ await self.close()
@@ -0,0 +1,5 @@
1
+ """Async HTTP transport — re-exported from :mod:`tacoscore.http`."""
2
+
3
+ from tacoscore.http import AsyncHttpTransport
4
+
5
+ __all__ = ["AsyncHttpTransport"]
@@ -0,0 +1,64 @@
1
+ """asyncio-compatible rate limiter.
2
+
3
+ Mirrors :class:`tacoscore.rate_limiter.RateLimiter` but uses ``asyncio.Lock``
4
+ and ``asyncio.sleep`` so it doesn't block the event loop.
5
+
6
+ Note:
7
+ Sharing one limiter across many concurrent coroutines still serializes
8
+ their requests one-per-interval — the limiter holds its lock during the
9
+ sleep. Async doesn't make rate-limited scraping faster; it just lets
10
+ other coroutines do work *during* the wait.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import random
17
+ import time
18
+
19
+
20
+ class AsyncRateLimiter:
21
+ """Async rate limiter with optional jitter.
22
+
23
+ Args:
24
+ min_interval: Minimum seconds between calls.
25
+ jitter: Random uniform noise added to each interval (\u00b1 jitter / 2).
26
+
27
+ Example:
28
+ >>> limiter = AsyncRateLimiter(min_interval=1.5, jitter=0.5)
29
+ >>> await limiter.wait() # Returns 0 on first call
30
+ >>> await limiter.wait() # Sleeps 1.0-2.0 seconds
31
+ """
32
+
33
+ def __init__(self, min_interval: float = 1.5, jitter: float = 0.5) -> None:
34
+ if min_interval < 0:
35
+ raise ValueError("min_interval must be non-negative")
36
+ if jitter < 0:
37
+ raise ValueError("jitter must be non-negative")
38
+ self.min_interval = min_interval
39
+ self.jitter = jitter
40
+ self._lock = asyncio.Lock()
41
+ self._last_call: float = 0.0
42
+
43
+ async def wait(self) -> float:
44
+ """Block until the next call is allowed.
45
+
46
+ Returns:
47
+ Seconds actually slept (zero for the first call).
48
+ """
49
+ async with self._lock:
50
+ now = time.monotonic()
51
+ elapsed = now - self._last_call
52
+ target = self.min_interval + random.uniform(
53
+ -self.jitter / 2, self.jitter / 2
54
+ )
55
+ sleep_for = max(0.0, target - elapsed)
56
+ if sleep_for > 0:
57
+ await asyncio.sleep(sleep_for)
58
+ self._last_call = time.monotonic()
59
+ return sleep_for
60
+
61
+ async def reset(self) -> None:
62
+ """Reset the limiter so the next call returns immediately."""
63
+ async with self._lock:
64
+ self._last_call = 0.0