datafc 2.7.0__tar.gz → 2.7.2__tar.gz

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 (76) hide show
  1. {datafc-2.7.0 → datafc-2.7.2}/PKG-INFO +29 -6
  2. {datafc-2.7.0 → datafc-2.7.2}/README.md +28 -5
  3. {datafc-2.7.0 → datafc-2.7.2}/datafc/__init__.py +1 -1
  4. {datafc-2.7.0 → datafc-2.7.2}/datafc/clubelo/__init__.py +18 -18
  5. {datafc-2.7.0 → datafc-2.7.2}/datafc/clubelo/_client.py +112 -112
  6. {datafc-2.7.0 → datafc-2.7.2}/datafc/clubelo/_parsers.py +40 -40
  7. {datafc-2.7.0 → datafc-2.7.2}/datafc/clubelo/fetch_club_history_data.py +34 -34
  8. {datafc-2.7.0 → datafc-2.7.2}/datafc/clubelo/fetch_daily_ranking_data.py +48 -48
  9. {datafc-2.7.0 → datafc-2.7.2}/datafc/clubelo/fetch_fixtures_data.py +27 -27
  10. {datafc-2.7.0 → datafc-2.7.2}/datafc/eloratings/__init__.py +26 -26
  11. {datafc-2.7.0 → datafc-2.7.2}/datafc/eloratings/_client.py +112 -112
  12. {datafc-2.7.0 → datafc-2.7.2}/datafc/eloratings/_parsers.py +212 -212
  13. {datafc-2.7.0 → datafc-2.7.2}/datafc/eloratings/fetch_country_codes_data.py +28 -28
  14. {datafc-2.7.0 → datafc-2.7.2}/datafc/eloratings/fetch_country_matches_data.py +35 -35
  15. {datafc-2.7.0 → datafc-2.7.2}/datafc/eloratings/fetch_teams_data.py +27 -27
  16. {datafc-2.7.0 → datafc-2.7.2}/datafc/eloratings/fetch_tournament_codes_data.py +28 -28
  17. {datafc-2.7.0 → datafc-2.7.2}/datafc/eloratings/fetch_tournament_editions_data.py +30 -30
  18. {datafc-2.7.0 → datafc-2.7.2}/datafc/eloratings/fetch_tournament_groups_data.py +33 -33
  19. {datafc-2.7.0 → datafc-2.7.2}/datafc/eloratings/fetch_world_ranking_data.py +26 -26
  20. {datafc-2.7.0 → datafc-2.7.2}/datafc/utils/_config.py +6 -0
  21. {datafc-2.7.0 → datafc-2.7.2}/datafc.egg-info/PKG-INFO +29 -6
  22. {datafc-2.7.0 → datafc-2.7.2}/pyproject.toml +1 -1
  23. {datafc-2.7.0 → datafc-2.7.2}/LICENSE +0 -0
  24. {datafc-2.7.0 → datafc-2.7.2}/datafc/exceptions.py +0 -0
  25. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/__init__.py +0 -0
  26. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/_core.py +0 -0
  27. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/_parsers.py +0 -0
  28. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/aio.py +0 -0
  29. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_average_positions_data.py +0 -0
  30. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_coordinates_data.py +0 -0
  31. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_formations_data.py +0 -0
  32. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_goal_networks_data.py +0 -0
  33. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_incidents_data.py +0 -0
  34. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_league_player_stats_data.py +0 -0
  35. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_lineups_data.py +0 -0
  36. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_match_data.py +0 -0
  37. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_match_details_data.py +0 -0
  38. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_match_h2h_data.py +0 -0
  39. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_match_odds_data.py +0 -0
  40. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_match_stats_data.py +0 -0
  41. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_momentum_data.py +0 -0
  42. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_past_matches_data.py +0 -0
  43. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_player_attribute_overviews_data.py +0 -0
  44. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_player_career_stats_data.py +0 -0
  45. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_player_data.py +0 -0
  46. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_player_match_log_data.py +0 -0
  47. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_player_national_team_data.py +0 -0
  48. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_player_stats_data.py +0 -0
  49. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_player_transfers_data.py +0 -0
  50. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_pregame_form_data.py +0 -0
  51. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_referee_stats_data.py +0 -0
  52. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_search_data.py +0 -0
  53. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_season_rounds_data.py +0 -0
  54. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_seasons_data.py +0 -0
  55. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_shots_data.py +0 -0
  56. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_squad_data.py +0 -0
  57. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_standings_data.py +0 -0
  58. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_substitutions_data.py +0 -0
  59. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_team_data.py +0 -0
  60. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_team_match_history_data.py +0 -0
  61. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_team_stats_data.py +0 -0
  62. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_team_transfers_data.py +0 -0
  63. {datafc-2.7.0 → datafc-2.7.2}/datafc/sofascore/fetch_upcoming_matches_data.py +0 -0
  64. {datafc-2.7.0 → datafc-2.7.2}/datafc/utils/__init__.py +0 -0
  65. {datafc-2.7.0 → datafc-2.7.2}/datafc/utils/_async_client.py +0 -0
  66. {datafc-2.7.0 → datafc-2.7.2}/datafc/utils/_cache.py +0 -0
  67. {datafc-2.7.0 → datafc-2.7.2}/datafc/utils/_client.py +0 -0
  68. {datafc-2.7.0 → datafc-2.7.2}/datafc/utils/_helpers.py +0 -0
  69. {datafc-2.7.0 → datafc-2.7.2}/datafc/utils/_save_files.py +0 -0
  70. {datafc-2.7.0 → datafc-2.7.2}/datafc/utils/_tournament_info.py +0 -0
  71. {datafc-2.7.0 → datafc-2.7.2}/datafc/utils/_validate.py +0 -0
  72. {datafc-2.7.0 → datafc-2.7.2}/datafc.egg-info/SOURCES.txt +0 -0
  73. {datafc-2.7.0 → datafc-2.7.2}/datafc.egg-info/dependency_links.txt +0 -0
  74. {datafc-2.7.0 → datafc-2.7.2}/datafc.egg-info/requires.txt +0 -0
  75. {datafc-2.7.0 → datafc-2.7.2}/datafc.egg-info/top_level.txt +0 -0
  76. {datafc-2.7.0 → datafc-2.7.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datafc
3
- Version: 2.7.0
3
+ Version: 2.7.2
4
4
  Summary: Fetch, process, and export structured football data.
5
5
  Author-email: Uraz Akgül <urazdev@gmail.com>
6
6
  License: MIT
@@ -24,7 +24,7 @@ Requires-Dist: pytest>=8.0; extra == "dev"
24
24
  Requires-Dist: pytest-mock>=3.12; extra == "dev"
25
25
  Dynamic: license-file
26
26
 
27
- # datafc v2.7.0
27
+ # datafc v2.7.2
28
28
 
29
29
  ## Overview
30
30
 
@@ -580,17 +580,28 @@ wc_group_df = match_data(
580
580
  tournament_type="world_cup",
581
581
  tournament_stage="group_stage_week",
582
582
  )
583
+
584
+ # Domestic league promotion playoff (e.g. TFF 1. Lig, EFL Championship):
585
+ # use season_rounds_data to discover the round_number for each stage.
586
+ playoff_df = match_data(
587
+ tournament_id=98,
588
+ season_id=<season_id>,
589
+ week_number=<round_number>,
590
+ tournament_type="league_playoff",
591
+ tournament_stage="final",
592
+ )
583
593
  ```
584
594
 
585
595
  Parameters:
586
596
 
587
597
  - `tournament_id` (int)
588
598
  - `season_id` (int)
589
- - `week_number` (int, optional): Required for league rounds, UEFA stages, and `world_cup` + `group_stage_week`. Not needed for other `world_cup` stages.
590
- - `tournament_type` (str, optional): `"uefa"` for UEFA competitions, `"world_cup"` for FIFA World Cup. `None` assumes a domestic league.
599
+ - `week_number` (int, optional): Required for league rounds, UEFA stages, `world_cup` + `group_stage_week`, and all `league_playoff` stages. Not needed for other `world_cup` stages.
600
+ - `tournament_type` (str, optional): `"uefa"` for UEFA competitions, `"world_cup"` for FIFA World Cup, `"league_playoff"` for domestic promotion playoffs. `None` assumes a domestic league.
591
601
  - `tournament_stage` (str, optional): Required when `tournament_type` is set.
592
602
  - `"uefa"` options: `preliminary_semifinals`, `preliminary_final`, `qualification_round`, `qualification_playoff`, `group_stage_week`, `playoff_round`, `round_of_16`, `quarterfinals`, `semifinals`, `match_for_3rd_place`, `final`.
593
603
  - `"world_cup"` options: `group_stage_week`, `round_of_32`, `round_of_16`, `quarterfinals`, `semifinals`, `match_for_3rd_place`, `final`.
604
+ - `"league_playoff"` options: `qualification_round`, `quarterfinals`, `semifinals`, `final`. For `qualification_round`, `week_number` doubles as the round number in the slug (`1` → `qualification-round-1`, `2` → `qualification-round-2`).
594
605
 
595
606
  Columns: `country`, `tournament`, `season`, `week`, `game_id`, `home_team`, `home_team_id`, `away_team`, `away_team_id`, `injury_time_1`, `injury_time_2`, `start_timestamp`, `status`, `home_score_current`, `home_score_display`, `home_score_period1`, `home_score_period2`, `home_score_normaltime`, `away_score_current`, `away_score_display`, `away_score_period1`, `away_score_period2`, `away_score_normaltime`.
596
607
 
@@ -843,8 +854,8 @@ Parameters:
843
854
 
844
855
  - `tournament_id` (int)
845
856
  - `season_id` (int)
846
- - `week_number` (int, optional): Required for league rounds, UEFA stages, and `world_cup` + `group_stage_week`. Not needed for other `world_cup` stages.
847
- - `tournament_type` (str, optional): `"uefa"` for UEFA competitions, `"world_cup"` for FIFA World Cup.
857
+ - `week_number` (int, optional): Required for league rounds, UEFA stages, `world_cup` + `group_stage_week`, and all `league_playoff` stages. Not needed for other `world_cup` stages.
858
+ - `tournament_type` (str, optional): `"uefa"` for UEFA competitions, `"world_cup"` for FIFA World Cup, `"league_playoff"` for domestic promotion playoffs.
848
859
  - `tournament_stage` (str, optional): Required when `tournament_type` is set. Same options as `match_data`.
849
860
 
850
861
  Same columns as `match_data`.
@@ -1067,6 +1078,18 @@ eloratings.set_eloratings_base_url("http://www.eloratings.net")
1067
1078
 
1068
1079
  ## What's new
1069
1080
 
1081
+ ### v2.7.2
1082
+
1083
+ - **`league_playoff` gains `qualification_round`.** `match_data` and `past_matches_data` now cover the early elimination rounds of domestic promotion playoffs (e.g. TFF 1. Lig 1. and 2. eleme turu). `week_number` doubles as the round number in the slug (`1` → `qualification-round-1`, `2` → `qualification-round-2`), matching the existing UEFA `qualification_round` convention.
1084
+
1085
+ ---
1086
+
1087
+ ### v2.7.1
1088
+
1089
+ - **New `tournament_type="league_playoff"`** for `match_data` and `past_matches_data`. Covers domestic promotion playoffs (e.g. TFF 1. Lig, EFL Championship) with `quarterfinals`, `semifinals`, and `final` stages. Use `season_rounds_data` to discover the `week_number` for each stage.
1090
+
1091
+ ---
1092
+
1070
1093
  ### v2.7.0
1071
1094
 
1072
1095
  - **Club Elo ratings via ClubElo.** Pull daily Elo rankings of European clubs (going back to 1939), a single club's full rating history, and probability-based predictions for upcoming matches — all as `pandas` DataFrames. Three functions under `datafc.clubelo`: `daily_ranking_data`, `club_history_data`, `fixtures_data`.
@@ -1,4 +1,4 @@
1
- # datafc v2.7.0
1
+ # datafc v2.7.2
2
2
 
3
3
  ## Overview
4
4
 
@@ -554,17 +554,28 @@ wc_group_df = match_data(
554
554
  tournament_type="world_cup",
555
555
  tournament_stage="group_stage_week",
556
556
  )
557
+
558
+ # Domestic league promotion playoff (e.g. TFF 1. Lig, EFL Championship):
559
+ # use season_rounds_data to discover the round_number for each stage.
560
+ playoff_df = match_data(
561
+ tournament_id=98,
562
+ season_id=<season_id>,
563
+ week_number=<round_number>,
564
+ tournament_type="league_playoff",
565
+ tournament_stage="final",
566
+ )
557
567
  ```
558
568
 
559
569
  Parameters:
560
570
 
561
571
  - `tournament_id` (int)
562
572
  - `season_id` (int)
563
- - `week_number` (int, optional): Required for league rounds, UEFA stages, and `world_cup` + `group_stage_week`. Not needed for other `world_cup` stages.
564
- - `tournament_type` (str, optional): `"uefa"` for UEFA competitions, `"world_cup"` for FIFA World Cup. `None` assumes a domestic league.
573
+ - `week_number` (int, optional): Required for league rounds, UEFA stages, `world_cup` + `group_stage_week`, and all `league_playoff` stages. Not needed for other `world_cup` stages.
574
+ - `tournament_type` (str, optional): `"uefa"` for UEFA competitions, `"world_cup"` for FIFA World Cup, `"league_playoff"` for domestic promotion playoffs. `None` assumes a domestic league.
565
575
  - `tournament_stage` (str, optional): Required when `tournament_type` is set.
566
576
  - `"uefa"` options: `preliminary_semifinals`, `preliminary_final`, `qualification_round`, `qualification_playoff`, `group_stage_week`, `playoff_round`, `round_of_16`, `quarterfinals`, `semifinals`, `match_for_3rd_place`, `final`.
567
577
  - `"world_cup"` options: `group_stage_week`, `round_of_32`, `round_of_16`, `quarterfinals`, `semifinals`, `match_for_3rd_place`, `final`.
578
+ - `"league_playoff"` options: `qualification_round`, `quarterfinals`, `semifinals`, `final`. For `qualification_round`, `week_number` doubles as the round number in the slug (`1` → `qualification-round-1`, `2` → `qualification-round-2`).
568
579
 
569
580
  Columns: `country`, `tournament`, `season`, `week`, `game_id`, `home_team`, `home_team_id`, `away_team`, `away_team_id`, `injury_time_1`, `injury_time_2`, `start_timestamp`, `status`, `home_score_current`, `home_score_display`, `home_score_period1`, `home_score_period2`, `home_score_normaltime`, `away_score_current`, `away_score_display`, `away_score_period1`, `away_score_period2`, `away_score_normaltime`.
570
581
 
@@ -817,8 +828,8 @@ Parameters:
817
828
 
818
829
  - `tournament_id` (int)
819
830
  - `season_id` (int)
820
- - `week_number` (int, optional): Required for league rounds, UEFA stages, and `world_cup` + `group_stage_week`. Not needed for other `world_cup` stages.
821
- - `tournament_type` (str, optional): `"uefa"` for UEFA competitions, `"world_cup"` for FIFA World Cup.
831
+ - `week_number` (int, optional): Required for league rounds, UEFA stages, `world_cup` + `group_stage_week`, and all `league_playoff` stages. Not needed for other `world_cup` stages.
832
+ - `tournament_type` (str, optional): `"uefa"` for UEFA competitions, `"world_cup"` for FIFA World Cup, `"league_playoff"` for domestic promotion playoffs.
822
833
  - `tournament_stage` (str, optional): Required when `tournament_type` is set. Same options as `match_data`.
823
834
 
824
835
  Same columns as `match_data`.
@@ -1041,6 +1052,18 @@ eloratings.set_eloratings_base_url("http://www.eloratings.net")
1041
1052
 
1042
1053
  ## What's new
1043
1054
 
1055
+ ### v2.7.2
1056
+
1057
+ - **`league_playoff` gains `qualification_round`.** `match_data` and `past_matches_data` now cover the early elimination rounds of domestic promotion playoffs (e.g. TFF 1. Lig 1. and 2. eleme turu). `week_number` doubles as the round number in the slug (`1` → `qualification-round-1`, `2` → `qualification-round-2`), matching the existing UEFA `qualification_round` convention.
1058
+
1059
+ ---
1060
+
1061
+ ### v2.7.1
1062
+
1063
+ - **New `tournament_type="league_playoff"`** for `match_data` and `past_matches_data`. Covers domestic promotion playoffs (e.g. TFF 1. Lig, EFL Championship) with `quarterfinals`, `semifinals`, and `final` stages. Use `season_rounds_data` to discover the `week_number` for each stage.
1064
+
1065
+ ---
1066
+
1044
1067
  ### v2.7.0
1045
1068
 
1046
1069
  - **Club Elo ratings via ClubElo.** Pull daily Elo rankings of European clubs (going back to 1939), a single club's full rating history, and probability-based predictions for upcoming matches — all as `pandas` DataFrames. Three functions under `datafc.clubelo`: `daily_ranking_data`, `club_history_data`, `fixtures_data`.
@@ -1,4 +1,4 @@
1
- __version__ = "2.7.0"
1
+ __version__ = "2.7.2"
2
2
 
3
3
  from .sofascore import *
4
4
  from . import clubelo
@@ -1,18 +1,18 @@
1
- from datafc.utils._config import (
2
- get_clubelo_base_url,
3
- reset_clubelo_base_url,
4
- set_clubelo_base_url,
5
- )
6
-
7
- from .fetch_club_history_data import club_history_data
8
- from .fetch_daily_ranking_data import daily_ranking_data
9
- from .fetch_fixtures_data import fixtures_data
10
-
11
- __all__ = [
12
- "daily_ranking_data",
13
- "club_history_data",
14
- "fixtures_data",
15
- "get_clubelo_base_url",
16
- "set_clubelo_base_url",
17
- "reset_clubelo_base_url",
18
- ]
1
+ from datafc.utils._config import (
2
+ get_clubelo_base_url,
3
+ reset_clubelo_base_url,
4
+ set_clubelo_base_url,
5
+ )
6
+
7
+ from .fetch_club_history_data import club_history_data
8
+ from .fetch_daily_ranking_data import daily_ranking_data
9
+ from .fetch_fixtures_data import fixtures_data
10
+
11
+ __all__ = [
12
+ "daily_ranking_data",
13
+ "club_history_data",
14
+ "fixtures_data",
15
+ "get_clubelo_base_url",
16
+ "set_clubelo_base_url",
17
+ "reset_clubelo_base_url",
18
+ ]
@@ -1,112 +1,112 @@
1
- import logging
2
- import threading
3
- import time
4
- from typing import Optional
5
-
6
- from curl_cffi import requests as cf_requests
7
-
8
- from datafc.exceptions import APIError, RateLimitError, ServerError
9
- from datafc.utils._cache import get_default_cache
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- class ClubEloClient:
15
- """
16
- HTTP client for the ClubElo public API. Responses are CSV text.
17
-
18
- Cached payloads are stored as ``{"csv": "<text>"}`` so they share the same
19
- DiskCache backend used by SofascoreClient.
20
-
21
- Args:
22
- rate_limit: Maximum requests per second. Defaults to 2.0.
23
- timeout: Request timeout in seconds. Defaults to 30.
24
- retries: Number of retry attempts on transient errors. Defaults to 3.
25
- cache: Optional DiskCache. Falls back to the module-level default cache.
26
- """
27
-
28
- _class_lock: threading.Lock = threading.Lock()
29
- _class_last_request_time: float = 0.0
30
-
31
- def __init__(
32
- self,
33
- rate_limit: float = 2.0,
34
- timeout: int = 30,
35
- retries: int = 3,
36
- cache=None,
37
- ) -> None:
38
- self._min_interval = 1.0 / rate_limit if rate_limit > 0 else 0.0
39
- self._timeout = timeout
40
- self._retries = retries
41
- self._cache = cache if cache is not None else get_default_cache()
42
- self._session = cf_requests.Session(impersonate="chrome124")
43
-
44
- def _rate_limit_wait(self) -> None:
45
- with ClubEloClient._class_lock:
46
- elapsed = time.monotonic() - ClubEloClient._class_last_request_time
47
- if elapsed < self._min_interval:
48
- time.sleep(self._min_interval - elapsed)
49
- ClubEloClient._class_last_request_time = time.monotonic()
50
-
51
- def get(self, url: str) -> str:
52
- """Perform a GET, return the response body as CSV text."""
53
- if self._cache is not None:
54
- cached = self._cache.get(url)
55
- if isinstance(cached, dict) and "csv" in cached:
56
- logger.debug("Cache hit: %s", url)
57
- return cached["csv"]
58
-
59
- last_exc: Optional[Exception] = None
60
-
61
- for attempt in range(1, self._retries + 1):
62
- self._rate_limit_wait()
63
- try:
64
- response = self._session.get(url, timeout=self._timeout)
65
-
66
- if response.status_code == 200:
67
- text = response.text
68
- if self._cache is not None:
69
- self._cache.set(url, {"csv": text})
70
- return text
71
-
72
- if response.status_code == 429:
73
- wait = 2 ** attempt
74
- logger.warning(
75
- "Rate limited (429). Waiting %ds before retry %d/%d. URL: %s",
76
- wait, attempt, self._retries, url,
77
- )
78
- time.sleep(wait)
79
- last_exc = RateLimitError(429, url)
80
- continue
81
-
82
- if response.status_code in (500, 502, 503, 504):
83
- wait = 2 ** attempt
84
- logger.warning(
85
- "Server error %d. Waiting %ds before retry %d/%d. URL: %s",
86
- response.status_code, wait, attempt, self._retries, url,
87
- )
88
- time.sleep(wait)
89
- last_exc = ServerError(response.status_code, url)
90
- continue
91
-
92
- raise APIError(response.status_code, url)
93
-
94
- except (APIError, RateLimitError, ServerError):
95
- raise
96
- except Exception as exc:
97
- logger.warning("Request failed (attempt %d/%d): %s", attempt, self._retries, exc)
98
- last_exc = exc
99
- time.sleep(2 ** attempt)
100
-
101
- if isinstance(last_exc, (RateLimitError, ServerError, APIError)):
102
- raise last_exc
103
- raise APIError(0, url, f"All {self._retries} attempts failed") from last_exc
104
-
105
- def close(self) -> None:
106
- self._session.close()
107
-
108
- def __enter__(self) -> "ClubEloClient":
109
- return self
110
-
111
- def __exit__(self, *_) -> None:
112
- self.close()
1
+ import logging
2
+ import threading
3
+ import time
4
+ from typing import Optional
5
+
6
+ from curl_cffi import requests as cf_requests
7
+
8
+ from datafc.exceptions import APIError, RateLimitError, ServerError
9
+ from datafc.utils._cache import get_default_cache
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ClubEloClient:
15
+ """
16
+ HTTP client for the ClubElo public API. Responses are CSV text.
17
+
18
+ Cached payloads are stored as ``{"csv": "<text>"}`` so they share the same
19
+ DiskCache backend used by SofascoreClient.
20
+
21
+ Args:
22
+ rate_limit: Maximum requests per second. Defaults to 2.0.
23
+ timeout: Request timeout in seconds. Defaults to 30.
24
+ retries: Number of retry attempts on transient errors. Defaults to 3.
25
+ cache: Optional DiskCache. Falls back to the module-level default cache.
26
+ """
27
+
28
+ _class_lock: threading.Lock = threading.Lock()
29
+ _class_last_request_time: float = 0.0
30
+
31
+ def __init__(
32
+ self,
33
+ rate_limit: float = 2.0,
34
+ timeout: int = 30,
35
+ retries: int = 3,
36
+ cache=None,
37
+ ) -> None:
38
+ self._min_interval = 1.0 / rate_limit if rate_limit > 0 else 0.0
39
+ self._timeout = timeout
40
+ self._retries = retries
41
+ self._cache = cache if cache is not None else get_default_cache()
42
+ self._session = cf_requests.Session(impersonate="chrome124")
43
+
44
+ def _rate_limit_wait(self) -> None:
45
+ with ClubEloClient._class_lock:
46
+ elapsed = time.monotonic() - ClubEloClient._class_last_request_time
47
+ if elapsed < self._min_interval:
48
+ time.sleep(self._min_interval - elapsed)
49
+ ClubEloClient._class_last_request_time = time.monotonic()
50
+
51
+ def get(self, url: str) -> str:
52
+ """Perform a GET, return the response body as CSV text."""
53
+ if self._cache is not None:
54
+ cached = self._cache.get(url)
55
+ if isinstance(cached, dict) and "csv" in cached:
56
+ logger.debug("Cache hit: %s", url)
57
+ return cached["csv"]
58
+
59
+ last_exc: Optional[Exception] = None
60
+
61
+ for attempt in range(1, self._retries + 1):
62
+ self._rate_limit_wait()
63
+ try:
64
+ response = self._session.get(url, timeout=self._timeout)
65
+
66
+ if response.status_code == 200:
67
+ text = response.text
68
+ if self._cache is not None:
69
+ self._cache.set(url, {"csv": text})
70
+ return text
71
+
72
+ if response.status_code == 429:
73
+ wait = 2 ** attempt
74
+ logger.warning(
75
+ "Rate limited (429). Waiting %ds before retry %d/%d. URL: %s",
76
+ wait, attempt, self._retries, url,
77
+ )
78
+ time.sleep(wait)
79
+ last_exc = RateLimitError(429, url)
80
+ continue
81
+
82
+ if response.status_code in (500, 502, 503, 504):
83
+ wait = 2 ** attempt
84
+ logger.warning(
85
+ "Server error %d. Waiting %ds before retry %d/%d. URL: %s",
86
+ response.status_code, wait, attempt, self._retries, url,
87
+ )
88
+ time.sleep(wait)
89
+ last_exc = ServerError(response.status_code, url)
90
+ continue
91
+
92
+ raise APIError(response.status_code, url)
93
+
94
+ except (APIError, RateLimitError, ServerError):
95
+ raise
96
+ except Exception as exc:
97
+ logger.warning("Request failed (attempt %d/%d): %s", attempt, self._retries, exc)
98
+ last_exc = exc
99
+ time.sleep(2 ** attempt)
100
+
101
+ if isinstance(last_exc, (RateLimitError, ServerError, APIError)):
102
+ raise last_exc
103
+ raise APIError(0, url, f"All {self._retries} attempts failed") from last_exc
104
+
105
+ def close(self) -> None:
106
+ self._session.close()
107
+
108
+ def __enter__(self) -> "ClubEloClient":
109
+ return self
110
+
111
+ def __exit__(self, *_) -> None:
112
+ self.close()
@@ -1,40 +1,40 @@
1
- from io import StringIO
2
-
3
- import pandas as pd
4
-
5
- from datafc.exceptions import DataNotAvailableError
6
-
7
-
8
- def _read_csv(text: str, *, context: str) -> pd.DataFrame:
9
- if not text or not text.strip():
10
- raise DataNotAvailableError(f"Empty response from ClubElo for {context}.")
11
- df = pd.read_csv(StringIO(text))
12
- if df.empty:
13
- raise DataNotAvailableError(f"No rows returned from ClubElo for {context}.")
14
- return df
15
-
16
-
17
- def _normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
18
- df.columns = [str(c).strip().lower() for c in df.columns]
19
- return df
20
-
21
-
22
- def parse_daily_ranking(text: str, date: str) -> pd.DataFrame:
23
- """Columns: rank, club, country, level, elo, from, to."""
24
- df = _normalize_columns(_read_csv(text, context=f"date={date}"))
25
- df.insert(0, "query_date", date)
26
- return df
27
-
28
-
29
- def parse_club_history(text: str, club: str) -> pd.DataFrame:
30
- """Columns: rank, club, country, level, elo, from, to (rank may be 'None')."""
31
- df = _normalize_columns(_read_csv(text, context=f"club={club}"))
32
- if "rank" in df.columns:
33
- df["rank"] = pd.to_numeric(df["rank"], errors="coerce")
34
- df.insert(0, "query_club", club)
35
- return df
36
-
37
-
38
- def parse_fixtures(text: str) -> pd.DataFrame:
39
- """Upcoming fixtures with per-goal-difference and exact-result probabilities."""
40
- return _normalize_columns(_read_csv(text, context="fixtures"))
1
+ from io import StringIO
2
+
3
+ import pandas as pd
4
+
5
+ from datafc.exceptions import DataNotAvailableError
6
+
7
+
8
+ def _read_csv(text: str, *, context: str) -> pd.DataFrame:
9
+ if not text or not text.strip():
10
+ raise DataNotAvailableError(f"Empty response from ClubElo for {context}.")
11
+ df = pd.read_csv(StringIO(text))
12
+ if df.empty:
13
+ raise DataNotAvailableError(f"No rows returned from ClubElo for {context}.")
14
+ return df
15
+
16
+
17
+ def _normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
18
+ df.columns = [str(c).strip().lower() for c in df.columns]
19
+ return df
20
+
21
+
22
+ def parse_daily_ranking(text: str, date: str) -> pd.DataFrame:
23
+ """Columns: rank, club, country, level, elo, from, to."""
24
+ df = _normalize_columns(_read_csv(text, context=f"date={date}"))
25
+ df.insert(0, "query_date", date)
26
+ return df
27
+
28
+
29
+ def parse_club_history(text: str, club: str) -> pd.DataFrame:
30
+ """Columns: rank, club, country, level, elo, from, to (rank may be 'None')."""
31
+ df = _normalize_columns(_read_csv(text, context=f"club={club}"))
32
+ if "rank" in df.columns:
33
+ df["rank"] = pd.to_numeric(df["rank"], errors="coerce")
34
+ df.insert(0, "query_club", club)
35
+ return df
36
+
37
+
38
+ def parse_fixtures(text: str) -> pd.DataFrame:
39
+ """Upcoming fixtures with per-goal-difference and exact-result probabilities."""
40
+ return _normalize_columns(_read_csv(text, context="fixtures"))
@@ -1,34 +1,34 @@
1
- from typing import TYPE_CHECKING, Optional
2
-
3
- import pandas as pd
4
-
5
- from datafc.clubelo._client import ClubEloClient
6
- from datafc.clubelo._parsers import parse_club_history
7
- from datafc.exceptions import InvalidParameterError
8
- from datafc.utils._config import get_clubelo_base_url
9
-
10
- if TYPE_CHECKING:
11
- from datafc.utils._cache import DiskCache
12
-
13
-
14
- def club_history_data(
15
- club: str,
16
- rate_limit: float = 2.0,
17
- cache: Optional["DiskCache"] = None,
18
- ) -> pd.DataFrame:
19
- """Fetches the full Elo rating history for one club.
20
-
21
- Args:
22
- club: Club name exactly as ClubElo spells it (use a daily ranking to
23
- confirm the spelling, e.g. ``"Barcelona"``, ``"ManCity"``).
24
-
25
- Returns:
26
- DataFrame with columns: ``query_club, rank, club, country, level, elo, from, to``.
27
- """
28
- if not isinstance(club, str) or not club.strip():
29
- raise InvalidParameterError("club must be a non-empty string.")
30
- club_clean = club.strip()
31
- url = f"{get_clubelo_base_url()}/{club_clean}"
32
- with ClubEloClient(rate_limit=rate_limit, cache=cache) as client:
33
- text = client.get(url)
34
- return parse_club_history(text, club_clean)
1
+ from typing import TYPE_CHECKING, Optional
2
+
3
+ import pandas as pd
4
+
5
+ from datafc.clubelo._client import ClubEloClient
6
+ from datafc.clubelo._parsers import parse_club_history
7
+ from datafc.exceptions import InvalidParameterError
8
+ from datafc.utils._config import get_clubelo_base_url
9
+
10
+ if TYPE_CHECKING:
11
+ from datafc.utils._cache import DiskCache
12
+
13
+
14
+ def club_history_data(
15
+ club: str,
16
+ rate_limit: float = 2.0,
17
+ cache: Optional["DiskCache"] = None,
18
+ ) -> pd.DataFrame:
19
+ """Fetches the full Elo rating history for one club.
20
+
21
+ Args:
22
+ club: Club name exactly as ClubElo spells it (use a daily ranking to
23
+ confirm the spelling, e.g. ``"Barcelona"``, ``"ManCity"``).
24
+
25
+ Returns:
26
+ DataFrame with columns: ``query_club, rank, club, country, level, elo, from, to``.
27
+ """
28
+ if not isinstance(club, str) or not club.strip():
29
+ raise InvalidParameterError("club must be a non-empty string.")
30
+ club_clean = club.strip()
31
+ url = f"{get_clubelo_base_url()}/{club_clean}"
32
+ with ClubEloClient(rate_limit=rate_limit, cache=cache) as client:
33
+ text = client.get(url)
34
+ return parse_club_history(text, club_clean)