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,41 @@
1
+ from openmatchkit.client import MatchClient
2
+ from openmatchkit.models import (
3
+ Match,
4
+ MatchClock,
5
+ MatchEvent,
6
+ MatchStatus,
7
+ Player,
8
+ PlayerHistory,
9
+ PlayerMatchAppearance,
10
+ PlayerMatchStats,
11
+ PlayerTotals,
12
+ Prediction,
13
+ Score,
14
+ Scoreboard,
15
+ StandingRow,
16
+ Team,
17
+ TeamInfo,
18
+ TeamLineup,
19
+ TeamMatchStats,
20
+ )
21
+
22
+ __all__ = [
23
+ "MatchClient",
24
+ "Match",
25
+ "MatchClock",
26
+ "MatchEvent",
27
+ "MatchStatus",
28
+ "Player",
29
+ "PlayerHistory",
30
+ "PlayerMatchAppearance",
31
+ "PlayerMatchStats",
32
+ "PlayerTotals",
33
+ "Prediction",
34
+ "Score",
35
+ "Scoreboard",
36
+ "StandingRow",
37
+ "Team",
38
+ "TeamInfo",
39
+ "TeamLineup",
40
+ "TeamMatchStats",
41
+ ]
openmatchkit/cli.py ADDED
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from openmatchkit.client import MatchClient
10
+ from openmatchkit.export import (
11
+ to_csv,
12
+ to_csv_string,
13
+ to_json,
14
+ to_json_string,
15
+ to_model_json_string,
16
+ )
17
+ from openmatchkit.models import Match
18
+ from openmatchkit.sources.json_file import JsonFileSource
19
+
20
+ app = typer.Typer(help="Unofficial football public-data CLI.")
21
+
22
+
23
+ def _client(data_file: Path | None = None) -> MatchClient:
24
+ if data_file is not None:
25
+ return MatchClient(sources=[JsonFileSource(data_file)])
26
+ return MatchClient()
27
+
28
+
29
+ def _emit_matches(matches: list[Match], output_format: str, output: Path | None) -> None:
30
+ if output_format == "csv":
31
+ if output:
32
+ to_csv(matches, output)
33
+ typer.echo(str(output))
34
+ else:
35
+ typer.echo(to_csv_string(matches))
36
+ return
37
+
38
+ if output:
39
+ to_json(matches, output)
40
+ typer.echo(str(output))
41
+ else:
42
+ typer.echo(to_json_string(matches))
43
+
44
+
45
+ @app.command()
46
+ def fixtures(
47
+ competition: str = typer.Option(..., help="Example: worldcup or en.1"),
48
+ season: str = typer.Option(..., help="Example: 2026 or 2015-16"),
49
+ output_format: str = typer.Option("json", "--format", help="json or csv"),
50
+ output: Path | None = typer.Option(None, "--output", "-o", help="Write to a file."),
51
+ ) -> None:
52
+ """Fetch fixtures and results from configured public sources."""
53
+
54
+ client = MatchClient()
55
+ matches = client.fixtures(competition=competition, season=season)
56
+ _emit_matches(matches, output_format.lower(), output)
57
+
58
+
59
+ @app.command()
60
+ def results(
61
+ competition: str = typer.Option(..., help="Example: worldcup or en.1"),
62
+ season: str = typer.Option(..., help="Example: 2026 or 2015-16"),
63
+ output_format: str = typer.Option("json", "--format", help="json or csv"),
64
+ output: Path | None = typer.Option(None, "--output", "-o", help="Write to a file."),
65
+ ) -> None:
66
+ """Fetch completed matches from configured public sources."""
67
+
68
+ client = MatchClient()
69
+ matches = client.results(competition=competition, season=season)
70
+ _emit_matches(matches, output_format.lower(), output)
71
+
72
+
73
+ @app.command()
74
+ def live() -> None:
75
+ """Show best-effort live scores from configured live adapters."""
76
+
77
+ client = MatchClient()
78
+ matches = client.live_scores()
79
+ typer.echo(to_json_string(matches))
80
+
81
+
82
+ @app.command()
83
+ def live_scoreboards(
84
+ data_file: Path | None = typer.Option(None, "--data-file", help="Optional detailed JSON file."),
85
+ ) -> None:
86
+ """Show live scoreboards with any detailed fields provided by the source."""
87
+
88
+ client = _client(data_file)
89
+ boards = client.live_scoreboards()
90
+ typer.echo(to_model_json_string(boards))
91
+
92
+
93
+ @app.command()
94
+ def next_match(
95
+ competition: str = typer.Option(..., help="Example: worldcup or en.1"),
96
+ season: str = typer.Option(..., help="Example: 2026 or 2015-16"),
97
+ ) -> None:
98
+ """Show the next scheduled match from fixture data."""
99
+
100
+ client = MatchClient()
101
+ match = client.next_match(
102
+ competition=competition,
103
+ season=season,
104
+ after=datetime.now(timezone.utc),
105
+ )
106
+ typer.echo(to_model_json_string(match.model_dump(mode="json") if match else {}))
107
+
108
+
109
+ @app.command()
110
+ def scoreboards(
111
+ competition: str | None = typer.Option(None, help="Optional competition filter."),
112
+ season: str | None = typer.Option(None, help="Optional season filter."),
113
+ data_file: Path | None = typer.Option(None, "--data-file", help="Optional detailed JSON file."),
114
+ ) -> None:
115
+ """Return all available scoreboard details as JSON."""
116
+
117
+ client = _client(data_file)
118
+ boards = client.scoreboards(competition=competition, season=season)
119
+ typer.echo(to_model_json_string(boards))
120
+
121
+
122
+ @app.command()
123
+ def scoreboard(
124
+ match_id: str = typer.Option(..., help="Match ID from fixtures or scoreboards output."),
125
+ competition: str | None = typer.Option(None, help="Optional competition filter."),
126
+ season: str | None = typer.Option(None, help="Optional season filter."),
127
+ data_file: Path | None = typer.Option(None, "--data-file", help="Optional detailed JSON file."),
128
+ ) -> None:
129
+ """Return one scoreboard with match, events, lineups, stats, and player details."""
130
+
131
+ client = _client(data_file)
132
+ board = client.scoreboard(match_id=match_id, competition=competition, season=season)
133
+ if board is None:
134
+ typer.echo("{}")
135
+ raise typer.Exit(code=1)
136
+ typer.echo(to_model_json_string(board))
137
+
138
+
139
+ @app.command()
140
+ def player_history(
141
+ player: str = typer.Option(..., help="Player name."),
142
+ competition: str | None = typer.Option(None, help="Optional competition filter."),
143
+ season: str | None = typer.Option(None, help="Optional season filter."),
144
+ data_file: Path | None = typer.Option(None, "--data-file", help="Optional detailed JSON file."),
145
+ ) -> None:
146
+ """Return public player match history details provided by configured sources."""
147
+
148
+ client = _client(data_file)
149
+ history = client.player_history(player=player, competition=competition, season=season)
150
+ typer.echo(to_model_json_string(history))
151
+
152
+
153
+ @app.command()
154
+ def standings(
155
+ competition: str = typer.Option(..., help="Example: worldcup or en.1"),
156
+ season: str = typer.Option(..., help="Example: 2026 or 2015-16"),
157
+ group: str | None = typer.Option(None, help="Optional group name."),
158
+ ) -> None:
159
+ """Build a simple points table from full-time results."""
160
+
161
+ client = MatchClient()
162
+ rows = client.standings(competition=competition, season=season, group=group)
163
+ payload = [row.model_dump(mode="json") for row in rows]
164
+ typer.echo(json.dumps(payload, indent=2))
165
+
166
+
167
+ @app.command()
168
+ def predict(
169
+ home: str = typer.Option(...),
170
+ away: str = typer.Option(...),
171
+ competition: str | None = typer.Option(None, help="Optional history competition."),
172
+ season: str | None = typer.Option(None, help="Optional history season."),
173
+ ) -> None:
174
+ """Run the educational Poisson baseline."""
175
+
176
+ client = MatchClient()
177
+ prediction = client.predict(home=home, away=away, competition=competition, season=season)
178
+ typer.echo(json.dumps(prediction.model_dump(mode="json"), indent=2))
179
+
180
+
181
+ if __name__ == "__main__":
182
+ app()
openmatchkit/client.py ADDED
@@ -0,0 +1,405 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+
6
+ from openmatchkit.export import to_csv, to_json
7
+ from openmatchkit.models import (
8
+ Match,
9
+ MatchStatus,
10
+ Player,
11
+ PlayerHistory,
12
+ PlayerTotals,
13
+ Prediction,
14
+ Scoreboard,
15
+ StandingRow,
16
+ TeamInfo,
17
+ )
18
+ from openmatchkit.prediction.poisson import SimplePoissonPredictor
19
+ from openmatchkit.sources.openfootball import OpenFootballSource
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class SourceError:
24
+ source: str
25
+ operation: str
26
+ message: str
27
+
28
+
29
+ class MatchClient:
30
+ """Main public client for fixtures, results, standings, prediction, and export."""
31
+
32
+ def __init__(self, sources: list[object] | None = None, *, strict: bool = False) -> None:
33
+ self.sources = sources if sources is not None else [OpenFootballSource()]
34
+ self.strict = strict
35
+ self.last_errors: list[SourceError] = []
36
+
37
+ def _matching_sources(self, source: str | None = None) -> list[object]:
38
+ if source is None:
39
+ return list(self.sources)
40
+ return [
41
+ candidate for candidate in self.sources if getattr(candidate, "name", None) == source
42
+ ]
43
+
44
+ def _record_error(self, source: object, operation: str, exc: Exception) -> None:
45
+ error = SourceError(
46
+ source=str(getattr(source, "name", source.__class__.__name__)),
47
+ operation=operation,
48
+ message=str(exc),
49
+ )
50
+ self.last_errors.append(error)
51
+
52
+ if self.strict:
53
+ raise exc
54
+
55
+ def _as_utc(self, value: datetime | None, *, default: datetime) -> datetime:
56
+ if value is None:
57
+ return default
58
+ if value.tzinfo is None:
59
+ return value.replace(tzinfo=timezone.utc)
60
+ return value.astimezone(timezone.utc)
61
+
62
+ def fixtures(
63
+ self,
64
+ competition: str,
65
+ season: str | None = None,
66
+ *,
67
+ source: str | None = None,
68
+ ) -> list[Match]:
69
+ self.last_errors = []
70
+ results: list[Match] = []
71
+
72
+ for match_source in self._matching_sources(source):
73
+ fixtures = getattr(match_source, "fixtures", None)
74
+ if fixtures is None:
75
+ continue
76
+
77
+ try:
78
+ results.extend(fixtures(competition=competition, season=season))
79
+ except Exception as exc: # pragma: no cover - strict mode re-raises this path
80
+ self._record_error(match_source, "fixtures", exc)
81
+
82
+ return sorted(results, key=lambda match: match.kickoff or match.match_id)
83
+
84
+ def results(
85
+ self,
86
+ competition: str,
87
+ season: str | None = None,
88
+ *,
89
+ source: str | None = None,
90
+ ) -> list[Match]:
91
+ return [
92
+ match
93
+ for match in self.fixtures(competition=competition, season=season, source=source)
94
+ if match.status == MatchStatus.FULL_TIME
95
+ or (match.score.home is not None and match.score.away is not None)
96
+ ]
97
+
98
+ def live_scores(self, *, source: str | None = None) -> list[Match]:
99
+ self.last_errors = []
100
+ results: list[Match] = []
101
+
102
+ for match_source in self._matching_sources(source):
103
+ live_scores = getattr(match_source, "live_scores", None)
104
+ if live_scores is None:
105
+ continue
106
+
107
+ try:
108
+ results.extend(live_scores())
109
+ except Exception as exc: # pragma: no cover - strict mode re-raises this path
110
+ self._record_error(match_source, "live_scores", exc)
111
+
112
+ return results
113
+
114
+ def next_match(
115
+ self,
116
+ competition: str,
117
+ season: str | None = None,
118
+ *,
119
+ source: str | None = None,
120
+ after: datetime | None = None,
121
+ ) -> Match | None:
122
+ after_time = after or datetime.now(timezone.utc)
123
+ after_utc = self._as_utc(after_time, default=datetime.min.replace(tzinfo=timezone.utc))
124
+ upcoming = [
125
+ match
126
+ for match in self.fixtures(competition=competition, season=season, source=source)
127
+ if match.kickoff
128
+ and self._as_utc(
129
+ match.kickoff,
130
+ default=datetime.min.replace(tzinfo=timezone.utc),
131
+ )
132
+ >= after_utc
133
+ ]
134
+ upcoming.sort(
135
+ key=lambda match: self._as_utc(
136
+ match.kickoff,
137
+ default=datetime.max.replace(tzinfo=timezone.utc),
138
+ )
139
+ )
140
+ return upcoming[0] if upcoming else None
141
+
142
+ def scoreboards(
143
+ self,
144
+ competition: str | None = None,
145
+ season: str | None = None,
146
+ *,
147
+ source: str | None = None,
148
+ ) -> list[Scoreboard]:
149
+ self.last_errors = []
150
+ results: list[Scoreboard] = []
151
+
152
+ for match_source in self._matching_sources(source):
153
+ scoreboards = getattr(match_source, "scoreboards", None)
154
+ if scoreboards is not None:
155
+ try:
156
+ results.extend(scoreboards(competition=competition, season=season))
157
+ continue
158
+ except Exception as exc: # pragma: no cover - strict mode re-raises this path
159
+ self._record_error(match_source, "scoreboards", exc)
160
+ continue
161
+
162
+ if competition is None:
163
+ continue
164
+
165
+ fixtures = getattr(match_source, "fixtures", None)
166
+ if fixtures is None:
167
+ continue
168
+
169
+ try:
170
+ for match in fixtures(competition=competition, season=season):
171
+ results.append(
172
+ Scoreboard(
173
+ match=match,
174
+ source_notes=[
175
+ "This source returned match-level data only.",
176
+ "Detailed events, lineups, team stats, and player stats are unavailable.",
177
+ ],
178
+ )
179
+ )
180
+ except Exception as exc: # pragma: no cover - strict mode re-raises this path
181
+ self._record_error(match_source, "scoreboards", exc)
182
+
183
+ return sorted(results, key=lambda board: board.match.kickoff or board.match.match_id)
184
+
185
+ def scoreboard(
186
+ self,
187
+ match_id: str,
188
+ competition: str | None = None,
189
+ season: str | None = None,
190
+ *,
191
+ source: str | None = None,
192
+ ) -> Scoreboard | None:
193
+ self.last_errors = []
194
+
195
+ for match_source in self._matching_sources(source):
196
+ scoreboard = getattr(match_source, "scoreboard", None)
197
+ if scoreboard is None:
198
+ continue
199
+
200
+ try:
201
+ board = scoreboard(match_id=match_id, competition=competition, season=season)
202
+ except Exception as exc: # pragma: no cover - strict mode re-raises this path
203
+ self._record_error(match_source, "scoreboard", exc)
204
+ continue
205
+
206
+ if board is not None:
207
+ return board
208
+
209
+ for board in self.scoreboards(competition=competition, season=season, source=source):
210
+ if board.match.match_id == match_id:
211
+ return board
212
+
213
+ return None
214
+
215
+ def live_scoreboards(self, *, source: str | None = None) -> list[Scoreboard]:
216
+ self.last_errors = []
217
+ results: list[Scoreboard] = []
218
+
219
+ for match_source in self._matching_sources(source):
220
+ live_scoreboards = getattr(match_source, "live_scoreboards", None)
221
+ if live_scoreboards is not None:
222
+ try:
223
+ results.extend(live_scoreboards())
224
+ continue
225
+ except Exception as exc: # pragma: no cover - strict mode re-raises this path
226
+ self._record_error(match_source, "live_scoreboards", exc)
227
+ continue
228
+
229
+ live_scores = getattr(match_source, "live_scores", None)
230
+ if live_scores is None:
231
+ continue
232
+
233
+ try:
234
+ for match in live_scores():
235
+ results.append(
236
+ Scoreboard(
237
+ match=match,
238
+ source_notes=[
239
+ "This live source returned match-level score data only.",
240
+ "Detailed events, lineups, team stats, and player stats are unavailable.",
241
+ ],
242
+ )
243
+ )
244
+ except Exception as exc: # pragma: no cover - strict mode re-raises this path
245
+ self._record_error(match_source, "live_scoreboards", exc)
246
+
247
+ return results
248
+
249
+ def player_history(
250
+ self,
251
+ player: str,
252
+ competition: str | None = None,
253
+ season: str | None = None,
254
+ *,
255
+ source: str | None = None,
256
+ ) -> PlayerHistory:
257
+ self.last_errors = []
258
+ merged = PlayerHistory(
259
+ player=Player(name=player),
260
+ source_notes=[
261
+ "Player history is available only from adapters that provide player-level public data."
262
+ ],
263
+ )
264
+
265
+ for match_source in self._matching_sources(source):
266
+ source_player_history = getattr(match_source, "player_history", None)
267
+ if source_player_history is None:
268
+ continue
269
+
270
+ try:
271
+ history = source_player_history(
272
+ player=player,
273
+ competition=competition,
274
+ season=season,
275
+ )
276
+ except Exception as exc: # pragma: no cover - strict mode re-raises this path
277
+ self._record_error(match_source, "player_history", exc)
278
+ continue
279
+
280
+ if history.appearances:
281
+ if not merged.appearances:
282
+ merged.player = history.player
283
+ merged.source_notes = []
284
+ merged.appearances.extend(history.appearances)
285
+ merged.source_notes.extend(history.source_notes)
286
+
287
+ merged.appearances.sort(
288
+ key=lambda item: self._as_utc(
289
+ item.kickoff,
290
+ default=datetime.min.replace(tzinfo=timezone.utc),
291
+ )
292
+ )
293
+ merged.totals = PlayerTotals(
294
+ appearances=len(merged.appearances),
295
+ starts=sum(1 for item in merged.appearances if item.started is True),
296
+ minutes_played=sum(item.minutes_played or 0 for item in merged.appearances),
297
+ goals=sum(item.goals for item in merged.appearances),
298
+ assists=sum(item.assists for item in merged.appearances),
299
+ yellow_cards=sum(item.yellow_cards for item in merged.appearances),
300
+ red_cards=sum(item.red_cards for item in merged.appearances),
301
+ )
302
+
303
+ return merged
304
+
305
+ def teams(
306
+ self,
307
+ competition: str,
308
+ season: str | None = None,
309
+ *,
310
+ source: str | None = None,
311
+ ) -> list[TeamInfo]:
312
+ teams: dict[str, TeamInfo] = {}
313
+
314
+ for match in self.fixtures(competition=competition, season=season, source=source):
315
+ for team in (match.home, match.away):
316
+ info = teams.setdefault(
317
+ team.name,
318
+ TeamInfo(name=team.name, code=team.code, country=team.country),
319
+ )
320
+ info.appearances += 1
321
+ if match.source not in info.sources:
322
+ info.sources.append(match.source)
323
+
324
+ return sorted(teams.values(), key=lambda team: team.name.lower())
325
+
326
+ def standings(
327
+ self,
328
+ competition: str,
329
+ season: str | None = None,
330
+ *,
331
+ group: str | None = None,
332
+ source: str | None = None,
333
+ ) -> list[StandingRow]:
334
+ table: dict[tuple[str | None, str], StandingRow] = {}
335
+
336
+ def row_for(team: str, group_name: str | None) -> StandingRow:
337
+ key = (group_name, team)
338
+ if key not in table:
339
+ table[key] = StandingRow(team=team, group=group_name)
340
+ return table[key]
341
+
342
+ for match in self.results(competition=competition, season=season, source=source):
343
+ if group is not None and match.group != group:
344
+ continue
345
+ if match.score.home is None or match.score.away is None:
346
+ continue
347
+
348
+ home = row_for(match.home.name, match.group)
349
+ away = row_for(match.away.name, match.group)
350
+
351
+ home.played += 1
352
+ away.played += 1
353
+ home.goals_for += match.score.home
354
+ home.goals_against += match.score.away
355
+ away.goals_for += match.score.away
356
+ away.goals_against += match.score.home
357
+
358
+ if match.score.home > match.score.away:
359
+ home.won += 1
360
+ away.lost += 1
361
+ home.points += 3
362
+ elif match.score.home < match.score.away:
363
+ away.won += 1
364
+ home.lost += 1
365
+ away.points += 3
366
+ else:
367
+ home.drawn += 1
368
+ away.drawn += 1
369
+ home.points += 1
370
+ away.points += 1
371
+
372
+ for row in table.values():
373
+ row.goal_difference = row.goals_for - row.goals_against
374
+
375
+ return sorted(
376
+ table.values(),
377
+ key=lambda row: (-row.points, -row.goal_difference, -row.goals_for, row.team.lower()),
378
+ )
379
+
380
+ def predict(
381
+ self,
382
+ home: str,
383
+ away: str,
384
+ history: list[Match] | None = None,
385
+ *,
386
+ competition: str | None = None,
387
+ season: str | None = None,
388
+ source: str | None = None,
389
+ ) -> Prediction:
390
+ if history is None:
391
+ history = (
392
+ self.results(competition=competition, season=season, source=source)
393
+ if competition
394
+ else []
395
+ )
396
+
397
+ model = SimplePoissonPredictor()
398
+ model.fit(history)
399
+ return model.predict(home=home, away=away)
400
+
401
+ def export_json(self, matches: list[Match], path: str) -> None:
402
+ to_json(matches, path)
403
+
404
+ def export_csv(self, matches: list[Match], path: str) -> None:
405
+ to_csv(matches, path)
@@ -0,0 +1,14 @@
1
+ class OpenMatchKitError(Exception):
2
+ """Base exception for openmatchkit."""
3
+
4
+
5
+ class SourceNotAllowedError(OpenMatchKitError):
6
+ """Raised when a source is blocked by robots.txt or source policy."""
7
+
8
+
9
+ class SourceFetchError(OpenMatchKitError):
10
+ """Raised when a source cannot be fetched."""
11
+
12
+
13
+ class ParseError(OpenMatchKitError):
14
+ """Raised when a source response cannot be parsed."""