openmatchkit 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from datetime import datetime, timezone
5
+ from hashlib import sha1
6
+ from io import StringIO
7
+
8
+ from openmatchkit.http import SafeHttpClient
9
+ from openmatchkit.models import Match, MatchStatus, Score, Team
10
+
11
+
12
+ class FootballDataUkSource:
13
+ """Adapter for football-data.co.uk CSV result files.
14
+
15
+ The source is free to download, but users should verify the site's current
16
+ terms before redistributing data. The adapter fetches facts from source and
17
+ does not bundle third-party data.
18
+ """
19
+
20
+ name = "football_data_uk"
21
+
22
+ def __init__(
23
+ self,
24
+ http: SafeHttpClient | None = None,
25
+ base_url: str = "https://www.football-data.co.uk/mmz4281",
26
+ ) -> None:
27
+ self.http = http or SafeHttpClient()
28
+ self.base_url = base_url.rstrip("/")
29
+
30
+ def _make_id(self, *parts: object) -> str:
31
+ raw = "|".join(str(part) for part in parts)
32
+ return sha1(raw.encode("utf-8")).hexdigest()[:16]
33
+
34
+ def csv_url(self, competition: str, season: str) -> str:
35
+ return f"{self.base_url}/{season}/{competition}.csv"
36
+
37
+ def _parse_date(self, value: str) -> datetime | None:
38
+ value = value.strip()
39
+ for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
40
+ try:
41
+ return datetime.strptime(value, fmt)
42
+ except ValueError:
43
+ continue
44
+ return None
45
+
46
+ def _parse_int(self, value: object) -> int | None:
47
+ if value is None:
48
+ return None
49
+
50
+ text = str(value).strip()
51
+ if not text:
52
+ return None
53
+
54
+ try:
55
+ return int(float(text))
56
+ except ValueError:
57
+ return None
58
+
59
+ def parse_csv(
60
+ self,
61
+ text: str,
62
+ *,
63
+ source_url: str,
64
+ competition: str,
65
+ season: str,
66
+ fetched_at: datetime | None = None,
67
+ ) -> list[Match]:
68
+ fetched = fetched_at or datetime.now(timezone.utc)
69
+ reader = csv.DictReader(StringIO(text))
70
+ matches: list[Match] = []
71
+
72
+ for row in reader:
73
+ home = (row.get("HomeTeam") or "").strip()
74
+ away = (row.get("AwayTeam") or "").strip()
75
+
76
+ if not home or not away:
77
+ continue
78
+
79
+ home_goals = self._parse_int(row.get("FTHG"))
80
+ away_goals = self._parse_int(row.get("FTAG"))
81
+ has_score = home_goals is not None and away_goals is not None
82
+ status = MatchStatus.FULL_TIME if has_score else MatchStatus.SCHEDULED
83
+ kickoff = self._parse_date(row.get("Date") or "")
84
+
85
+ match_id = self._make_id(
86
+ self.name,
87
+ competition,
88
+ season,
89
+ row.get("Date"),
90
+ home,
91
+ away,
92
+ )
93
+
94
+ matches.append(
95
+ Match(
96
+ match_id=match_id,
97
+ competition=competition,
98
+ season=season,
99
+ kickoff=kickoff,
100
+ home=Team(name=home),
101
+ away=Team(name=away),
102
+ score=Score(home=home_goals, away=away_goals),
103
+ status=status,
104
+ source=self.name,
105
+ source_url=source_url,
106
+ fetched_at=fetched,
107
+ )
108
+ )
109
+
110
+ return matches
111
+
112
+ def fixtures(self, competition: str, season: str | None = None) -> list[Match]:
113
+ if season is None:
114
+ raise ValueError("Football-Data adapter requires a season code, for example '2324'.")
115
+
116
+ url = self.csv_url(competition=competition, season=season)
117
+ text = self.http.get_text(url)
118
+ return self.parse_csv(text, source_url=url, competition=competition, season=season)
119
+
120
+ def live_scores(self) -> list[Match]:
121
+ return []
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from openmatchkit.models import (
8
+ Match,
9
+ MatchStatus,
10
+ Player,
11
+ PlayerHistory,
12
+ PlayerMatchAppearance,
13
+ PlayerTotals,
14
+ Scoreboard,
15
+ )
16
+
17
+
18
+ class JsonFileSource:
19
+ """Source adapter for local detailed JSON files.
20
+
21
+ This is useful for authorized/public detailed feeds, saved fixtures, and demos.
22
+ It never performs network requests.
23
+ """
24
+
25
+ name = "json_file"
26
+
27
+ def __init__(self, path: str | Path) -> None:
28
+ self.path = Path(path)
29
+ self.source_url = self.path.resolve().as_uri()
30
+ self._data: dict[str, Any] | None = None
31
+
32
+ def _load(self) -> dict[str, Any]:
33
+ if self._data is None:
34
+ self._data = json.loads(self.path.read_text(encoding="utf-8"))
35
+ return self._data
36
+
37
+ def _scoreboards(self) -> list[Scoreboard]:
38
+ data = self._load()
39
+ return [Scoreboard.model_validate(item) for item in data.get("scoreboards", [])]
40
+
41
+ def _matches(self) -> list[Match]:
42
+ data = self._load()
43
+ matches: dict[str, Match] = {}
44
+
45
+ for item in data.get("matches", []):
46
+ match = Match.model_validate(item)
47
+ matches[match.match_id] = match
48
+
49
+ for board in self._scoreboards():
50
+ matches[board.match.match_id] = board.match
51
+
52
+ return list(matches.values())
53
+
54
+ def _match_filter(
55
+ self,
56
+ match: Match,
57
+ competition: str | None = None,
58
+ season: str | None = None,
59
+ ) -> bool:
60
+ if competition is not None and match.competition != competition:
61
+ return False
62
+ if season is not None and match.season != season:
63
+ return False
64
+ return True
65
+
66
+ def fixtures(self, competition: str, season: str | None = None) -> list[Match]:
67
+ return [
68
+ match
69
+ for match in self._matches()
70
+ if self._match_filter(match, competition=competition, season=season)
71
+ ]
72
+
73
+ def live_scores(self) -> list[Match]:
74
+ return [board.match for board in self.live_scoreboards()]
75
+
76
+ def scoreboards(
77
+ self,
78
+ competition: str | None = None,
79
+ season: str | None = None,
80
+ ) -> list[Scoreboard]:
81
+ return [
82
+ board
83
+ for board in self._scoreboards()
84
+ if self._match_filter(board.match, competition=competition, season=season)
85
+ ]
86
+
87
+ def scoreboard(
88
+ self,
89
+ match_id: str,
90
+ competition: str | None = None,
91
+ season: str | None = None,
92
+ ) -> Scoreboard | None:
93
+ for board in self.scoreboards(competition=competition, season=season):
94
+ if board.match.match_id == match_id:
95
+ return board
96
+ return None
97
+
98
+ def live_scoreboards(self) -> list[Scoreboard]:
99
+ return [
100
+ board
101
+ for board in self._scoreboards()
102
+ if board.match.status in {MatchStatus.LIVE, MatchStatus.HALF_TIME}
103
+ ]
104
+
105
+ def _appearance_from_stats(
106
+ self,
107
+ board: Scoreboard,
108
+ player_name: str,
109
+ ) -> PlayerMatchAppearance | None:
110
+ needle = player_name.casefold()
111
+
112
+ for stats in board.player_stats:
113
+ if stats.player.name.casefold() != needle:
114
+ continue
115
+
116
+ team = stats.player.team or ""
117
+ opponent = None
118
+ home_away = "unknown"
119
+
120
+ if team == board.match.home.name:
121
+ opponent = board.match.away.name
122
+ home_away = "home"
123
+ elif team == board.match.away.name:
124
+ opponent = board.match.home.name
125
+ home_away = "away"
126
+ elif team:
127
+ home_away = "neutral"
128
+
129
+ return PlayerMatchAppearance(
130
+ match_id=board.match.match_id,
131
+ competition=board.match.competition,
132
+ season=board.match.season,
133
+ kickoff=board.match.kickoff,
134
+ team=team or "unknown",
135
+ opponent=opponent,
136
+ home_away=home_away,
137
+ status=board.match.status,
138
+ minutes_played=stats.minutes_played,
139
+ started=stats.started,
140
+ goals=stats.goals,
141
+ assists=stats.assists,
142
+ yellow_cards=stats.yellow_cards,
143
+ red_cards=stats.red_cards,
144
+ source=stats.source or board.match.source,
145
+ source_url=board.match.source_url,
146
+ )
147
+
148
+ return None
149
+
150
+ def player_history(
151
+ self,
152
+ player: str,
153
+ competition: str | None = None,
154
+ season: str | None = None,
155
+ ) -> PlayerHistory:
156
+ appearances: list[PlayerMatchAppearance] = []
157
+ player_model = Player(name=player)
158
+
159
+ for board in self.scoreboards(competition=competition, season=season):
160
+ appearance = self._appearance_from_stats(board, player)
161
+ if appearance is None:
162
+ continue
163
+
164
+ appearances.append(appearance)
165
+ for stats in board.player_stats:
166
+ if stats.player.name.casefold() == player.casefold():
167
+ player_model = stats.player
168
+ break
169
+
170
+ totals = PlayerTotals(
171
+ appearances=len(appearances),
172
+ starts=sum(1 for item in appearances if item.started is True),
173
+ minutes_played=sum(item.minutes_played or 0 for item in appearances),
174
+ goals=sum(item.goals for item in appearances),
175
+ assists=sum(item.assists for item in appearances),
176
+ yellow_cards=sum(item.yellow_cards for item in appearances),
177
+ red_cards=sum(item.red_cards for item in appearances),
178
+ )
179
+
180
+ notes = []
181
+ if not appearances:
182
+ notes.append("No player-level appearances found in this JSON source.")
183
+
184
+ return PlayerHistory(
185
+ player=player_model,
186
+ totals=totals,
187
+ appearances=appearances,
188
+ source_notes=notes,
189
+ )
@@ -0,0 +1,235 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import datetime, timedelta, timezone
5
+ from hashlib import sha1
6
+ from typing import Any
7
+
8
+ from openmatchkit.exceptions import ParseError
9
+ from openmatchkit.http import SafeHttpClient
10
+ from openmatchkit.models import Match, MatchStatus, Player, PlayerHistory, Score, Scoreboard, Team
11
+
12
+
13
+ class OpenFootballSource:
14
+ """Adapter for OpenFootball JSON datasets."""
15
+
16
+ name = "openfootball"
17
+
18
+ WORLD_CUP_ALIASES = {
19
+ "fifa-world-cup",
20
+ "fifa_world_cup",
21
+ "world-cup",
22
+ "world_cup",
23
+ "worldcup",
24
+ }
25
+
26
+ def __init__(
27
+ self,
28
+ http: SafeHttpClient | None = None,
29
+ league_base_url: str = "https://raw.githubusercontent.com/openfootball/football.json/master",
30
+ worldcup_base_url: str = "https://raw.githubusercontent.com/openfootball/worldcup.json/master",
31
+ ) -> None:
32
+ self.http = http or SafeHttpClient()
33
+ self.league_base_url = league_base_url.rstrip("/")
34
+ self.worldcup_base_url = worldcup_base_url.rstrip("/")
35
+
36
+ def _make_id(self, *parts: object) -> str:
37
+ raw = "|".join(str(part) for part in parts)
38
+ return sha1(raw.encode("utf-8")).hexdigest()[:16]
39
+
40
+ def dataset_url(self, competition: str, season: str) -> str:
41
+ normalized = competition.strip().lower()
42
+
43
+ if normalized in self.WORLD_CUP_ALIASES:
44
+ return f"{self.worldcup_base_url}/{season}/worldcup.json"
45
+
46
+ return f"{self.league_base_url}/{season}/{competition}.json"
47
+
48
+ def league_json(self, season: str, league_code: str) -> dict[str, Any]:
49
+ url = self.dataset_url(competition=league_code, season=season)
50
+ data = self.http.get_json(url)
51
+
52
+ if not isinstance(data, dict):
53
+ raise ParseError(f"Expected JSON object from {url}")
54
+
55
+ return data
56
+
57
+ def _parse_kickoff(
58
+ self, date_value: object, time_value: object | None = None
59
+ ) -> datetime | None:
60
+ if not date_value:
61
+ return None
62
+
63
+ date_text = str(date_value).strip()
64
+ time_text = str(time_value).strip() if time_value else ""
65
+
66
+ if not time_text:
67
+ try:
68
+ return datetime.fromisoformat(date_text)
69
+ except ValueError:
70
+ return None
71
+
72
+ tzinfo = None
73
+ time_part = time_text
74
+ match = re.search(r"UTC\s*([+-]\d{1,2})(?::?(\d{2}))?", time_text)
75
+ if match:
76
+ hours = int(match.group(1))
77
+ minutes = int(match.group(2) or "0")
78
+ sign = 1 if hours >= 0 else -1
79
+ tzinfo = timezone(timedelta(hours=hours, minutes=sign * minutes))
80
+ time_part = time_text.split("UTC", 1)[0].strip()
81
+
82
+ try:
83
+ parsed = datetime.fromisoformat(f"{date_text}T{time_part}")
84
+ except ValueError:
85
+ return None
86
+
87
+ if tzinfo is not None:
88
+ parsed = parsed.replace(tzinfo=tzinfo)
89
+
90
+ return parsed
91
+
92
+ def _score_from_item(self, item: dict[str, Any]) -> tuple[Score, MatchStatus]:
93
+ score_data = item.get("score") or {}
94
+ ft_score = score_data.get("ft") if isinstance(score_data, dict) else None
95
+
96
+ if not isinstance(ft_score, list) or len(ft_score) < 2:
97
+ return Score(), MatchStatus.SCHEDULED
98
+
99
+ home, away = ft_score[0], ft_score[1]
100
+ if home is None or away is None:
101
+ return Score(), MatchStatus.SCHEDULED
102
+
103
+ try:
104
+ return Score(home=int(home), away=int(away)), MatchStatus.FULL_TIME
105
+ except (TypeError, ValueError):
106
+ return Score(), MatchStatus.UNKNOWN
107
+
108
+ def parse_matches(
109
+ self,
110
+ data: dict[str, Any],
111
+ *,
112
+ source_url: str,
113
+ competition: str,
114
+ season: str,
115
+ fetched_at: datetime | None = None,
116
+ ) -> list[Match]:
117
+ fetched = fetched_at or datetime.now(timezone.utc)
118
+ raw_matches = data.get("matches", [])
119
+
120
+ if not isinstance(raw_matches, list):
121
+ raise ParseError("OpenFootball JSON did not contain a list at 'matches'.")
122
+
123
+ competition_name = str(data.get("name") or competition)
124
+ matches: list[Match] = []
125
+
126
+ for item in raw_matches:
127
+ if not isinstance(item, dict):
128
+ continue
129
+
130
+ home_name = item.get("team1")
131
+ away_name = item.get("team2")
132
+ if not home_name or not away_name:
133
+ continue
134
+
135
+ score, status = self._score_from_item(item)
136
+ kickoff = self._parse_kickoff(item.get("date"), item.get("time"))
137
+
138
+ match_id = self._make_id(
139
+ self.name,
140
+ competition_name,
141
+ season,
142
+ item.get("round"),
143
+ item.get("group"),
144
+ item.get("date"),
145
+ item.get("time"),
146
+ home_name,
147
+ away_name,
148
+ )
149
+
150
+ matches.append(
151
+ Match(
152
+ match_id=match_id,
153
+ competition=competition_name,
154
+ season=season,
155
+ round=item.get("round") or item.get("stage"),
156
+ group=item.get("group"),
157
+ kickoff=kickoff,
158
+ home=Team(name=str(home_name)),
159
+ away=Team(name=str(away_name)),
160
+ score=score,
161
+ status=status,
162
+ venue=item.get("ground") or item.get("venue"),
163
+ source=self.name,
164
+ source_url=source_url,
165
+ fetched_at=fetched,
166
+ )
167
+ )
168
+
169
+ return matches
170
+
171
+ def fixtures(self, competition: str, season: str | None = None) -> list[Match]:
172
+ if season is None:
173
+ raise ValueError("OpenFootball fixtures require a season, for example '2026'.")
174
+
175
+ url = self.dataset_url(competition=competition, season=season)
176
+ data = self.league_json(season=season, league_code=competition)
177
+ return self.parse_matches(data, source_url=url, competition=competition, season=season)
178
+
179
+ def live_scores(self) -> list[Match]:
180
+ return []
181
+
182
+ def scoreboard(
183
+ self,
184
+ match_id: str,
185
+ competition: str | None = None,
186
+ season: str | None = None,
187
+ ) -> Scoreboard | None:
188
+ if competition is None or season is None:
189
+ raise ValueError("OpenFootball scoreboard lookup requires competition and season.")
190
+
191
+ for match in self.fixtures(competition=competition, season=season):
192
+ if match.match_id == match_id:
193
+ return Scoreboard(
194
+ match=match,
195
+ source_notes=[
196
+ "OpenFootball provides fixtures/results only for this match.",
197
+ "Player stats, lineups, detailed events, and live clock are not available from this source.",
198
+ ],
199
+ )
200
+ return None
201
+
202
+ def scoreboards(
203
+ self,
204
+ competition: str | None = None,
205
+ season: str | None = None,
206
+ ) -> list[Scoreboard]:
207
+ if competition is None or season is None:
208
+ raise ValueError("OpenFootball scoreboards require competition and season.")
209
+
210
+ return [
211
+ Scoreboard(
212
+ match=match,
213
+ source_notes=[
214
+ "OpenFootball provides fixtures/results only for this match.",
215
+ "Player stats, lineups, detailed events, and live clock are not available from this source.",
216
+ ],
217
+ )
218
+ for match in self.fixtures(competition=competition, season=season)
219
+ ]
220
+
221
+ def live_scoreboards(self) -> list[Scoreboard]:
222
+ return []
223
+
224
+ def player_history(
225
+ self,
226
+ player: str,
227
+ competition: str | None = None,
228
+ season: str | None = None,
229
+ ) -> PlayerHistory:
230
+ return PlayerHistory(
231
+ player=Player(name=player),
232
+ source_notes=[
233
+ "OpenFootball does not provide player-level match histories in this adapter."
234
+ ],
235
+ )
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from datetime import datetime, timezone
5
+
6
+ from bs4 import BeautifulSoup
7
+
8
+ from openmatchkit.http import SafeHttpClient
9
+ from openmatchkit.models import Match
10
+
11
+ HtmlParser = Callable[[BeautifulSoup, str, datetime], list[Match]]
12
+
13
+
14
+ class PublicHtmlSource:
15
+ """Generic public HTML adapter.
16
+
17
+ It is intentionally parser-driven. Site-specific parsers should live in
18
+ optional modules and include source-specific policy notes and fixture tests.
19
+ """
20
+
21
+ name = "public_html"
22
+
23
+ def __init__(
24
+ self,
25
+ url: str,
26
+ parser: HtmlParser,
27
+ http: SafeHttpClient | None = None,
28
+ min_delay_seconds: float = 60.0,
29
+ ) -> None:
30
+ self.url = url
31
+ self.parser = parser
32
+ self.http = http or SafeHttpClient(min_delay_seconds=min_delay_seconds)
33
+
34
+ def live_scores(self) -> list[Match]:
35
+ html = self.http.get_text(self.url)
36
+ soup = BeautifulSoup(html, "lxml")
37
+ fetched_at = datetime.now(timezone.utc)
38
+ return self.parser(soup, self.url, fetched_at)
39
+
40
+ def fixtures(self, competition: str, season: str | None = None) -> list[Match]:
41
+ return self.live_scores()