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,35 @@
1
+ from transfermarkt_api.tfmkt import TfmktClient
2
+ from transfermarkt_api.tfmkt.concurrency import gather
3
+ from transfermarkt_api.tfmkt.reference import market_value_of
4
+
5
+
6
+ async def get_player_market_value(tfmkt: TfmktClient, player_id: str) -> dict:
7
+ """
8
+ Current market value, valuation history (oldest first) and worldwide market-value ranking.
9
+
10
+ Club names are the clubs' current names. Only the worldwide ranking is available upstream; the v3 position,
11
+ club and country rankings have no JSON source.
12
+ """
13
+ data, ranking = await gather(
14
+ tfmkt.player_market_value_history(player_id),
15
+ tfmkt.player_market_value_ranking(player_id),
16
+ )
17
+ history = sorted(data.get("history") or [], key=lambda e: (e.get("marketValue") or {}).get("determined") or "")
18
+ clubs = await tfmkt.clubs(entry.get("clubId") for entry in history)
19
+ worldwide = (ranking or {}).get("ranking")
20
+
21
+ return {
22
+ "id": player_id,
23
+ "marketValue": market_value_of((data.get("current") or {}).get("marketValue")),
24
+ "marketValueHistory": [
25
+ {
26
+ "age": entry.get("age"),
27
+ "date": (entry.get("marketValue") or {}).get("determined"),
28
+ "clubId": str(entry["clubId"]) if entry.get("clubId") else None,
29
+ "clubName": clubs.get(str(entry.get("clubId")), {}).get("name"),
30
+ "marketValue": market_value_of(entry.get("marketValue")),
31
+ }
32
+ for entry in history
33
+ ],
34
+ "ranking": {"Worldwide": worldwide} if worldwide else None,
35
+ }
@@ -0,0 +1,107 @@
1
+ from typing import Optional
2
+
3
+ from transfermarkt_api.tfmkt import TfmktClient
4
+ from transfermarkt_api.tfmkt.concurrency import gather
5
+ from transfermarkt_api.tfmkt.reference import get_reference, last_page_number
6
+
7
+ PAGE_SIZE = 50
8
+ # upstream participationState -> participation; other values are passed through unchanged
9
+ PARTICIPATION = {
10
+ "played": "played",
11
+ "in squad": "onBench",
12
+ "not in squad": "notInSquad",
13
+ "injured": "injured",
14
+ "absent": "absent",
15
+ }
16
+ # detailed stat -> (statistics group, upstream field); null when upstream did not record it for the match
17
+ DETAILED_STATS = {
18
+ "ownGoals": ("goalStatistics", "ownGoalsScored"),
19
+ "penaltyGoals": ("goalStatistics", "penaltyShooterGoalsScored"),
20
+ "penaltiesMissed": ("goalStatistics", "penaltyShooterMisses"),
21
+ "penaltiesSaved": ("goalStatistics", "penaltyGoalkeeperSaves"),
22
+ "shots": ("goalStatistics", "scoringAttempts"),
23
+ "shotsOnTarget": ("goalStatistics", "scoringAttemptsOnGoal"),
24
+ "passes": ("distributionStatistics", "passes"),
25
+ "accuratePasses": ("distributionStatistics", "passesReached"),
26
+ "tackles": ("duelStatistics", "tackles"),
27
+ "tacklesWon": ("duelStatistics", "tacklesWon"),
28
+ "foulsCommitted": ("duelStatistics", "foulsCommitted"),
29
+ "foulsSuffered": ("duelStatistics", "foulsGained"),
30
+ "offsides": ("duelStatistics", "offsides"),
31
+ }
32
+
33
+
34
+ async def get_player_matches(
35
+ tfmkt: TfmktClient,
36
+ player_id: str,
37
+ page_number: int,
38
+ season_id: Optional[str] = None,
39
+ ) -> dict:
40
+ """
41
+ Matches of the player's teams, most recent first, paginated, with the player's part in each.
42
+
43
+ Includes matches the player missed (`participation` tells why) and national-team matches. Detailed statistics
44
+ (shots, passes, tackles, ...) are null when upstream did not record them, which is common before 2018. Only past
45
+ matches are available upstream; there is no fixture list.
46
+ """
47
+ performance = (await tfmkt.player_performance_games(player_id))["performance"]
48
+ games = [
49
+ game
50
+ for game in performance
51
+ if season_id is None or str(game["gameInformation"].get("seasonId")) == str(season_id)
52
+ ]
53
+ games.sort(key=lambda game: (game["gameInformation"].get("date") or {}).get("dateTimeUTC") or "", reverse=True)
54
+ page = games[(page_number - 1) * PAGE_SIZE : page_number * PAGE_SIZE]
55
+
56
+ competitions, clubs, reference = await gather(
57
+ tfmkt.competitions(game["gameInformation"]["competitionId"] for game in page),
58
+ tfmkt.clubs(game["clubsInformation"][side]["clubId"] for game in page for side in ("club", "opponent")),
59
+ get_reference(tfmkt),
60
+ )
61
+
62
+ def entity(entity_id: str, records: dict) -> dict:
63
+ """`{id, name}` for a referenced club or competition."""
64
+ return {"id": str(entity_id), "name": records.get(str(entity_id), {}).get("name")}
65
+
66
+ matches = []
67
+ for game in page:
68
+ info, teams, stats = game["gameInformation"], game["clubsInformation"], game["statistics"]
69
+ general = stats["generalStatistics"]
70
+ state = general.get("participationState")
71
+ cards = stats.get("cardStatistics") or {}
72
+ goals = stats.get("goalStatistics") or {}
73
+ playing_time = stats.get("playingTimeStatistics") or {}
74
+ matches.append({
75
+ "gameId": str(info["gameId"]),
76
+ "date": (info.get("date") or {}).get("dateTimeUTC"),
77
+ "season": (info.get("season") or {}).get("display"),
78
+ "competition": entity(info["competitionId"], competitions),
79
+ "matchDay": info.get("gameDay") or None,
80
+ "club": entity(teams["club"]["clubId"], clubs),
81
+ "opponent": entity(teams["opponent"]["clubId"], clubs),
82
+ "venue": teams["club"].get("venue"),
83
+ "clubGoals": teams["club"].get("goalsTotal"),
84
+ "opponentGoals": teams["opponent"].get("goalsTotal"),
85
+ "participation": PARTICIPATION.get(state, state),
86
+ "shirtNumber": general.get("shirtNumber"),
87
+ "isCaptain": bool(general.get("isCaptain")),
88
+ "position": reference.position_name(general.get("positionId")),
89
+ "isStarting": bool(playing_time.get("isStarting")),
90
+ "substitutedInMinute": (playing_time.get("substitutedIn") or {}).get("minute"),
91
+ "substitutedOutMinute": (playing_time.get("substitutedOut") or {}).get("minute"),
92
+ "minutesPlayed": playing_time.get("playedMinutes") or 0,
93
+ "teamPoints": teams["club"].get("points"),
94
+ "goals": goals.get("goalsScoredTotal") or 0,
95
+ "assists": goals.get("assists") or 0,
96
+ "yellowCard": bool(cards.get("yellowCard")),
97
+ "secondYellowCard": bool(cards.get("yellowRedCard")),
98
+ "redCard": bool(cards.get("redCard")),
99
+ **{name: (stats.get(group) or {}).get(field) for name, (group, field) in DETAILED_STATS.items()},
100
+ })
101
+
102
+ return {
103
+ "id": player_id,
104
+ "pageNumber": page_number,
105
+ "lastPageNumber": last_page_number(len(games), PAGE_SIZE),
106
+ "matches": matches,
107
+ }
@@ -0,0 +1,31 @@
1
+ from transfermarkt_api.tfmkt import TfmktClient
2
+
3
+ CAREER_STATUS = {
4
+ "CURRENT_NATIONAL_PLAYER": "current",
5
+ "RECENT_NATIONAL_PLAYER": "recent",
6
+ "FORMER_NATIONAL_PLAYER": "former",
7
+ }
8
+
9
+
10
+ async def get_player_national_career(tfmkt: TfmktClient, player_id: str) -> dict:
11
+ """Senior and youth national teams the player has played for, in upstream order (most recent team first)."""
12
+ history = (await tfmkt.player_national_career(player_id)).get("history") or []
13
+ clubs = await tfmkt.clubs(entry.get("clubId") for entry in history)
14
+
15
+ return {
16
+ "id": player_id,
17
+ "nationalTeams": [
18
+ {
19
+ "id": str(entry["clubId"]),
20
+ "name": clubs.get(str(entry["clubId"]), {}).get("name"),
21
+ "appearances": entry.get("gamesPlayed"),
22
+ "goals": entry.get("goalsScored"),
23
+ "shirtNumber": entry.get("shirtNumber"),
24
+ "isCaptain": entry.get("isCaptain"),
25
+ "debut": entry.get("debut"),
26
+ "lastMatch": entry.get("lastGame"),
27
+ "status": CAREER_STATUS.get(entry.get("careerState")),
28
+ }
29
+ for entry in history
30
+ ],
31
+ }
@@ -0,0 +1,122 @@
1
+ from datetime import date
2
+ from typing import Optional
3
+
4
+ from transfermarkt_api.tfmkt import TfmktClient
5
+ from transfermarkt_api.tfmkt.concurrency import gather
6
+ from transfermarkt_api.tfmkt.reference import (
7
+ RETIRED_CLUB_ID,
8
+ current_assignment,
9
+ date_of_birth,
10
+ full_url,
11
+ get_reference,
12
+ height_cm,
13
+ market_value_of,
14
+ nationality_ids,
15
+ )
16
+
17
+
18
+ async def get_player_profile(tfmkt: TfmktClient, player_id: str) -> dict:
19
+ """
20
+ Player profile.
21
+
22
+ A player is retired when assigned to Transfermarkt's special "Retired" club (ID 123); `retiredSince` is the start
23
+ of that assignment and `club.lastClubId` names the club before it.
24
+ """
25
+ player, reference = await gather(tfmkt.player(player_id), get_reference(tfmkt))
26
+ attributes = player.get("attributes") or {}
27
+ birth = player.get("birthPlaceDetails") or {}
28
+ assignment = current_assignment(player) or {}
29
+ club_id = str(assignment["clubId"]) if assignment.get("clubId") else None
30
+ is_retired = club_id == RETIRED_CLUB_ID
31
+ last_club_id = str(player["lastClubId"]) if player.get("lastClubId") else None
32
+ national = next((a for a in player.get("clubAssignments") or [] if a.get("type") == "nationalTeam"), None)
33
+ national_id = str(national["clubId"]) if national and national.get("clubId") else None
34
+
35
+ clubs = await tfmkt.clubs(i for i in (club_id, last_club_id, national_id) if i)
36
+ agency = attributes.get("consultantAgency") or {}
37
+ life = player.get("lifeDates") or {}
38
+ values = player.get("marketValueDetails") or {}
39
+ trend = ((values.get("delta") or {}).get("type") or "").lower()
40
+
41
+ return {
42
+ "id": player_id,
43
+ "url": full_url(player.get("relativeUrl")),
44
+ "name": player.get("name"),
45
+ "fullName": player.get("displayName") or None,
46
+ "nameInHomeCountry": (player.get("nationalityDetails") or {}).get("passportName") or None,
47
+ "imageUrl": player.get("portraitUrl"),
48
+ "dateOfBirth": date_of_birth(player),
49
+ "dateOfDeath": None if life.get("isDateOfDeathUnknown") else life.get("dateOfDeath"),
50
+ "placeOfBirth": {
51
+ "city": birth.get("placeOfBirth") or None,
52
+ "country": reference.country_name(birth.get("countryOfBirthId")),
53
+ },
54
+ "age": life.get("age"),
55
+ "height": height_cm(player),
56
+ "citizenship": reference.country_names(*nationality_ids(player)),
57
+ "isRetired": is_retired,
58
+ "retiredSince": assignment.get("start") if is_retired else None,
59
+ "position": {
60
+ "main": (attributes.get("position") or {}).get("name"),
61
+ "group": attributes.get("positionGroupName"),
62
+ "other": [
63
+ position["name"]
64
+ for position in (attributes.get("firstSidePosition"), attributes.get("secondSidePosition"))
65
+ if position
66
+ ],
67
+ },
68
+ "foot": (attributes.get("preferredFoot") or {}).get("name"),
69
+ "shirtNumber": shirt_number_label(assignment.get("shirtNumber")),
70
+ "club": {
71
+ "id": club_id,
72
+ "name": clubs.get(club_id or "", {}).get("name"),
73
+ "joined": assignment.get("start"),
74
+ "isCaptain": assignment.get("isCaptain"),
75
+ "contractExpires": attributes.get("contractUntil"),
76
+ "lastContractRenewal": date_from_parts(attributes.get("lastContractRenewal")),
77
+ "contractOption": reference.contract_option_name(attributes.get("contractOptionId")),
78
+ "lastClubId": last_club_id,
79
+ "lastClubName": clubs.get(last_club_id or "", {}).get("name"),
80
+ },
81
+ "nationalTeam": {
82
+ "id": national_id,
83
+ "name": clubs.get(national_id, {}).get("name"),
84
+ "shirtNumber": shirt_number_label(national.get("shirtNumber")),
85
+ "isCaptain": national.get("isCaptain"),
86
+ "debut": national.get("debut"),
87
+ }
88
+ if national_id and national
89
+ else None,
90
+ "marketValue": market_value_of(values.get("current")),
91
+ "marketValueDetails": {
92
+ "lastUpdated": (values.get("current") or {}).get("determined"),
93
+ "trend": trend if trend in ("increased", "decreased", "unchanged") else None,
94
+ "previous": dated_value(values.get("previous")),
95
+ "highest": dated_value(values.get("highest")),
96
+ }
97
+ if values
98
+ else None,
99
+ "agent": {"name": agency.get("name"), "url": agency.get("relativeUrl")} if agency else None,
100
+ "outfitter": (attributes.get("outfitter") or {}).get("name"),
101
+ }
102
+
103
+
104
+ def shirt_number_label(number: Optional[int]) -> Optional[str]:
105
+ """Shirt number as the website shows it, e.g. "#10"."""
106
+ return f"#{number}" if number is not None else None
107
+
108
+
109
+ def dated_value(money: Optional[dict]) -> Optional[dict]:
110
+ """`{value, date}` for a market value with the date it was set, or None when there is no valuation."""
111
+ value = market_value_of(money)
112
+ return {"value": value, "date": money.get("determined")} if value and money else None
113
+
114
+
115
+ def date_from_parts(parts: Optional[dict]) -> Optional[str]:
116
+ """ISO date from upstream `{year, month, day}`, or None when incomplete or invalid."""
117
+ if parts is None:
118
+ return None
119
+ try:
120
+ return date(parts["year"], parts["month"], parts["day"]).isoformat()
121
+ except (TypeError, KeyError, ValueError):
122
+ return None
@@ -0,0 +1,44 @@
1
+ from transfermarkt_api.tfmkt import TfmktClient
2
+ from transfermarkt_api.tfmkt.concurrency import gather
3
+ from transfermarkt_api.tfmkt.reference import (
4
+ current_assignment,
5
+ get_reference,
6
+ last_page_number,
7
+ market_value_of,
8
+ nationality_ids,
9
+ )
10
+
11
+
12
+ async def search_players(tfmkt: TfmktClient, query: str, page_number: int) -> dict:
13
+ """Search players by name. Results keep the upstream search ranking."""
14
+ search = await tfmkt.quick_search(query, page_number)
15
+ player_ids = [str(i) for i in search["result"]["playerIds"]]
16
+
17
+ players, reference = await gather(tfmkt.players(player_ids), get_reference(tfmkt))
18
+ assignments = {i: current_assignment(players.get(i, {})) for i in player_ids}
19
+ clubs = await tfmkt.clubs(a["clubId"] for a in assignments.values() if a)
20
+
21
+ results = []
22
+ for player_id in player_ids:
23
+ player = players.get(player_id, {})
24
+ attributes = player.get("attributes") or {}
25
+ assignment = assignments[player_id]
26
+ club_id = str(assignment["clubId"]) if assignment else None
27
+ results.append(
28
+ {
29
+ "id": player_id,
30
+ "name": player.get("name"),
31
+ "position": (attributes.get("position") or {}).get("shortName"),
32
+ "club": {"id": club_id, "name": clubs.get(club_id, {}).get("name")} if club_id else None,
33
+ "age": (player.get("lifeDates") or {}).get("age"),
34
+ "nationalities": reference.country_names(*nationality_ids(player)),
35
+ "marketValue": market_value_of((player.get("marketValueDetails") or {}).get("current")),
36
+ },
37
+ )
38
+
39
+ return {
40
+ "query": query,
41
+ "pageNumber": page_number,
42
+ "lastPageNumber": last_page_number(search["totalCount"]["players"]),
43
+ "results": results,
44
+ }
@@ -0,0 +1,74 @@
1
+ from collections import Counter
2
+
3
+ from transfermarkt_api.tfmkt import TfmktClient
4
+
5
+ # Competition type IDs the website's detailed stats table leaves out (national-team matches, including friendlies).
6
+ # Mirrors `nationalTeamCompetitions` in the website's player-performance-proxy bundle.
7
+ NATIONAL_TEAM_COMPETITION_TYPE_IDS = {11, 17, 19, 20}
8
+
9
+
10
+ async def get_player_stats(tfmkt: TfmktClient, player_id: str) -> dict:
11
+ """
12
+ Aggregate a player's match records into per season, competition and club totals.
13
+
14
+ Follows the website's own aggregation of `/player/{id}/performance-game`: appearances count matches with
15
+ participation state `played`, numeric statistics are summed, and each card event counts once. The goalkeeper
16
+ columns follow the website too: `goalsConceded` sums the opponent goals scored while the player was on the pitch,
17
+ and `cleanSheets` counts matches the player played in which the opponent did not score at all. Both are computed
18
+ for every player; the website shows them only for goalkeepers.
19
+
20
+ Returns:
21
+ dict: The player ID and one stats entry per (season, competition, club), most recent season first.
22
+ """
23
+ performance = (await tfmkt.player_performance_games(player_id))["performance"]
24
+ games = [
25
+ game
26
+ for game in performance
27
+ if game["gameInformation"]["competitionTypeId"] not in NATIONAL_TEAM_COMPETITION_TYPE_IDS
28
+ ]
29
+
30
+ totals: dict[tuple[str, str, str], Counter] = {}
31
+ for game in games:
32
+ info = game["gameInformation"]
33
+ key = (str(info["seasonId"]), info["competitionId"], str(game["clubsInformation"]["club"]["clubId"]))
34
+ total = totals.setdefault(key, Counter())
35
+ stats = game["statistics"]
36
+ if stats["generalStatistics"]["participationState"] != "played":
37
+ continue
38
+ cards = stats["cardStatistics"]
39
+ total["appearances"] += 1
40
+ total["goals"] += stats["goalStatistics"]["goalsScoredTotal"] or 0
41
+ total["assists"] += stats["goalStatistics"]["assists"] or 0
42
+ total["yellowCards"] += 1 if cards.get("yellowCard") else 0
43
+ total["secondYellowCards"] += 1 if cards.get("yellowRedCard") else 0
44
+ total["redCards"] += 1 if cards.get("redCard") else 0
45
+ played_minutes = stats["playingTimeStatistics"]["playedMinutes"] or 0
46
+ total["minutesPlayed"] += played_minutes
47
+ total["goalsConceded"] += stats["goalStatistics"]["opponentGoalsOnThePitch"] or 0
48
+ total["cleanSheets"] += (
49
+ 1 if played_minutes and game["clubsInformation"]["club"]["opponentGoalsTotal"] == 0 else 0
50
+ )
51
+
52
+ competitions = await tfmkt.competitions(competition_id for _, competition_id, _ in totals)
53
+
54
+ return {
55
+ "id": player_id,
56
+ "stats": [
57
+ {
58
+ "competitionId": competition_id,
59
+ "competitionName": competitions.get(competition_id, {}).get("name"),
60
+ "seasonId": season_id,
61
+ "clubId": club_id,
62
+ "appearances": total["appearances"],
63
+ "goals": total["goals"],
64
+ "assists": total["assists"],
65
+ "yellowCards": total["yellowCards"],
66
+ "secondYellowCards": total["secondYellowCards"],
67
+ "redCards": total["redCards"],
68
+ "minutesPlayed": total["minutesPlayed"],
69
+ "goalsConceded": total["goalsConceded"],
70
+ "cleanSheets": total["cleanSheets"],
71
+ }
72
+ for (season_id, competition_id, club_id), total in totals.items()
73
+ ],
74
+ }
@@ -0,0 +1,89 @@
1
+ import re
2
+ from typing import Optional
3
+
4
+ from transfermarkt_api.tfmkt import TfmktClient
5
+ from transfermarkt_api.tfmkt.concurrency import gather
6
+ from transfermarkt_api.tfmkt.reference import get_reference, market_value_of, value_of
7
+
8
+ # upstream typeDetails.type -> transferType; STANDARD is split into transfer and freeTransfer by its fee label
9
+ TRANSFER_TYPES = {
10
+ "STANDARD": "transfer",
11
+ "INTERNAL_TRANSFER": "internal",
12
+ "ACTIVE_LOAN_TRANSFER": "loan",
13
+ "RETURNED_FROM_PREVIOUS_LOAN": "endOfLoan",
14
+ }
15
+
16
+ # formerClubsNote lists youth clubs as "Grandoli FC (1992-1995), Newell's Old Boys (1995-2000)"
17
+ YOUTH_CLUB_SEPARATOR = re.compile(r"(?<=\)),\s*")
18
+
19
+
20
+ async def get_player_transfers(tfmkt: TfmktClient, player_id: str) -> dict:
21
+ """
22
+ Transfer history, upcoming (pending) transfers first, then completed ones, most recent first.
23
+
24
+ `fee` is null when upstream has no numeric fee: free transfers, loans without fee and unknown fees (`-`).
25
+ `transferType` tells these apart: an unknown fee is a `transfer` with a null fee, never a `freeTransfer`.
26
+ """
27
+ player, history, reference = await gather(
28
+ tfmkt.player(player_id),
29
+ tfmkt.player_transfer_history(player_id),
30
+ get_reference(tfmkt),
31
+ )
32
+ transfers = (history or {}).get("history") or {}
33
+ records = (transfers.get("pending") or []) + (transfers.get("terminated") or [])
34
+ sides = [side for record in records for side in (record["transferSource"], record["transferDestination"])]
35
+ clubs, competitions = await gather(
36
+ tfmkt.clubs(side["clubId"] for side in sides),
37
+ tfmkt.competitions(side.get("competitionId") for side in sides),
38
+ )
39
+
40
+ def club(side: dict) -> dict:
41
+ """Club with the league and country recorded at the time of this transfer."""
42
+ club_id = str(side["clubId"])
43
+ country_id = side.get("countryId")
44
+ competition_id = side.get("competitionId")
45
+ return {
46
+ "id": club_id,
47
+ "name": clubs.get(club_id, {}).get("name"),
48
+ "countryId": str(country_id) if country_id is not None else None,
49
+ "countryName": reference.country_name(country_id),
50
+ "league": {
51
+ "id": str(competition_id),
52
+ "name": competitions.get(str(competition_id), {}).get("name"),
53
+ }
54
+ if competition_id
55
+ else None,
56
+ }
57
+
58
+ former_clubs_note = (player.get("attributes") or {}).get("formerClubsNote")
59
+
60
+ return {
61
+ "id": player_id,
62
+ "transfers": [
63
+ {
64
+ "id": str(record["id"]),
65
+ "clubFrom": club(record["transferSource"]),
66
+ "clubTo": club(record["transferDestination"]),
67
+ "date": (record["details"].get("date") or "")[:10] or None,
68
+ "upcoming": bool(record["details"].get("isPending")),
69
+ "transferType": transfer_type(record),
70
+ "season": (record["details"].get("season") or {}).get("display"),
71
+ "marketValue": market_value_of(record["details"].get("marketValue")),
72
+ "fee": value_of(record["details"].get("fee")),
73
+ "age": record["details"].get("age"),
74
+ "contractUntil": (record["details"].get("contractUntilDate") or "")[:10] or None,
75
+ "remainingContractDays": (record["details"].get("remainingContractPeriod") or {}).get("days"),
76
+ }
77
+ for record in records
78
+ ],
79
+ "youthClubs": YOUTH_CLUB_SEPARATOR.split(former_clubs_note) if former_clubs_note else [],
80
+ }
81
+
82
+
83
+ def transfer_type(record: dict) -> Optional[str]:
84
+ """Kind of transfer, or None for an upstream type this API does not know."""
85
+ kind = TRANSFER_TYPES.get((record.get("typeDetails") or {}).get("type") or "")
86
+ fee_label = ((record["details"].get("fee") or {}).get("compact") or {}).get("content") or ""
87
+ if kind == "transfer" and fee_label.strip().lower() == "free transfer":
88
+ return "freeTransfer"
89
+ return kind
@@ -0,0 +1 @@
1
+ from transfermarkt_api.tfmkt.client import TfmktClient as TfmktClient