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.
- openmatchkit/__init__.py +41 -0
- openmatchkit/cli.py +182 -0
- openmatchkit/client.py +405 -0
- openmatchkit/exceptions.py +14 -0
- openmatchkit/export.py +94 -0
- openmatchkit/http.py +181 -0
- openmatchkit/models.py +198 -0
- openmatchkit/prediction/__init__.py +7 -0
- openmatchkit/prediction/elo.py +43 -0
- openmatchkit/prediction/poisson.py +99 -0
- openmatchkit/sources/__init__.py +11 -0
- openmatchkit/sources/base.py +29 -0
- openmatchkit/sources/football_data_uk.py +121 -0
- openmatchkit/sources/json_file.py +189 -0
- openmatchkit/sources/openfootball.py +235 -0
- openmatchkit/sources/public_html.py +41 -0
- openmatchkit-0.2.1.dist-info/METADATA +192 -0
- openmatchkit-0.2.1.dist-info/RECORD +20 -0
- openmatchkit-0.2.1.dist-info/WHEEL +4 -0
- openmatchkit-0.2.1.dist-info/entry_points.txt +2 -0
openmatchkit/__init__.py
ADDED
|
@@ -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."""
|