transfermarkt-api 5.0.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 (71) hide show
  1. transfermarkt_api/__init__.py +23 -0
  2. transfermarkt_api/_version.py +3 -0
  3. transfermarkt_api/cache.py +20 -0
  4. transfermarkt_api/client.py +226 -0
  5. transfermarkt_api/exceptions.py +31 -0
  6. transfermarkt_api/py.typed +0 -0
  7. transfermarkt_api/schemas/__init__.py +24 -0
  8. transfermarkt_api/schemas/achievements.py +25 -0
  9. transfermarkt_api/schemas/base.py +17 -0
  10. transfermarkt_api/schemas/clubs/__init__.py +3 -0
  11. transfermarkt_api/schemas/clubs/listing.py +14 -0
  12. transfermarkt_api/schemas/clubs/players.py +30 -0
  13. transfermarkt_api/schemas/clubs/profile.py +82 -0
  14. transfermarkt_api/schemas/clubs/search.py +19 -0
  15. transfermarkt_api/schemas/coaches/__init__.py +31 -0
  16. transfermarkt_api/schemas/competitions/__init__.py +4 -0
  17. transfermarkt_api/schemas/competitions/clubs.py +15 -0
  18. transfermarkt_api/schemas/competitions/search.py +21 -0
  19. transfermarkt_api/schemas/competitions/seasons.py +15 -0
  20. transfermarkt_api/schemas/competitions/table.py +31 -0
  21. transfermarkt_api/schemas/countries.py +16 -0
  22. transfermarkt_api/schemas/games/__init__.py +83 -0
  23. transfermarkt_api/schemas/players/__init__.py +9 -0
  24. transfermarkt_api/schemas/players/absences.py +22 -0
  25. transfermarkt_api/schemas/players/injuries.py +20 -0
  26. transfermarkt_api/schemas/players/market_value.py +19 -0
  27. transfermarkt_api/schemas/players/matches.py +56 -0
  28. transfermarkt_api/schemas/players/national_career.py +21 -0
  29. transfermarkt_api/schemas/players/profile.py +81 -0
  30. transfermarkt_api/schemas/players/search.py +25 -0
  31. transfermarkt_api/schemas/players/stats.py +24 -0
  32. transfermarkt_api/schemas/players/transfers.py +38 -0
  33. transfermarkt_api/services/__init__.py +0 -0
  34. transfermarkt_api/services/achievements.py +50 -0
  35. transfermarkt_api/services/clubs/__init__.py +0 -0
  36. transfermarkt_api/services/clubs/achievements.py +10 -0
  37. transfermarkt_api/services/clubs/listing.py +22 -0
  38. transfermarkt_api/services/clubs/players.py +88 -0
  39. transfermarkt_api/services/clubs/profile.py +114 -0
  40. transfermarkt_api/services/clubs/search.py +33 -0
  41. transfermarkt_api/services/coaches/__init__.py +0 -0
  42. transfermarkt_api/services/coaches/profile.py +29 -0
  43. transfermarkt_api/services/coaches/search.py +18 -0
  44. transfermarkt_api/services/competitions/__init__.py +0 -0
  45. transfermarkt_api/services/competitions/clubs.py +30 -0
  46. transfermarkt_api/services/competitions/search.py +61 -0
  47. transfermarkt_api/services/competitions/seasons.py +28 -0
  48. transfermarkt_api/services/competitions/table.py +61 -0
  49. transfermarkt_api/services/countries.py +12 -0
  50. transfermarkt_api/services/games/__init__.py +0 -0
  51. transfermarkt_api/services/games/game.py +136 -0
  52. transfermarkt_api/services/players/__init__.py +0 -0
  53. transfermarkt_api/services/players/absences.py +33 -0
  54. transfermarkt_api/services/players/achievements.py +10 -0
  55. transfermarkt_api/services/players/injuries.py +42 -0
  56. transfermarkt_api/services/players/market_value.py +35 -0
  57. transfermarkt_api/services/players/matches.py +107 -0
  58. transfermarkt_api/services/players/national_career.py +31 -0
  59. transfermarkt_api/services/players/profile.py +122 -0
  60. transfermarkt_api/services/players/search.py +44 -0
  61. transfermarkt_api/services/players/stats.py +74 -0
  62. transfermarkt_api/services/players/transfers.py +89 -0
  63. transfermarkt_api/tfmkt/__init__.py +1 -0
  64. transfermarkt_api/tfmkt/client.py +249 -0
  65. transfermarkt_api/tfmkt/concurrency.py +17 -0
  66. transfermarkt_api/tfmkt/freshness.py +38 -0
  67. transfermarkt_api/tfmkt/reference.py +116 -0
  68. transfermarkt_api-5.0.0.dist-info/METADATA +234 -0
  69. transfermarkt_api-5.0.0.dist-info/RECORD +71 -0
  70. transfermarkt_api-5.0.0.dist-info/WHEEL +4 -0
  71. transfermarkt_api-5.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,249 @@
1
+ """Async client for Transfermarkt's JSON API (tfmkt), the data source of every endpoint migrated off HTML scraping."""
2
+
3
+ import asyncio
4
+ from collections.abc import Iterable
5
+ from datetime import UTC, datetime
6
+ from typing import Any, Optional
7
+
8
+ import httpx
9
+ from cachetools import TTLCache
10
+
11
+ from transfermarkt_api.cache import CacheConfig
12
+ from transfermarkt_api.exceptions import NotFound, UpstreamBlocked, UpstreamError, UpstreamTimeout
13
+ from transfermarkt_api.tfmkt.concurrency import gather
14
+ from transfermarkt_api.tfmkt.freshness import record_fetch
15
+
16
+ BATCH_SIZE = 100 # batch routes accepted 200 IDs in testing; stay well below
17
+
18
+
19
+ class TfmktClient:
20
+ """
21
+ Thin wrapper around tfmkt routes.
22
+
23
+ Every request goes through `get`, which applies the concurrency cap, the response cache and the upstream error
24
+ mapping. Successful responses are cached with the time they were fetched, so callers can report data age.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ base_url: str = "https://tmapi.transfermarkt.technology",
30
+ transport: Optional[httpx.AsyncBaseTransport] = None,
31
+ *,
32
+ timeout: float | httpx.Timeout | None = 15.0,
33
+ max_concurrency: int = 10,
34
+ cache: CacheConfig | None = CacheConfig(),
35
+ ) -> None:
36
+ """Create the HTTP client, concurrency cap and response caches."""
37
+ url = httpx.URL(base_url)
38
+ if url.scheme not in ("http", "https") or not url.host or url.query or url.fragment:
39
+ raise ValueError("base_url must be an absolute HTTP(S) URL without query or fragment")
40
+ if not isinstance(max_concurrency, int) or isinstance(max_concurrency, bool) or max_concurrency < 1:
41
+ raise ValueError("max_concurrency must be a positive integer")
42
+ self._transport_injected = transport is not None
43
+ self.http = httpx.AsyncClient(
44
+ base_url=base_url,
45
+ headers={"Accept": "application/json", "User-Agent": "transfermarkt-api"},
46
+ timeout=timeout,
47
+ transport=transport,
48
+ )
49
+ self.semaphore = asyncio.Semaphore(max_concurrency)
50
+ self.cache: TTLCache | None = TTLCache(maxsize=cache.max_entries, ttl=cache.ttl) if cache else None
51
+ self.reference_cache: TTLCache | None = TTLCache(maxsize=8, ttl=cache.reference_ttl) if cache else None
52
+
53
+ async def aclose(self) -> None:
54
+ """Close the underlying HTTP client."""
55
+ await self.http.aclose()
56
+
57
+ async def get(self, path: str, params: Optional[list[tuple[str, Any]]] = None, reference: bool = False) -> Any:
58
+ """
59
+ Fetch a tfmkt route and return its `data` payload.
60
+
61
+ Exceptions from an injected transport propagate unchanged, including httpx errors.
62
+
63
+ Raises:
64
+ TransfermarktError: 404 when upstream does not know the entity, 503 when upstream challenges or rejects the
65
+ request, 504 on timeout and 502 for any other unexpected upstream response.
66
+ """
67
+ if self.http.is_closed:
68
+ raise RuntimeError("Transfermarkt client is closed")
69
+ request = self.http.build_request("GET", path, params=params)
70
+ key = str(request.url)
71
+ cache = self.reference_cache if reference else self.cache
72
+ if cache is not None and key in cache:
73
+ fetched_at, data = cache[key]
74
+ record_fetch(fetched_at)
75
+ return data
76
+
77
+ async with self.semaphore:
78
+ try:
79
+ response = await self.http.send(request)
80
+ except httpx.TimeoutException as e:
81
+ if self._transport_injected:
82
+ raise
83
+ raise UpstreamTimeout(f"Upstream timeout for {path}", url=str(request.url)) from e
84
+ except httpx.HTTPError as e:
85
+ if self._transport_injected:
86
+ raise
87
+ raise UpstreamError(f"Upstream connection error for {path}: {e}", url=str(request.url)) from e
88
+
89
+ data = self._parse(response, path)
90
+ fetched_at = response.extensions.get("transfermarkt_fetched_at", datetime.now(UTC).replace(tzinfo=None))
91
+ if not isinstance(fetched_at, datetime):
92
+ raise UpstreamError("transfermarkt_fetched_at must be a datetime", url=key)
93
+ if fetched_at.utcoffset() is not None:
94
+ fetched_at = fetched_at.astimezone(UTC).replace(tzinfo=None)
95
+ if cache is not None:
96
+ cache[key] = (fetched_at, data)
97
+ record_fetch(fetched_at)
98
+ return data
99
+
100
+ async def get_optional(self, path: str, params: Optional[list[tuple[str, Any]]] = None) -> Any:
101
+ """Like `get`, but returns None when upstream answers 404 (e.g. a club without a stadium)."""
102
+ try:
103
+ return await self.get(path, params=params)
104
+ except NotFound:
105
+ return None
106
+
107
+ @staticmethod
108
+ def _parse(response: httpx.Response, path: str) -> Any:
109
+ """Return the `data` payload of a tfmkt response, or raise the TransfermarktError matching the failure."""
110
+ metadata = {"url": str(response.request.url), "status_code": response.status_code}
111
+ if response.headers.get("x-amzn-waf-action") or (response.status_code == 202 and not response.content):
112
+ raise UpstreamBlocked(f"Upstream blocked the request (WAF challenge) for {path}", **metadata)
113
+ if response.status_code == 404:
114
+ raise NotFound(f"Not found: {path}", **metadata)
115
+ if response.status_code in (403, 429):
116
+ raise UpstreamBlocked(f"Upstream refused the request ({response.status_code})", **metadata)
117
+ if response.status_code != 200:
118
+ raise UpstreamError(f"Unexpected upstream status {response.status_code}", **metadata)
119
+ try:
120
+ body = response.json()
121
+ except ValueError:
122
+ raise UpstreamError(f"Upstream returned invalid JSON for {path}", **metadata)
123
+ if not isinstance(body, dict) or not body.get("success") or "data" not in body:
124
+ raise UpstreamError(f"Unexpected upstream payload for {path}", **metadata)
125
+ return body["data"]
126
+
127
+ async def get_batch(self, route: str, ids: Iterable[str | int | None]) -> dict[str, dict]:
128
+ """
129
+ Fetch entities from a batch route (`/players`, `/clubs`, `/competitions`) and index them by ID.
130
+
131
+ IDs that upstream does not return are simply absent from the result; callers must handle the gap.
132
+ """
133
+ unique_ids = sorted({str(i) for i in ids if i not in (None, "")})
134
+ chunks = [unique_ids[i : i + BATCH_SIZE] for i in range(0, len(unique_ids), BATCH_SIZE)]
135
+ results = await gather(*(self.get(route, params=[("ids[]", i) for i in chunk]) for chunk in chunks))
136
+ return {str(entity["id"]): entity for data in results for entity in data or []}
137
+
138
+ async def attributes(self) -> dict:
139
+ """Reference data: countries, positions, competition types, confederations, injuries, etc."""
140
+ return await self.get("/attributes", reference=True)
141
+
142
+ async def quick_search(self, term: str, page: int) -> dict:
143
+ """Search players, clubs, competitions and coaches; returns IDs per category."""
144
+ return await self.get("/quick-search", params=[("term", term), ("page", page)])
145
+
146
+ async def player(self, player_id: str) -> dict:
147
+ """Fetch a player record."""
148
+ return await self.get(f"/player/{player_id}")
149
+
150
+ async def player_market_value_history(self, player_id: str) -> dict:
151
+ """Fetch a player's market value history and current value."""
152
+ return await self.get(f"/player/{player_id}/market-value-history")
153
+
154
+ async def player_transfer_history(self, player_id: str) -> Optional[dict]:
155
+ """None when the player has no transfer history (upstream answers 404)."""
156
+ return await self.get_optional(f"/transfer/history/player/{player_id}")
157
+
158
+ async def player_injuries(self, player_id: str) -> dict:
159
+ """Fetch a player's injury history."""
160
+ return await self.get(f"/player/{player_id}/injury")
161
+
162
+ async def player_performance_games(self, player_id: str) -> dict:
163
+ """Fetch one performance record per match of the player's club."""
164
+ return await self.get(f"/player/{player_id}/performance-game")
165
+
166
+ async def player_achievements(self, player_id: str) -> dict:
167
+ """Fetch a player's titles and awards (one record per title won)."""
168
+ return await self.get(f"/player/{player_id}/achievement")
169
+
170
+ async def player_market_value_ranking(self, player_id: str) -> Optional[dict]:
171
+ """None when the player has no ranking (upstream answers 404)."""
172
+ return await self.get_optional(f"/player/{player_id}/market-value-ranking")
173
+
174
+ async def player_national_career(self, player_id: str) -> dict:
175
+ """Fetch the national teams a player has played for."""
176
+ return await self.get(f"/player/{player_id}/national-career-history")
177
+
178
+ async def player_absences(self, player_id: str) -> dict:
179
+ """Fetch a player's non-injury absences."""
180
+ return await self.get(f"/player/{player_id}/absence")
181
+
182
+ async def coach(self, coach_id: str) -> dict:
183
+ """Fetch a coach record."""
184
+ return await self.get(f"/coach/{coach_id}")
185
+
186
+ async def coaches(self, ids: Iterable[str | int | None]) -> dict[str, dict]:
187
+ """Fetch coach records by ID, indexed by ID."""
188
+ return await self.get_batch("/coaches", ids)
189
+
190
+ async def country_clubs(self, country_id: int) -> dict:
191
+ """Fetch the limited club directory for a country; no upstream pagination is available."""
192
+ return await self.get(f"/country/{country_id}/club")
193
+
194
+ async def club(self, club_id: str) -> dict:
195
+ """Fetch a club record."""
196
+ return await self.get(f"/club/{club_id}")
197
+
198
+ async def club_squad(self, club_id: str, season_id: Optional[str] = None) -> dict:
199
+ """Fetch a club's squad for a season (current squad by default)."""
200
+ return await self.get(f"/club/{club_id}/squad", params=_season(season_id))
201
+
202
+ async def club_achievements(self, club_id: str) -> dict:
203
+ """Fetch a club's titles (one record per title won)."""
204
+ return await self.get(f"/club/{club_id}/achievement")
205
+
206
+ async def club_coach(self, club_id: str) -> Optional[dict]:
207
+ """None when the club has no head coach on record (upstream answers 404)."""
208
+ return await self.get_optional(f"/club/{club_id}/coach")
209
+
210
+ async def club_stadium(self, club_id: str) -> Optional[dict]:
211
+ """None when the club has no stadium (upstream answers 404)."""
212
+ return await self.get_optional(f"/club/{club_id}/stadium")
213
+
214
+ async def competition_clubs(self, competition_id: str, season_id: Optional[str] = None) -> dict:
215
+ """Fetch the member clubs of a competition season (current by default)."""
216
+ return await self.get(f"/competition/{competition_id}/club", params=_season(season_id))
217
+
218
+ async def competition_table(self, competition_id: str, season_id: Optional[str] = None) -> dict:
219
+ """Fetch a competition's league table(s) for a season (current by default)."""
220
+ return await self.get(f"/competition/{competition_id}/table", params=_season(season_id))
221
+
222
+ async def competition_seasons(self, competition_id: str) -> dict:
223
+ """Fetch the seasons available for a competition."""
224
+ return await self.get(f"/competition/{competition_id}/season")
225
+
226
+ async def game(self, game_id: str) -> dict:
227
+ """Fetch a match: lineups, events, score and officials."""
228
+ return await self.get(f"/game/{game_id}")
229
+
230
+ async def stadium(self, stadium_id: str) -> Optional[dict]:
231
+ """None when the stadium is unknown (upstream answers 404)."""
232
+ return await self.get_optional(f"/stadium/{stadium_id}")
233
+
234
+ async def competitions(self, ids: Iterable[str | int | None]) -> dict[str, dict]:
235
+ """Fetch competition records by ID, indexed by ID."""
236
+ return await self.get_batch("/competitions", ids)
237
+
238
+ async def clubs(self, ids: Iterable[str | int | None]) -> dict[str, dict]:
239
+ """Fetch club records by ID, indexed by ID."""
240
+ return await self.get_batch("/clubs", ids)
241
+
242
+ async def players(self, ids: Iterable[str | int | None]) -> dict[str, dict]:
243
+ """Fetch player records by ID, indexed by ID."""
244
+ return await self.get_batch("/players", ids)
245
+
246
+
247
+ def _season(season_id: Optional[str]) -> Optional[list[tuple[str, str]]]:
248
+ """Query parameters selecting a season, or None for the current season."""
249
+ return [("season", season_id)] if season_id else None
@@ -0,0 +1,17 @@
1
+ """Ordered fan-out with cleanup before failures leave the SDK."""
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable
5
+ from typing import Any
6
+
7
+
8
+ async def gather(*awaitables: Awaitable[Any]) -> list[Any]:
9
+ """Return results in input order, cancelling and awaiting siblings on any failure."""
10
+ tasks = [asyncio.ensure_future(awaitable) for awaitable in awaitables]
11
+ try:
12
+ return await asyncio.gather(*tasks)
13
+ except BaseException:
14
+ for task in tasks:
15
+ task.cancel()
16
+ await asyncio.gather(*tasks, return_exceptions=True)
17
+ raise
@@ -0,0 +1,38 @@
1
+ """
2
+ Track when the upstream data behind a response was fetched, so `updatedAt` stays truthful when served from cache.
3
+
4
+ `fetch_scope()` starts a per-call record before assembly and model validation. The client reports
5
+ each response it uses, fresh or cached, and `data_fetched_at()` returns the oldest fetch time. The record is a
6
+ mutable list shared through a context variable, so fetches made inside `asyncio.gather` child tasks are seen by
7
+ the request.
8
+ """
9
+
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager
12
+ from contextvars import ContextVar
13
+ from datetime import UTC, datetime
14
+ from typing import Optional
15
+
16
+ _fetch_times: ContextVar[Optional[list[datetime]]] = ContextVar("tfmkt_fetch_times", default=None)
17
+
18
+
19
+ @contextmanager
20
+ def fetch_scope() -> Iterator[None]:
21
+ """Isolate one assembly operation, restoring the calling context even on failure."""
22
+ token = _fetch_times.set([])
23
+ try:
24
+ yield
25
+ finally:
26
+ _fetch_times.reset(token)
27
+
28
+
29
+ def record_fetch(fetched_at: datetime) -> None:
30
+ """Record when a response used by the current request was fetched from upstream."""
31
+ fetch_times = _fetch_times.get()
32
+ if fetch_times is not None:
33
+ fetch_times.append(fetched_at)
34
+
35
+
36
+ def data_fetched_at() -> datetime:
37
+ """Oldest fetch time recorded for the current request, or now when nothing was fetched."""
38
+ return min(_fetch_times.get() or [], default=None) or datetime.now(UTC).replace(tzinfo=None)
@@ -0,0 +1,116 @@
1
+ """Lookups into tfmkt reference data (`/attributes`) and small helpers shared by JSON-backed services."""
2
+
3
+ import math
4
+ from typing import Any, Optional, Union
5
+
6
+ from transfermarkt_api.tfmkt.client import TfmktClient
7
+
8
+ TRANSFERMARKT_URL = "https://www.transfermarkt.com"
9
+ RETIRED_CLUB_ID = "123"
10
+ SEARCH_PAGE_SIZE = 10 # quick-search returns 10 IDs per category per page
11
+
12
+
13
+ class Reference:
14
+ """Indexed view of `/attributes`."""
15
+
16
+ def __init__(self, attributes: dict) -> None:
17
+ """Index the `/attributes` lists by ID."""
18
+ self.countries = {c["id"]: c for c in attributes.get("countries", [])}
19
+ self.confederations = {c["id"]: c for c in attributes.get("confederations", [])}
20
+ self.competition_types = {c["id"]: c for c in attributes.get("competitionTypes", [])}
21
+ self.contracts = {c["id"]: c for c in attributes.get("contracts", [])}
22
+ self.positions = {p["id"]: p for p in attributes.get("positions", [])}
23
+
24
+ def country(self, country_id: Optional[Union[int, str]]) -> dict:
25
+ """Record of a country, or an empty dict if unknown."""
26
+ return self.countries.get(_int(country_id)) or {}
27
+
28
+ def country_name(self, country_id: Optional[Union[int, str]]) -> Optional[str]:
29
+ """Name of a country, or None if unknown."""
30
+ return self.country(country_id).get("name")
31
+
32
+ def country_names(self, *country_ids: Optional[Union[int, str]]) -> list[str]:
33
+ """Names of the known countries among the given IDs, in order."""
34
+ return [name for name in (self.country_name(i) for i in country_ids) if name]
35
+
36
+ def confederation_name(self, confederation_id: Optional[Union[int, str]]) -> Optional[str]:
37
+ """Short name of a confederation (e.g. UEFA), or None if unknown."""
38
+ return self.confederations.get(_int(confederation_id), {}).get("name")
39
+
40
+ def competition_type_name(self, type_id: Optional[Union[int, str]]) -> Optional[str]:
41
+ """Name of a competition type (e.g. First Tier), or None if unknown."""
42
+ return self.competition_types.get(_int(type_id), {}).get("name")
43
+
44
+ def position_name(self, position_id: Optional[Union[int, str]]) -> Optional[str]:
45
+ """Name of a position (e.g. Right Winger), or None if unknown."""
46
+ return self.positions.get(_int(position_id), {}).get("name")
47
+
48
+ def contract_option_name(self, option_id: Optional[Union[int, str]]) -> Optional[str]:
49
+ """Name of a contract option, or None if unknown."""
50
+ return self.contracts.get(_int(option_id), {}).get("name")
51
+
52
+
53
+ async def get_reference(tfmkt: TfmktClient) -> Reference:
54
+ """Fetch `/attributes` (cached) and index it."""
55
+ return Reference(await tfmkt.attributes())
56
+
57
+
58
+ def _int(value: Any) -> Optional[int]:
59
+ """Convert to int, or None when the value is not a number."""
60
+ try:
61
+ return int(value)
62
+ except (TypeError, ValueError):
63
+ return None
64
+
65
+
66
+ def value_of(money: Optional[dict]) -> Optional[int]:
67
+ """Numeric value of a tfmkt money object (`{"value": 15000000, "currency": "EUR", ...}`)."""
68
+ if not money or money.get("value") is None:
69
+ return None
70
+ return int(money["value"])
71
+
72
+
73
+ def market_value_of(money: Optional[dict]) -> Optional[int]:
74
+ """Like `value_of`, but 0 means "no valuation" (the website shows "-"), so it becomes None."""
75
+ return value_of(money) or None
76
+
77
+
78
+ def full_url(relative_url: Optional[str]) -> Optional[str]:
79
+ """Absolute website URL for a relative upstream URL."""
80
+ return f"{TRANSFERMARKT_URL}{relative_url}" if relative_url else None
81
+
82
+
83
+ def season_label(season_id: Optional[Union[int, str]]) -> Optional[str]:
84
+ """Split-year season label used by the website, e.g. 2025 -> "25/26"."""
85
+ year = _int(season_id)
86
+ if year is None:
87
+ return None
88
+ return f"{year % 100:02d}/{(year + 1) % 100:02d}"
89
+
90
+
91
+ def last_page_number(total: Optional[int], page_size: int = SEARCH_PAGE_SIZE) -> int:
92
+ """Number of pages needed for `total` results (at least 1)."""
93
+ return max(1, math.ceil((total or 0) / page_size))
94
+
95
+
96
+ def current_assignment(player: dict) -> Optional[dict]:
97
+ """The player's current club assignment. Selected by type: national-team assignments can come first."""
98
+ return next((a for a in player.get("clubAssignments") or [] if a.get("type") == "current"), None)
99
+
100
+
101
+ def date_of_birth(player: dict) -> Optional[str]:
102
+ """ISO date of birth, or None when upstream marks it unknown."""
103
+ life = player.get("lifeDates") or {}
104
+ return None if life.get("isDateOfBirthUnknown") else life.get("dateOfBirth")
105
+
106
+
107
+ def height_cm(player: dict) -> Optional[int]:
108
+ """Height in centimetres (upstream gives metres)."""
109
+ height = (player.get("attributes") or {}).get("height")
110
+ return round(height * 100) if height else None
111
+
112
+
113
+ def nationality_ids(player: dict) -> tuple:
114
+ """First and second nationality country IDs."""
115
+ nationalities = (player.get("nationalityDetails") or {}).get("nationalities") or {}
116
+ return nationalities.get("nationalityId"), nationalities.get("secondNationalityId")
@@ -0,0 +1,234 @@
1
+ Metadata-Version: 2.5
2
+ Name: transfermarkt-api
3
+ Version: 5.0.0
4
+ Summary: Async Python SDK and HTTP service for Transfermarkt data
5
+ Author-email: Felipe Allegretti <felipe@allegretti.me>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: <3.14,>=3.12
9
+ Requires-Dist: cachetools<8,>=7.2
10
+ Requires-Dist: httpx<1,>=0.28.1
11
+ Requires-Dist: pydantic<3,>=2.13
12
+ Provides-Extra: server
13
+ Requires-Dist: fastapi<1,>=0.142.2; extra == 'server'
14
+ Requires-Dist: limits<6,>=5.8; extra == 'server'
15
+ Requires-Dist: pydantic-settings<3,>=2.15; extra == 'server'
16
+ Requires-Dist: uvicorn[standard]<1,>=0.54; extra == 'server'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # transfermarkt-api
20
+
21
+ An async Python SDK and RESTful API (FastAPI) for football data from [Transfermarkt](https://www.transfermarkt.com/): players, clubs and competitions.
22
+
23
+ Since version 4.0.0, data comes from Transfermarkt's JSON API (`tmapi.transfermarkt.technology`), the same API the website uses, instead of scraping HTML pages. The website blocks requests from most cloud hosts, which broke v3 deployments; the JSON API does not. See [CHANGELOG.md](CHANGELOG.md) for what changed in the response format.
24
+
25
+ Please note that the deployed application is used only for testing purposes and is rate limited to 2 requests
26
+ every 3 seconds per client IP. If you'd like to customize it, consider hosting in your own cloud service.
27
+
28
+ The JSON API is internal to Transfermarkt and undocumented: it can change without notice. This project's MIT license covers its code, not the data; check [Transfermarkt's terms](https://www.transfermarkt.com/intern/) before using the data in your own services.
29
+
30
+ ### Python SDK
31
+
32
+ Install the library without server dependencies:
33
+
34
+ ```bash
35
+ pip install transfermarkt-api
36
+ # Until the SDK release is published, install from this repository:
37
+ # pip install .
38
+ ```
39
+
40
+ Release setup and the publishing procedure are documented in [Python package releases](docs/releases.md).
41
+
42
+ ```python
43
+ import asyncio
44
+
45
+ from transfermarkt_api import Transfermarkt
46
+
47
+
48
+ async def main() -> None:
49
+ async with Transfermarkt() as tm:
50
+ squad = await tm.club_players("985")
51
+ for player in squad.players:
52
+ print(player.shirt_number, player.name, player.position, player.market_value)
53
+ print(squad.model_dump(mode="json", by_alias=True))
54
+
55
+
56
+ asyncio.run(main())
57
+ ```
58
+
59
+ The facade has one method per data endpoint: `player_profile`, `player_transfers`, `club_players`,
60
+ `competition_clubs`, `competition_table`, `game`, and the remaining methods listed in the
61
+ [SDK method table](docs/plans/0001-python-sdk.md#public-contract). Methods return typed Pydantic models exported
62
+ from `transfermarkt_api.schemas`. Nested models remain available in their resource modules. Use string entity
63
+ and season IDs, integer country IDs, and 1-based page numbers. Calls return one page; callers control pagination.
64
+
65
+ Constructor settings are explicit: `base_url`, `timeout` (seconds, `httpx.Timeout`, or `None`),
66
+ `max_concurrency` (default `10`), `transport`, and `cache`. The SDK reads no `.env` or environment settings on import.
67
+ `CacheConfig(ttl=600, reference_ttl=86400, max_entries=2000)` configures per-client caching. Pass `cache=None` to
68
+ disable both response and reference caches so every request reaches the transport.
69
+
70
+ One client supports concurrent calls within one event loop. Its async context manager, or explicit `aclose()`,
71
+ closes the HTTP client and supplied transport. Do not share a supplied transport across client lifetimes unless
72
+ your wrapper owns and controls its closure. Cache hits remain local to each SDK instance.
73
+
74
+ The SDK raises `NotFound`, `UpstreamBlocked`, `UpstreamTimeout`, `UpstreamError`, or `EndpointUnavailable`, all
75
+ derived from `TransfermarktError` and exported at the package root. Available upstream URL/status metadata lives
76
+ on `error.url` and `error.status_code`; the status is the upstream status, not the server's mapped HTTP status.
77
+ The unsupported `player_jersey_numbers` method raises `EndpointUnavailable` without fetching data.
78
+ Invalid constructor/page/country arguments raise `ValueError`. Custom transport exceptions propagate unchanged.
79
+
80
+ ### Custom transports and replay
81
+
82
+ Supply an `httpx.AsyncBaseTransport` to control every upstream request, including reference lookups, batches,
83
+ and per-player fan-out. The SDK bounds in-flight concurrency; the caller's transport owns rate limits, retries,
84
+ proxy selection, recording and circuit breakers. The SDK performs no automatic retries.
85
+
86
+ ```python
87
+ import httpx
88
+
89
+ from transfermarkt_api import Transfermarkt
90
+
91
+ # Run inside an async function. A custom browser-capable backend can implement the same transport interface.
92
+ async with Transfermarkt(
93
+ transport=httpx.AsyncHTTPTransport(proxy="http://127.0.0.1:8080"),
94
+ cache=None,
95
+ ) as tm:
96
+ squad = await tm.club_players("985", season_id="2024")
97
+ ```
98
+
99
+ [Recording and replay examples](examples/transports.py) wrap a caller-selected backend and save the response
100
+ before SDK parsing. Persist the method, full URL with repeated query parameters, status, relevant headers (including
101
+ `x-amzn-waf-action`), decoded body, and original fetch timestamp. The example store is in memory; replace it with
102
+ durable storage in your application. When reconstructing decoded responses, drop content-encoding/content-length
103
+ headers so the body is not decoded twice.
104
+
105
+ A replay transport returns an `httpx.Response` with
106
+ `extensions={"transfermarkt_fetched_at": captured_datetime}`. This optional metadata accepts naive or timezone-aware
107
+ datetimes. Aware inputs are converted to UTC; naive inputs are interpreted as UTC. `updatedAt` retains its existing
108
+ JSON shape without a timezone suffix and always represents UTC. Without metadata, it reports the current UTC fetch
109
+ time. Each model reports the oldest fetch time used in that call, including cache hits. Exact replay
110
+ needs the same response bodies and timestamps. Missing recordings must raise a transport error rather than fetch
111
+ live data. Batch URLs are stable; concurrent request completion order is not part of the replay key.
112
+
113
+ ```python
114
+ from examples.transports import RecordingTransport, ReplayTransport
115
+
116
+ records = {}
117
+ async with Transfermarkt(transport=RecordingTransport(httpx.AsyncHTTPTransport(), records), cache=None) as tm:
118
+ captured = await tm.club_players("985")
119
+ async with Transfermarkt(transport=ReplayTransport(records), cache=None) as tm:
120
+ replayed = await tm.club_players("985")
121
+ assert captured == replayed
122
+ ```
123
+
124
+ The examples and HTTP server are repository files. The wheel installs only the `transfermarkt_api` package,
125
+ avoiding collisions with a consumer's own `app` package. The `server` extra installs the server dependencies;
126
+ run `uvicorn app.main:app` from a repository checkout, or use the Docker image.
127
+
128
+ ### API Swagger
129
+ https://transfermarkt-api.fly.dev/
130
+
131
+ ### Endpoints
132
+
133
+ | Endpoint | Notes |
134
+ | --- | --- |
135
+ | `GET /players/search/{player_name}?page_number=` | 10 results per page |
136
+ | `GET /players/{player_id}/profile` | |
137
+ | `GET /players/{player_id}/market_value` | |
138
+ | `GET /players/{player_id}/transfers` | Transfer type, age, contract dates, remaining contract days, and each club's league and country at the time |
139
+ | `GET /players/{player_id}/stats` | Per season, competition and club; club competitions only |
140
+ | `GET /players/{player_id}/matches?season_id=&page_number=` | Past matches of the player's teams, most recent first, 50 per page; no upcoming fixtures |
141
+ | `GET /players/{player_id}/injuries?page_number=` | 15 injuries per page |
142
+ | `GET /players/{player_id}/absences?page_number=` | Suspensions, call-ups, leave; 15 per page |
143
+ | `GET /players/{player_id}/achievements` | Titles and awards with season, club and competition |
144
+ | `GET /players/{player_id}/national_career` | Senior and youth national teams |
145
+ | `GET /players/{player_id}/jersey_numbers` | `501`: no data source |
146
+ | `GET /countries/` | Country IDs and metadata, including historical entries; cached for 24 hours |
147
+ | `GET /clubs/?country_id=` | Available club IDs and names for a country; limited upstream coverage, no pagination |
148
+ | `GET /clubs/search/{club_name}?page_number=` | 10 results per page |
149
+ | `GET /clubs/{club_id}/profile` | Squad value details, historical names and stadium details |
150
+ | `GET /clubs/{club_id}/players?season_id=` | Current squad by default; shirt number and captaincy for the selected season |
151
+ | `GET /clubs/{club_id}/achievements` | Club titles by season |
152
+ | `GET /coaches/search/{coach_name}?page_number=` | 10 results per page |
153
+ | `GET /coaches/{coach_id}/profile` | |
154
+ | `GET /competitions/search/{competition_name}?page_number=` | 10 results per page |
155
+ | `GET /competitions/{competition_id}/clubs?season_id=` | Current season by default |
156
+ | `GET /competitions/{competition_id}/table?season_id=` | League table; one table per group for group stages |
157
+ | `GET /competitions/{competition_id}/seasons` | Valid `season_id` values |
158
+ | `GET /games/{game_id}` | Score, lineups, coaches, stadium, team stats, goals, cards and substitutions |
159
+
160
+ Errors: `404` unknown ID, `501` endpoint without data source, `502` unexpected upstream response, `503` upstream blocked or refused the request, `504` upstream timeout.
161
+
162
+ Fields without an upstream value are returned as `null`. `updatedAt` is when the underlying data was fetched from Transfermarkt; responses are cached for 10 minutes by default.
163
+
164
+ ### Running Locally
165
+
166
+ Requires [uv](https://docs.astral.sh/uv/).
167
+
168
+ ````bash
169
+ # Clone the repository
170
+ $ git clone https://github.com/felipeall/transfermarkt-api.git
171
+
172
+ # Go to the project's root folder
173
+ $ cd transfermarkt-api
174
+
175
+ # Install Python 3.12 and the dependencies
176
+ $ uv sync --extra server
177
+
178
+ # Start the API server
179
+ $ uv run uvicorn app.main:app --reload
180
+
181
+ # Access the API local page
182
+ $ open http://localhost:8000/
183
+ ````
184
+
185
+ ### Running Tests
186
+
187
+ ````bash
188
+ # Offline tests (recorded upstream responses, no network)
189
+ $ uv run --extra server pytest
190
+
191
+ # Live smoke tests against Transfermarkt's JSON API
192
+ $ uv run --extra server pytest -m live
193
+
194
+ # Re-record upstream fixtures and response snapshots
195
+ $ rm -rf tests/fixtures/tfmkt && uv run python scripts/capture_fixtures.py
196
+ ````
197
+
198
+ ### Running via Docker
199
+
200
+ ````bash
201
+ # Clone the repository
202
+ $ git clone https://github.com/felipeall/transfermarkt-api.git
203
+
204
+ # Go to the project's root folder
205
+ $ cd transfermarkt-api
206
+
207
+ # Build the Docker image
208
+ $ docker build -t transfermarkt-api .
209
+
210
+ # Instantiate the Docker container
211
+ $ docker run -d -p 8000:8000 transfermarkt-api
212
+
213
+ # Access the API local page
214
+ $ open http://localhost:8000/
215
+ ````
216
+
217
+ ### Environment Variables
218
+
219
+ | Variable | Description | Default |
220
+ |-------------------------------|--------------------------------------------------------------------------------------------------------|------------------------------------------|
221
+ | `RATE_LIMITING_ENABLE` | Enable rate limiting feature for API calls | `false` |
222
+ | `RATE_LIMITING_FREQUENCY` | Requests allowed per client IP across all routes, in [limits notation](https://limits.readthedocs.io/en/stable/quickstart.html#rate-limit-string-notation) | `2/3seconds` |
223
+ | `TFMKT_BASE_URL` | Transfermarkt JSON API base URL | `https://tmapi.transfermarkt.technology` |
224
+ | `TFMKT_TIMEOUT_SECONDS` | Timeout for each upstream request | `15` |
225
+ | `TFMKT_MAX_CONCURRENCY` | Maximum concurrent upstream requests across the whole server | `10` |
226
+ | `CACHE_TTL_SECONDS` | How long successful upstream responses are cached | `600` |
227
+ | `CACHE_REFERENCE_TTL_SECONDS` | Cache duration for reference data (countries, positions, ...) | `86400` |
228
+ | `CACHE_MAX_ENTRIES` | Maximum number of cached upstream responses | `2000` |
229
+
230
+ Rate limits apply to the API routes only: `/health`, the docs (`/`, `/docs`, `/redoc`) and `/openapi.json` are not
231
+ counted. They are counted per client IP: the `Fly-Client-IP` header set by the Fly.io proxy, or the address of the
232
+ connection when that header is missing. `X-Forwarded-For` is not trusted. Behind another reverse proxy, all
233
+ requests share the proxy's address, and outside Fly.io clients can send `Fly-Client-IP` themselves, so adjust
234
+ `client_ip` in `app/main.py` before enabling rate limiting on another host.