openplay 0.1.0__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.
- openplay-0.1.0/LICENSE +21 -0
- openplay-0.1.0/PKG-INFO +92 -0
- openplay-0.1.0/README.md +71 -0
- openplay-0.1.0/pyproject.toml +29 -0
- openplay-0.1.0/setup.cfg +4 -0
- openplay-0.1.0/src/openplay/__init__.py +34 -0
- openplay-0.1.0/src/openplay/_data.py +147 -0
- openplay-0.1.0/src/openplay/api.py +374 -0
- openplay-0.1.0/src/openplay.egg-info/PKG-INFO +92 -0
- openplay-0.1.0/src/openplay.egg-info/SOURCES.txt +12 -0
- openplay-0.1.0/src/openplay.egg-info/dependency_links.txt +1 -0
- openplay-0.1.0/src/openplay.egg-info/requires.txt +2 -0
- openplay-0.1.0/src/openplay.egg-info/top_level.txt +1 -0
- openplay-0.1.0/tests/test_api.py +145 -0
openplay-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Noah Bair
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
openplay-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openplay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Open men's soccer event data (every pass, shot and tackle with coordinates) with xG on every shot
|
|
5
|
+
Author-email: Noah Bair <noahlbair@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/nlbair/openplay-py
|
|
8
|
+
Project-URL: Data, https://github.com/nlbair/openplay
|
|
9
|
+
Project-URL: Issues, https://github.com/nlbair/openplay-py/issues
|
|
10
|
+
Keywords: soccer,football,event data,xG,sports analytics
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pandas>=1.5
|
|
19
|
+
Requires-Dist: pyarrow>=10
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# openplay (Python)
|
|
23
|
+
|
|
24
|
+
Read the [openplay](https://github.com/nlbair/openplay) soccer event data in Python. It covers 26,536 men's matches with every pass, shot and tackle, plus xG on every shot. Files download once from the data release, are checked against published SHA-256 checksums, and are cached on your computer.
|
|
25
|
+
|
|
26
|
+
The R package [openplay-r](https://github.com/nlbair/openplay-r) has the same functions and returns the same numbers.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install openplay
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or the latest version from GitHub: `pip install git+https://github.com/nlbair/openplay-py`
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import openplay as op
|
|
40
|
+
|
|
41
|
+
op.competitions()
|
|
42
|
+
m = op.matches("eng", "2024/25")
|
|
43
|
+
e = op.events("eng", "2024/25", columns=["match_id", "type", "x", "y", "player"])
|
|
44
|
+
s = op.shots("eng", "2024/25")
|
|
45
|
+
p = op.player_match_stats("eng", "2024/25")
|
|
46
|
+
ps = op.player_season_stats("eng", "2024/25", per90=True, min_minutes=900)
|
|
47
|
+
op.player_season_stats("esp", "2016/17", player="Cristiano Ronaldo")
|
|
48
|
+
op.player_career("Lionel Messi")
|
|
49
|
+
t = op.team_match_stats("eng", "2024/25")
|
|
50
|
+
op.lineups("ucl", "2425")
|
|
51
|
+
op.players()
|
|
52
|
+
op.teams()
|
|
53
|
+
op.coverage_report("nla")
|
|
54
|
+
op.known_gaps()
|
|
55
|
+
op.data_issues()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- **Seasons.** You can write a season as `"2024/25"`, `"2024-25"` or `"2425"`. Tournaments use the year, for example `"2022"`.
|
|
59
|
+
- **Players.** `player=` takes the full name. Capitals and accents don't matter (`"kylian mbappe"` works). A misspelling gets a warning like *No player named 'Lionel Messsi'. Did you mean: Lionel Messi?* If two players share a name, use `player_id=`. The functions that take `player=` are `player_season_stats`, `player_match_stats`, `player_career`, `shots`, `lineups` and `events`.
|
|
60
|
+
- **Gaps.** When a request includes matches that have no event data, or have a known data issue, you get a `CoverageWarning`. It never stops your code with an error.
|
|
61
|
+
- **Local copy.** If you already have the files on disk, `op.set_data_dir(path)` reads them from there. Setting the `OPENPLAY_DATA_DIR` environment variable does the same.
|
|
62
|
+
- **Cache.** Downloads are kept in `op.cache_dir()`. `op.clear_cache()` frees the space.
|
|
63
|
+
|
|
64
|
+
## Functions
|
|
65
|
+
|
|
66
|
+
| Function | Returns |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `competitions()` | one row per competition |
|
|
69
|
+
| `matches(comp, season)` | one row per match |
|
|
70
|
+
| `events(comp, season, columns)` | one row per event (`comp` is required) |
|
|
71
|
+
| `shots(comp, season)` | one row per shot, with xG and the player who set it up |
|
|
72
|
+
| `lineups(comp, season)` | one row per player in each matchday squad |
|
|
73
|
+
| `player_match_stats(comp, season)` | one row per player per match played, about 80 counts |
|
|
74
|
+
| `player_season_stats(comp, season, per90, min_minutes, player)` | season totals or per-90 numbers per player and team |
|
|
75
|
+
| `player_career(player, per90)` | one player's whole career: one row per competition, season and team |
|
|
76
|
+
| `suggest_players(name)` | the closest full names to what was typed |
|
|
77
|
+
| `team_match_stats(comp, season)` | one row per team per match |
|
|
78
|
+
| `players()`, `teams()` | one row per player or team |
|
|
79
|
+
| `formations(comp, season)`, `cards(comp, season)` | formation spells and cards |
|
|
80
|
+
| `coverage_report(comp, season)`, `known_gaps()`, `data_issues()` | what's covered and what's missing |
|
|
81
|
+
|
|
82
|
+
## Tests
|
|
83
|
+
|
|
84
|
+
The tests run on a small sample dataset (every UEFA Super Cup plus Nations League A 2022/23), with no download needed:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
python -m unittest discover -s tests
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
Code: MIT. Data: CC-BY 4.0; credit "openplay (Noah Bair)".
|
openplay-0.1.0/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# openplay (Python)
|
|
2
|
+
|
|
3
|
+
Read the [openplay](https://github.com/nlbair/openplay) soccer event data in Python. It covers 26,536 men's matches with every pass, shot and tackle, plus xG on every shot. Files download once from the data release, are checked against published SHA-256 checksums, and are cached on your computer.
|
|
4
|
+
|
|
5
|
+
The R package [openplay-r](https://github.com/nlbair/openplay-r) has the same functions and returns the same numbers.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install openplay
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or the latest version from GitHub: `pip install git+https://github.com/nlbair/openplay-py`
|
|
14
|
+
|
|
15
|
+
## Use
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import openplay as op
|
|
19
|
+
|
|
20
|
+
op.competitions()
|
|
21
|
+
m = op.matches("eng", "2024/25")
|
|
22
|
+
e = op.events("eng", "2024/25", columns=["match_id", "type", "x", "y", "player"])
|
|
23
|
+
s = op.shots("eng", "2024/25")
|
|
24
|
+
p = op.player_match_stats("eng", "2024/25")
|
|
25
|
+
ps = op.player_season_stats("eng", "2024/25", per90=True, min_minutes=900)
|
|
26
|
+
op.player_season_stats("esp", "2016/17", player="Cristiano Ronaldo")
|
|
27
|
+
op.player_career("Lionel Messi")
|
|
28
|
+
t = op.team_match_stats("eng", "2024/25")
|
|
29
|
+
op.lineups("ucl", "2425")
|
|
30
|
+
op.players()
|
|
31
|
+
op.teams()
|
|
32
|
+
op.coverage_report("nla")
|
|
33
|
+
op.known_gaps()
|
|
34
|
+
op.data_issues()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- **Seasons.** You can write a season as `"2024/25"`, `"2024-25"` or `"2425"`. Tournaments use the year, for example `"2022"`.
|
|
38
|
+
- **Players.** `player=` takes the full name. Capitals and accents don't matter (`"kylian mbappe"` works). A misspelling gets a warning like *No player named 'Lionel Messsi'. Did you mean: Lionel Messi?* If two players share a name, use `player_id=`. The functions that take `player=` are `player_season_stats`, `player_match_stats`, `player_career`, `shots`, `lineups` and `events`.
|
|
39
|
+
- **Gaps.** When a request includes matches that have no event data, or have a known data issue, you get a `CoverageWarning`. It never stops your code with an error.
|
|
40
|
+
- **Local copy.** If you already have the files on disk, `op.set_data_dir(path)` reads them from there. Setting the `OPENPLAY_DATA_DIR` environment variable does the same.
|
|
41
|
+
- **Cache.** Downloads are kept in `op.cache_dir()`. `op.clear_cache()` frees the space.
|
|
42
|
+
|
|
43
|
+
## Functions
|
|
44
|
+
|
|
45
|
+
| Function | Returns |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `competitions()` | one row per competition |
|
|
48
|
+
| `matches(comp, season)` | one row per match |
|
|
49
|
+
| `events(comp, season, columns)` | one row per event (`comp` is required) |
|
|
50
|
+
| `shots(comp, season)` | one row per shot, with xG and the player who set it up |
|
|
51
|
+
| `lineups(comp, season)` | one row per player in each matchday squad |
|
|
52
|
+
| `player_match_stats(comp, season)` | one row per player per match played, about 80 counts |
|
|
53
|
+
| `player_season_stats(comp, season, per90, min_minutes, player)` | season totals or per-90 numbers per player and team |
|
|
54
|
+
| `player_career(player, per90)` | one player's whole career: one row per competition, season and team |
|
|
55
|
+
| `suggest_players(name)` | the closest full names to what was typed |
|
|
56
|
+
| `team_match_stats(comp, season)` | one row per team per match |
|
|
57
|
+
| `players()`, `teams()` | one row per player or team |
|
|
58
|
+
| `formations(comp, season)`, `cards(comp, season)` | formation spells and cards |
|
|
59
|
+
| `coverage_report(comp, season)`, `known_gaps()`, `data_issues()` | what's covered and what's missing |
|
|
60
|
+
|
|
61
|
+
## Tests
|
|
62
|
+
|
|
63
|
+
The tests run on a small sample dataset (every UEFA Super Cup plus Nations League A 2022/23), with no download needed:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
python -m unittest discover -s tests
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
Code: MIT. Data: CC-BY 4.0; credit "openplay (Noah Bair)".
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "openplay"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Open men's soccer event data (every pass, shot and tackle with coordinates) with xG on every shot"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{name = "Noah Bair", email = "noahlbair@gmail.com"}]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = ["pandas>=1.5", "pyarrow>=10"]
|
|
15
|
+
keywords = ["soccer", "football", "event data", "xG", "sports analytics"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Scientific/Engineering",
|
|
19
|
+
"Intended Audience :: Science/Research",
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/nlbair/openplay-py"
|
|
25
|
+
Data = "https://github.com/nlbair/openplay"
|
|
26
|
+
Issues = "https://github.com/nlbair/openplay-py/issues"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
openplay-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""openplay: open men's soccer event data with xG on every shot."""
|
|
2
|
+
|
|
3
|
+
from ._data import cache_dir, clear_cache, set_data_dir, DATA_REPO, DATA_TAG
|
|
4
|
+
from .api import (
|
|
5
|
+
CoverageWarning,
|
|
6
|
+
PlayerWarning,
|
|
7
|
+
cards,
|
|
8
|
+
competitions,
|
|
9
|
+
coverage_report,
|
|
10
|
+
data_issues,
|
|
11
|
+
events,
|
|
12
|
+
formations,
|
|
13
|
+
known_gaps,
|
|
14
|
+
lineups,
|
|
15
|
+
matches,
|
|
16
|
+
name_key,
|
|
17
|
+
normalize_season,
|
|
18
|
+
player_career,
|
|
19
|
+
player_match_stats,
|
|
20
|
+
player_season_stats,
|
|
21
|
+
players,
|
|
22
|
+
shots,
|
|
23
|
+
suggest_players,
|
|
24
|
+
team_match_stats,
|
|
25
|
+
teams,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"CoverageWarning", "PlayerWarning", "name_key", "player_career", "suggest_players", "cache_dir", "cards", "clear_cache", "competitions", "coverage_report", "data_issues", "events",
|
|
32
|
+
"formations", "known_gaps", "lineups", "matches", "normalize_season", "player_match_stats", "player_season_stats", "players", "set_data_dir",
|
|
33
|
+
"shots", "team_match_stats", "teams", "DATA_REPO", "DATA_TAG", "__version__",
|
|
34
|
+
]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
import urllib.request
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
DATA_REPO = os.environ.get("OPENPLAY_REPO", "nlbair/openplay")
|
|
10
|
+
DATA_TAG = os.environ.get("OPENPLAY_DATA_TAG", "data-v0.1.0")
|
|
11
|
+
CHECKSUM_FILE = "checksums.json"
|
|
12
|
+
|
|
13
|
+
_state = {"data_dir": None, "checksums": None}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_data_dir(path):
|
|
17
|
+
"""Read tables from a local folder instead of downloading them.
|
|
18
|
+
|
|
19
|
+
The folder must hold the release files side by side, for example
|
|
20
|
+
``matches.parquet`` and ``events_eng_2425.parquet``. Pass ``None`` to go
|
|
21
|
+
back to downloading from the GitHub release.
|
|
22
|
+
"""
|
|
23
|
+
_state["data_dir"] = None if path is None else Path(path)
|
|
24
|
+
_state["checksums"] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def data_dir():
|
|
28
|
+
if _state["data_dir"] is not None:
|
|
29
|
+
return _state["data_dir"]
|
|
30
|
+
env = os.environ.get("OPENPLAY_DATA_DIR")
|
|
31
|
+
return Path(env) if env else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cache_dir():
|
|
35
|
+
"""Folder where downloaded files are kept between sessions."""
|
|
36
|
+
env = os.environ.get("OPENPLAY_CACHE_DIR")
|
|
37
|
+
if env:
|
|
38
|
+
base = Path(env)
|
|
39
|
+
elif os.name == "nt":
|
|
40
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "openplay" / "cache"
|
|
41
|
+
else:
|
|
42
|
+
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "openplay"
|
|
43
|
+
path = base / DATA_TAG
|
|
44
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def clear_cache():
|
|
49
|
+
"""Delete every downloaded file for the current data release."""
|
|
50
|
+
path = cache_dir()
|
|
51
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
52
|
+
_state["checksums"] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _token():
|
|
56
|
+
return os.environ.get("OPENPLAY_GITHUB_TOKEN") or os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _request(url, accept=None):
|
|
60
|
+
req = urllib.request.Request(url, headers={"User-Agent": "openplay-python"})
|
|
61
|
+
token = _token()
|
|
62
|
+
if token:
|
|
63
|
+
req.add_header("Authorization", "Bearer " + token)
|
|
64
|
+
if accept:
|
|
65
|
+
req.add_header("Accept", accept)
|
|
66
|
+
return urllib.request.urlopen(req, timeout=120)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _asset_urls():
|
|
70
|
+
if "assets" in _state and _state.get("assets_tag") == DATA_TAG:
|
|
71
|
+
return _state["assets"]
|
|
72
|
+
api = f"https://api.github.com/repos/{DATA_REPO}/releases/tags/{DATA_TAG}"
|
|
73
|
+
with _request(api, accept="application/vnd.github+json") as r:
|
|
74
|
+
release = json.load(r)
|
|
75
|
+
_state["assets"] = {a["name"]: a["url"] for a in release.get("assets", [])}
|
|
76
|
+
_state["assets_tag"] = DATA_TAG
|
|
77
|
+
return _state["assets"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _download(name, dest):
|
|
81
|
+
if _token():
|
|
82
|
+
urls = _asset_urls()
|
|
83
|
+
if name not in urls:
|
|
84
|
+
raise FileNotFoundError(f"{name} is not in release {DATA_TAG} of {DATA_REPO}")
|
|
85
|
+
resp = _request(urls[name], accept="application/octet-stream")
|
|
86
|
+
else:
|
|
87
|
+
resp = _request(f"https://github.com/{DATA_REPO}/releases/download/{DATA_TAG}/{name}")
|
|
88
|
+
fd, tmp = tempfile.mkstemp(dir=dest.parent, suffix=".part")
|
|
89
|
+
try:
|
|
90
|
+
with os.fdopen(fd, "wb") as fh, resp:
|
|
91
|
+
shutil.copyfileobj(resp, fh, length=1 << 20)
|
|
92
|
+
os.replace(tmp, dest)
|
|
93
|
+
finally:
|
|
94
|
+
if os.path.exists(tmp):
|
|
95
|
+
os.remove(tmp)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _sha256(path):
|
|
99
|
+
h = hashlib.sha256()
|
|
100
|
+
with open(path, "rb") as fh:
|
|
101
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
102
|
+
h.update(chunk)
|
|
103
|
+
return h.hexdigest()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def checksums():
|
|
107
|
+
if _state["checksums"] is not None:
|
|
108
|
+
return _state["checksums"]
|
|
109
|
+
local = data_dir()
|
|
110
|
+
if local is not None:
|
|
111
|
+
path = local / CHECKSUM_FILE
|
|
112
|
+
_state["checksums"] = json.loads(path.read_text()) if path.exists() else {}
|
|
113
|
+
return _state["checksums"]
|
|
114
|
+
path = cache_dir() / CHECKSUM_FILE
|
|
115
|
+
if not path.exists():
|
|
116
|
+
_download(CHECKSUM_FILE, path)
|
|
117
|
+
_state["checksums"] = json.loads(path.read_text())
|
|
118
|
+
return _state["checksums"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def available_files():
|
|
122
|
+
local = data_dir()
|
|
123
|
+
if local is not None:
|
|
124
|
+
return sorted(p.name for p in local.iterdir() if p.is_file())
|
|
125
|
+
return sorted(checksums())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fetch(name):
|
|
129
|
+
"""Return a local path to one release file, downloading it if needed."""
|
|
130
|
+
local = data_dir()
|
|
131
|
+
if local is not None:
|
|
132
|
+
path = local / name
|
|
133
|
+
if not path.exists():
|
|
134
|
+
raise FileNotFoundError(f"{name} not found in {local}")
|
|
135
|
+
return path
|
|
136
|
+
sums = checksums()
|
|
137
|
+
if name not in sums:
|
|
138
|
+
raise FileNotFoundError(f"{name} is not part of data release {DATA_TAG}")
|
|
139
|
+
path = cache_dir() / name
|
|
140
|
+
if path.exists() and _sha256(path) == sums[name]["sha256"]:
|
|
141
|
+
return path
|
|
142
|
+
_download(name, path)
|
|
143
|
+
got = _sha256(path)
|
|
144
|
+
if got != sums[name]["sha256"]:
|
|
145
|
+
path.unlink(missing_ok=True)
|
|
146
|
+
raise IOError(f"checksum mismatch for {name}; the download was removed, please try again")
|
|
147
|
+
return path
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import unicodedata
|
|
3
|
+
import warnings
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import pyarrow.parquet as pq
|
|
8
|
+
|
|
9
|
+
from . import _data
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CoverageWarning(UserWarning):
|
|
13
|
+
"""Raised as a warning when a request touches matches with no event data."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PlayerWarning(UserWarning):
|
|
17
|
+
"""Raised as a warning when a player name has no exact match or matches several players."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_FOLD = str.maketrans({"\u00f8": "o", "\u00d8": "O", "\u0142": "l", "\u0141": "L", "\u0111": "d", "\u0110": "D", "\u00df": "ss",
|
|
21
|
+
"\u00e6": "ae", "\u00c6": "AE", "\u0153": "oe", "\u0152": "OE", "\u0131": "i", "\u00fe": "th", "\u00de": "TH",
|
|
22
|
+
"\u00f0": "d", "\u00d0": "D", "\u0127": "h", "\u0126": "H", "\u2019": "'", "\u2018": "'"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def name_key(name):
|
|
26
|
+
"""Lower-case a name and strip accents, so 'Kylian Mbapp\u00e9' and 'kylian mbappe' match."""
|
|
27
|
+
s = unicodedata.normalize("NFKD", str(name).translate(_FOLD))
|
|
28
|
+
s = "".join(c for c in s if not unicodedata.combining(c))
|
|
29
|
+
return re.sub(r"\s+", " ", s).strip().lower()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _edit_distance(a, b):
|
|
33
|
+
prev = list(range(len(b) + 1))
|
|
34
|
+
for i, ca in enumerate(a, 1):
|
|
35
|
+
cur = [i]
|
|
36
|
+
for j, cb in enumerate(b, 1):
|
|
37
|
+
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
|
|
38
|
+
prev = cur
|
|
39
|
+
return prev[-1]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _player_keys():
|
|
43
|
+
pl = players()[["player_id", "player"]].dropna()
|
|
44
|
+
return pl.assign(key=pl["player"].astype(str).map(name_key))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def suggest_players(name, n=5):
|
|
48
|
+
"""Full names closest to ``name``: small spelling mistakes, or names that contain every word typed."""
|
|
49
|
+
key = name_key(name)
|
|
50
|
+
pl = _player_keys().drop_duplicates("key")
|
|
51
|
+
limit = max(2, len(key) // 4)
|
|
52
|
+
words = set(key.split())
|
|
53
|
+
hits = []
|
|
54
|
+
for k, full in zip(pl["key"], pl["player"]):
|
|
55
|
+
partial = bool(words) and words <= set(k.split())
|
|
56
|
+
if partial or abs(len(k) - len(key)) <= limit:
|
|
57
|
+
d = _edit_distance(key, k)
|
|
58
|
+
if partial or d <= limit:
|
|
59
|
+
hits.append((0 if partial else 1, d, k, str(full)))
|
|
60
|
+
hits.sort()
|
|
61
|
+
return [h[3] for h in hits[:n]]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _player_ids(player, player_id):
|
|
65
|
+
if player_id is not None:
|
|
66
|
+
return {int(x) for x in _as_list(player_id)}
|
|
67
|
+
if player is None:
|
|
68
|
+
return None
|
|
69
|
+
pl = _player_keys()
|
|
70
|
+
ids = set()
|
|
71
|
+
for name in _as_list(player):
|
|
72
|
+
found = pl[pl["key"] == name_key(name)]
|
|
73
|
+
if found.empty:
|
|
74
|
+
sug = suggest_players(name)
|
|
75
|
+
if sug:
|
|
76
|
+
msg = f"No player named '{name}'. Did you mean: " + ", ".join(sug) + "?"
|
|
77
|
+
else:
|
|
78
|
+
msg = f"No player named '{name}'. Check the spelling (use the full name) or search players()."
|
|
79
|
+
warnings.warn(msg, PlayerWarning, stacklevel=3)
|
|
80
|
+
continue
|
|
81
|
+
found_ids = sorted(int(i) for i in found["player_id"].unique())
|
|
82
|
+
if len(found_ids) > 1:
|
|
83
|
+
warnings.warn(f"{len(found_ids)} players are named '{name}' (player_id " + ", ".join(str(i) for i in found_ids) + "); all are included. Use player_id= to pick one.", PlayerWarning, stacklevel=3)
|
|
84
|
+
ids |= set(found_ids)
|
|
85
|
+
return ids
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _keep_players(df, ids, col="player_id"):
|
|
89
|
+
if ids is None:
|
|
90
|
+
return df
|
|
91
|
+
out = df[df[col].isin(ids)].reset_index(drop=True)
|
|
92
|
+
if ids and out.empty:
|
|
93
|
+
pl = players()
|
|
94
|
+
names = ", ".join(f"{n} (player_id {int(i)})" for i, n in zip(pl["player_id"], pl["player"]) if int(i) in ids)
|
|
95
|
+
warnings.warn(f"Found {names}, but there are no rows for them in this selection (they may not have played in this competition or season); try player_career().", PlayerWarning, stacklevel=3)
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _as_list(x):
|
|
100
|
+
if x is None:
|
|
101
|
+
return None
|
|
102
|
+
if isinstance(x, (list, tuple, set, pd.Series)):
|
|
103
|
+
return [str(v) for v in x]
|
|
104
|
+
return [str(x)]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def normalize_season(season):
|
|
108
|
+
"""Turn '2024/25', '2024-25', '2024/2025' or '2425' into the stored form '2425'.
|
|
109
|
+
|
|
110
|
+
Tournament years such as '2022' are returned unchanged.
|
|
111
|
+
"""
|
|
112
|
+
s = str(season).strip()
|
|
113
|
+
m = re.fullmatch(r"(\d{4})\s*[/-]\s*(\d{2}|\d{4})", s)
|
|
114
|
+
if m:
|
|
115
|
+
return m.group(1)[2:] + m.group(2)[-2:]
|
|
116
|
+
return s
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _seasons(season):
|
|
120
|
+
v = _as_list(season)
|
|
121
|
+
return None if v is None else [normalize_season(x) for x in v]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _read_table(name, comp=None, season=None, columns=None):
|
|
125
|
+
df = pd.read_parquet(_data.fetch(name + ".parquet"), columns=columns)
|
|
126
|
+
comps, seasons = _as_list(comp), _seasons(season)
|
|
127
|
+
if comps is not None and "comp" in df.columns:
|
|
128
|
+
df = df[df["comp"].astype(str).isin(comps)]
|
|
129
|
+
if seasons is not None and "season" in df.columns:
|
|
130
|
+
df = df[df["season"].astype(str).isin(seasons)]
|
|
131
|
+
return df.reset_index(drop=True)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def competitions():
|
|
135
|
+
"""All competitions with their names, type, seasons and whether they are in the v1 scope."""
|
|
136
|
+
return _read_table("competitions")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def coverage_report(comp=None, season=None, warn=True):
|
|
140
|
+
"""Matches played, matches with event data, and known gaps per competition-season.
|
|
141
|
+
|
|
142
|
+
Never raises for gaps. With ``warn=True`` it issues a ``CoverageWarning``
|
|
143
|
+
when the selection includes matches that have no event data or have a
|
|
144
|
+
known data issue.
|
|
145
|
+
"""
|
|
146
|
+
cov = pd.read_csv(_data.fetch("coverage.csv"), dtype={"season": str})
|
|
147
|
+
comps, seasons = _as_list(comp), _seasons(season)
|
|
148
|
+
if comps is not None:
|
|
149
|
+
cov = cov[cov["comp"].isin(comps)]
|
|
150
|
+
if seasons is not None:
|
|
151
|
+
cov = cov[cov["season"].isin(seasons)]
|
|
152
|
+
cov = cov.reset_index(drop=True)
|
|
153
|
+
if warn and len(cov):
|
|
154
|
+
gaps = int(cov["gaps"].sum())
|
|
155
|
+
issues = int(cov["matches_with_data_issues"].sum())
|
|
156
|
+
if gaps:
|
|
157
|
+
warnings.warn(f"{gaps} played match(es) in this selection have no event data; see known_gaps()", CoverageWarning, stacklevel=2)
|
|
158
|
+
if issues:
|
|
159
|
+
warnings.warn(f"{issues} match(es) in this selection have known data issues; see data_issues()", CoverageWarning, stacklevel=2)
|
|
160
|
+
return cov
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _check(comp, season, ids=None):
|
|
164
|
+
if ids is not None and not ids:
|
|
165
|
+
return
|
|
166
|
+
try:
|
|
167
|
+
coverage_report(comp, season, warn=True)
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def known_gaps(comp=None, season=None):
|
|
173
|
+
"""Played matches that have no event data, with date, teams and score where known."""
|
|
174
|
+
df = pd.read_csv(_data.fetch("known_gaps.csv"), dtype={"season": str})
|
|
175
|
+
comps, seasons = _as_list(comp), _seasons(season)
|
|
176
|
+
if comps is not None:
|
|
177
|
+
df = df[df["comp"].isin(comps)]
|
|
178
|
+
if seasons is not None:
|
|
179
|
+
df = df[df["season"].isin(seasons)]
|
|
180
|
+
return df.reset_index(drop=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def data_issues(comp=None, season=None):
|
|
184
|
+
"""Matches whose events are partial, whose lineups are incomplete, or whose events disagree with the official score."""
|
|
185
|
+
df = pd.read_csv(_data.fetch("data_issues.csv"), dtype={"season": str})
|
|
186
|
+
comps, seasons = _as_list(comp), _seasons(season)
|
|
187
|
+
if comps is not None:
|
|
188
|
+
df = df[df["comp"].isin(comps)]
|
|
189
|
+
if seasons is not None:
|
|
190
|
+
df = df[df["season"].isin(seasons)]
|
|
191
|
+
return df.reset_index(drop=True)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def matches(comp=None, season=None, in_scope_only=False):
|
|
195
|
+
"""One row per match with teams, scores, venue, referee, managers and formations."""
|
|
196
|
+
df = _read_table("matches", comp, season)
|
|
197
|
+
if in_scope_only:
|
|
198
|
+
df = df[df["in_v1_scope"]].reset_index(drop=True)
|
|
199
|
+
_check(comp, season)
|
|
200
|
+
return df
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def events(comp, season=None, columns=None, player=None, player_id=None):
|
|
204
|
+
"""Every event for one or more competition-seasons.
|
|
205
|
+
|
|
206
|
+
``comp`` is required because the full dataset has about 42 million rows.
|
|
207
|
+
``season`` may be one season, a list, or ``None`` for every season of the
|
|
208
|
+
competition. ``columns`` limits the columns read, which saves memory.
|
|
209
|
+
``player`` keeps one player's events (full name; capitals and accents are ignored).
|
|
210
|
+
"""
|
|
211
|
+
ids = _player_ids(player, player_id)
|
|
212
|
+
if ids is not None and columns is not None and "player_id" not in columns:
|
|
213
|
+
columns = list(columns) + ["player_id"]
|
|
214
|
+
comps = _as_list(comp)
|
|
215
|
+
if not comps:
|
|
216
|
+
raise ValueError("events() needs at least one competition code, for example events('eng', '2425')")
|
|
217
|
+
seasons = _seasons(season)
|
|
218
|
+
names = []
|
|
219
|
+
for f in _data.available_files():
|
|
220
|
+
m = re.fullmatch(r"events_([a-z]+)_(\d+)\.parquet", f)
|
|
221
|
+
if m and m.group(1) in comps and (seasons is None or m.group(2) in seasons):
|
|
222
|
+
names.append(f)
|
|
223
|
+
if not names:
|
|
224
|
+
raise ValueError(f"no event files for comp={comps} season={seasons}; see competitions() for what exists")
|
|
225
|
+
frames = []
|
|
226
|
+
for n in sorted(names):
|
|
227
|
+
path = _data.fetch(n)
|
|
228
|
+
cols = None
|
|
229
|
+
if columns is not None:
|
|
230
|
+
have = set(pq.read_schema(path).names)
|
|
231
|
+
cols = [c for c in columns if c in have]
|
|
232
|
+
frames.append(_keep_players(pd.read_parquet(path, columns=cols), ids))
|
|
233
|
+
_check(comp, season, ids)
|
|
234
|
+
if len(frames) == 1:
|
|
235
|
+
return frames[0]
|
|
236
|
+
return pd.concat(frames, ignore_index=True, sort=False)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def shots(comp=None, season=None, player=None, player_id=None):
|
|
240
|
+
"""Every shot with location, body part, situation, outcome, xG and the player who set it up.
|
|
241
|
+
|
|
242
|
+
``player`` keeps one player's shots (full name; capitals and accents are ignored).
|
|
243
|
+
"""
|
|
244
|
+
ids = _player_ids(player, player_id)
|
|
245
|
+
df = _keep_players(_read_table("shots", comp, season), ids)
|
|
246
|
+
_check(comp, season, ids)
|
|
247
|
+
return df
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def lineups(comp=None, season=None, player=None, player_id=None):
|
|
251
|
+
"""Every player in every matchday squad: starter or sub, minutes, position.
|
|
252
|
+
|
|
253
|
+
``player`` keeps one player's rows (full name; capitals and accents are ignored).
|
|
254
|
+
"""
|
|
255
|
+
ids = _player_ids(player, player_id)
|
|
256
|
+
df = _keep_players(_read_table("lineups", comp, season), ids)
|
|
257
|
+
_check(comp, season, ids)
|
|
258
|
+
return df
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def player_match_stats(comp=None, season=None, player=None, player_id=None):
|
|
262
|
+
"""One row per player per match played, with about 80 counts built from the events.
|
|
263
|
+
|
|
264
|
+
``player`` keeps one player's matches (full name; capitals and accents are ignored).
|
|
265
|
+
"""
|
|
266
|
+
ids = _player_ids(player, player_id)
|
|
267
|
+
df = _keep_players(_read_table("player_match_stats", comp, season), ids)
|
|
268
|
+
_check(comp, season, ids)
|
|
269
|
+
return df
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def team_match_stats(comp=None, season=None):
|
|
273
|
+
"""One row per team per match, with the same counts plus goals, xG and shots against."""
|
|
274
|
+
df = _read_table("team_match_stats", comp, season)
|
|
275
|
+
_check(comp, season)
|
|
276
|
+
return df
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def players():
|
|
280
|
+
"""One row per player: name, height, weight, estimated birth year, main position, appearances and minutes."""
|
|
281
|
+
return _read_table("players")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def teams():
|
|
285
|
+
"""One row per team: name, country, club or national team, and competitions played."""
|
|
286
|
+
return _read_table("teams")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def formations(comp=None, season=None):
|
|
290
|
+
"""Every formation a team used in a match, with the minutes it was used."""
|
|
291
|
+
df = _read_table("formations")
|
|
292
|
+
if comp is not None or season is not None:
|
|
293
|
+
ids = matches(comp, season)["match_id"]
|
|
294
|
+
df = df[df["match_id"].isin(ids)].reset_index(drop=True)
|
|
295
|
+
return df
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cards(comp=None, season=None):
|
|
299
|
+
"""Every card shown, with player, team and minute."""
|
|
300
|
+
df = _read_table("cards")
|
|
301
|
+
if comp is not None or season is not None:
|
|
302
|
+
ids = matches(comp, season)["match_id"]
|
|
303
|
+
df = df[df["match_id"].isin(ids)].reset_index(drop=True)
|
|
304
|
+
return df
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
_ID_COLS = ["comp", "season", "player_id", "player", "team_id", "team"]
|
|
308
|
+
_NOT_SUMMED = {"match_id", "date", "opponent_id", "opponent", "side", "position", "is_starter", "played", "in_lineup", "minute_on", "minute_off", "pass_pct"}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def player_season_stats(comp=None, season=None, per90=False, min_minutes=0, player=None, player_id=None):
|
|
312
|
+
"""Season totals per player and team, built from player_match_stats.
|
|
313
|
+
|
|
314
|
+
A player who moved clubs mid-season gets one row per team. With
|
|
315
|
+
``per90=True`` every count is divided by minutes played and multiplied by
|
|
316
|
+
90. ``min_minutes`` drops rows below that many minutes. ``player`` keeps
|
|
317
|
+
one player (full name; capitals and accents are ignored).
|
|
318
|
+
"""
|
|
319
|
+
ids = _player_ids(player, player_id)
|
|
320
|
+
p = player_match_stats(comp, season, player_id=None if ids is None else sorted(ids))
|
|
321
|
+
p = p.assign(comp=p["comp"].astype(str), season=p["season"].astype(str))
|
|
322
|
+
p = p.sort_values(["comp", "season", "player_id", "team_id", "date", "match_id"], kind="mergesort")
|
|
323
|
+
p["is_starter"] = p["is_starter"].fillna(False).astype(bool)
|
|
324
|
+
counts = [c for c in p.columns if c not in _ID_COLS and c not in _NOT_SUMMED and c != "minutes_played" and pd.api.types.is_numeric_dtype(p[c])]
|
|
325
|
+
p[counts] = p[counts].astype("float64")
|
|
326
|
+
g = p.groupby(["comp", "season", "player_id", "team_id"], sort=True)
|
|
327
|
+
out = g[counts].sum()
|
|
328
|
+
out.insert(0, "minutes_played", g["minutes_played"].sum())
|
|
329
|
+
out.insert(0, "starts", g["is_starter"].sum().astype("int64"))
|
|
330
|
+
out.insert(0, "matches", g["match_id"].nunique())
|
|
331
|
+
out.insert(0, "team", g["team"].last())
|
|
332
|
+
out.insert(0, "player", g["player"].last())
|
|
333
|
+
out = out.reset_index()
|
|
334
|
+
out["pass_pct"] = np.floor(1000 * out["passes_completed"] / out["passes"].where(out["passes"] > 0) + 0.5) / 10
|
|
335
|
+
out["minutes_played"] = out["minutes_played"].astype("float64")
|
|
336
|
+
out = out[out["minutes_played"] >= min_minutes].reset_index(drop=True)
|
|
337
|
+
if per90:
|
|
338
|
+
mins = out["minutes_played"].where(out["minutes_played"] > 0)
|
|
339
|
+
for c in counts:
|
|
340
|
+
out[c] = out[c].astype("float64") / mins * 90
|
|
341
|
+
for c in counts:
|
|
342
|
+
if not per90 and c not in ("xg", "npxg", "xa"):
|
|
343
|
+
out[c] = out[c].astype("int64")
|
|
344
|
+
else:
|
|
345
|
+
out[c] = out[c].astype("float64")
|
|
346
|
+
return out
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def player_career(player=None, player_id=None, per90=False):
|
|
350
|
+
"""One player's whole career in the data: one row per competition, season and team, oldest first.
|
|
351
|
+
|
|
352
|
+
``player`` is the full name (capitals and accents are ignored). A close
|
|
353
|
+
misspelling gets a "did you mean" warning and an empty result.
|
|
354
|
+
"""
|
|
355
|
+
if player is None and player_id is None:
|
|
356
|
+
raise ValueError("player_career() needs a full player name or a player_id, for example player_career('Lionel Messi')")
|
|
357
|
+
ids = _player_ids(player, player_id)
|
|
358
|
+
if not ids:
|
|
359
|
+
return pd.DataFrame(columns=["player_id", "player", "comp", "season", "team_id", "team", "first_match"])
|
|
360
|
+
with warnings.catch_warnings():
|
|
361
|
+
warnings.simplefilter("ignore", CoverageWarning)
|
|
362
|
+
out = player_season_stats(per90=per90, player_id=sorted(ids))
|
|
363
|
+
pm = player_match_stats(player_id=sorted(ids))
|
|
364
|
+
pm = pm.assign(comp=pm["comp"].astype(str), season=pm["season"].astype(str))
|
|
365
|
+
first = pm.groupby(["comp", "season", "player_id", "team_id"])["date"].min().rename("first_match").reset_index()
|
|
366
|
+
out = out.merge(first, on=["comp", "season", "player_id", "team_id"], how="left")
|
|
367
|
+
out = out.sort_values(["player_id", "first_match", "comp", "team_id"], kind="mergesort").reset_index(drop=True)
|
|
368
|
+
cov = coverage_report(warn=False)
|
|
369
|
+
pairs = set(zip(out["comp"], out["season"]))
|
|
370
|
+
cov = cov[[(c, x) in pairs for c, x in zip(cov["comp"], cov["season"])]]
|
|
371
|
+
if int(cov["gaps"].sum()):
|
|
372
|
+
warnings.warn(f"{int(cov['gaps'].sum())} played match(es) in this player's competition-seasons have no event data, so the totals may miss some of their matches; see known_gaps()", CoverageWarning, stacklevel=2)
|
|
373
|
+
cols = ["player_id", "player", "comp", "season", "team_id", "team", "first_match"]
|
|
374
|
+
return out[cols + [c for c in out.columns if c not in cols]]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openplay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Open men's soccer event data (every pass, shot and tackle with coordinates) with xG on every shot
|
|
5
|
+
Author-email: Noah Bair <noahlbair@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/nlbair/openplay-py
|
|
8
|
+
Project-URL: Data, https://github.com/nlbair/openplay
|
|
9
|
+
Project-URL: Issues, https://github.com/nlbair/openplay-py/issues
|
|
10
|
+
Keywords: soccer,football,event data,xG,sports analytics
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pandas>=1.5
|
|
19
|
+
Requires-Dist: pyarrow>=10
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# openplay (Python)
|
|
23
|
+
|
|
24
|
+
Read the [openplay](https://github.com/nlbair/openplay) soccer event data in Python. It covers 26,536 men's matches with every pass, shot and tackle, plus xG on every shot. Files download once from the data release, are checked against published SHA-256 checksums, and are cached on your computer.
|
|
25
|
+
|
|
26
|
+
The R package [openplay-r](https://github.com/nlbair/openplay-r) has the same functions and returns the same numbers.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install openplay
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or the latest version from GitHub: `pip install git+https://github.com/nlbair/openplay-py`
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import openplay as op
|
|
40
|
+
|
|
41
|
+
op.competitions()
|
|
42
|
+
m = op.matches("eng", "2024/25")
|
|
43
|
+
e = op.events("eng", "2024/25", columns=["match_id", "type", "x", "y", "player"])
|
|
44
|
+
s = op.shots("eng", "2024/25")
|
|
45
|
+
p = op.player_match_stats("eng", "2024/25")
|
|
46
|
+
ps = op.player_season_stats("eng", "2024/25", per90=True, min_minutes=900)
|
|
47
|
+
op.player_season_stats("esp", "2016/17", player="Cristiano Ronaldo")
|
|
48
|
+
op.player_career("Lionel Messi")
|
|
49
|
+
t = op.team_match_stats("eng", "2024/25")
|
|
50
|
+
op.lineups("ucl", "2425")
|
|
51
|
+
op.players()
|
|
52
|
+
op.teams()
|
|
53
|
+
op.coverage_report("nla")
|
|
54
|
+
op.known_gaps()
|
|
55
|
+
op.data_issues()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- **Seasons.** You can write a season as `"2024/25"`, `"2024-25"` or `"2425"`. Tournaments use the year, for example `"2022"`.
|
|
59
|
+
- **Players.** `player=` takes the full name. Capitals and accents don't matter (`"kylian mbappe"` works). A misspelling gets a warning like *No player named 'Lionel Messsi'. Did you mean: Lionel Messi?* If two players share a name, use `player_id=`. The functions that take `player=` are `player_season_stats`, `player_match_stats`, `player_career`, `shots`, `lineups` and `events`.
|
|
60
|
+
- **Gaps.** When a request includes matches that have no event data, or have a known data issue, you get a `CoverageWarning`. It never stops your code with an error.
|
|
61
|
+
- **Local copy.** If you already have the files on disk, `op.set_data_dir(path)` reads them from there. Setting the `OPENPLAY_DATA_DIR` environment variable does the same.
|
|
62
|
+
- **Cache.** Downloads are kept in `op.cache_dir()`. `op.clear_cache()` frees the space.
|
|
63
|
+
|
|
64
|
+
## Functions
|
|
65
|
+
|
|
66
|
+
| Function | Returns |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `competitions()` | one row per competition |
|
|
69
|
+
| `matches(comp, season)` | one row per match |
|
|
70
|
+
| `events(comp, season, columns)` | one row per event (`comp` is required) |
|
|
71
|
+
| `shots(comp, season)` | one row per shot, with xG and the player who set it up |
|
|
72
|
+
| `lineups(comp, season)` | one row per player in each matchday squad |
|
|
73
|
+
| `player_match_stats(comp, season)` | one row per player per match played, about 80 counts |
|
|
74
|
+
| `player_season_stats(comp, season, per90, min_minutes, player)` | season totals or per-90 numbers per player and team |
|
|
75
|
+
| `player_career(player, per90)` | one player's whole career: one row per competition, season and team |
|
|
76
|
+
| `suggest_players(name)` | the closest full names to what was typed |
|
|
77
|
+
| `team_match_stats(comp, season)` | one row per team per match |
|
|
78
|
+
| `players()`, `teams()` | one row per player or team |
|
|
79
|
+
| `formations(comp, season)`, `cards(comp, season)` | formation spells and cards |
|
|
80
|
+
| `coverage_report(comp, season)`, `known_gaps()`, `data_issues()` | what's covered and what's missing |
|
|
81
|
+
|
|
82
|
+
## Tests
|
|
83
|
+
|
|
84
|
+
The tests run on a small sample dataset (every UEFA Super Cup plus Nations League A 2022/23), with no download needed:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
python -m unittest discover -s tests
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
Code: MIT. Data: CC-BY 4.0; credit "openplay (Noah Bair)".
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/openplay/__init__.py
|
|
5
|
+
src/openplay/_data.py
|
|
6
|
+
src/openplay/api.py
|
|
7
|
+
src/openplay.egg-info/PKG-INFO
|
|
8
|
+
src/openplay.egg-info/SOURCES.txt
|
|
9
|
+
src/openplay.egg-info/dependency_links.txt
|
|
10
|
+
src/openplay.egg-info/requires.txt
|
|
11
|
+
src/openplay.egg-info/top_level.txt
|
|
12
|
+
tests/test_api.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openplay
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import unittest
|
|
4
|
+
import warnings
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import openplay as op
|
|
8
|
+
|
|
9
|
+
SAMPLE = Path(os.environ.get("OPENPLAY_SAMPLE_DIR", Path(__file__).parent / "data"))
|
|
10
|
+
EXPECTED = json.loads((SAMPLE / "expected.json").read_text())
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SampleTests(unittest.TestCase):
|
|
14
|
+
@classmethod
|
|
15
|
+
def setUpClass(cls):
|
|
16
|
+
op.set_data_dir(SAMPLE)
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def tearDownClass(cls):
|
|
20
|
+
op.set_data_dir(None)
|
|
21
|
+
|
|
22
|
+
def test_normalize_season(self):
|
|
23
|
+
self.assertEqual(op.normalize_season("2024/25"), "2425")
|
|
24
|
+
self.assertEqual(op.normalize_season("2024-2025"), "2425")
|
|
25
|
+
self.assertEqual(op.normalize_season("2425"), "2425")
|
|
26
|
+
self.assertEqual(op.normalize_season(2022), "2022")
|
|
27
|
+
|
|
28
|
+
def test_competitions(self):
|
|
29
|
+
self.assertEqual(len(op.competitions()), EXPECTED["n_competitions"])
|
|
30
|
+
|
|
31
|
+
def test_matches(self):
|
|
32
|
+
with warnings.catch_warnings():
|
|
33
|
+
warnings.simplefilter("ignore")
|
|
34
|
+
m = op.matches()
|
|
35
|
+
self.assertEqual(len(m), EXPECTED["n_matches"])
|
|
36
|
+
self.assertEqual(int(m["home_score"].sum() + m["away_score"].sum()), EXPECTED["total_goals"])
|
|
37
|
+
self.assertEqual(len(op.matches("usc")), EXPECTED["n_matches_usc"])
|
|
38
|
+
|
|
39
|
+
def test_events(self):
|
|
40
|
+
with warnings.catch_warnings():
|
|
41
|
+
warnings.simplefilter("ignore")
|
|
42
|
+
e = op.events("nla", "2022/23")
|
|
43
|
+
self.assertEqual(len(e), EXPECTED["n_events_nla_2223"])
|
|
44
|
+
self.assertEqual(int((e["type"].astype(str) == "Pass").sum()), EXPECTED["n_passes_nla_2223"])
|
|
45
|
+
small = op.events("usc", columns=["match_id", "type", "xg", "not_a_column"])
|
|
46
|
+
self.assertEqual(list(small.columns), ["match_id", "type", "xg"])
|
|
47
|
+
|
|
48
|
+
def test_events_needs_comp(self):
|
|
49
|
+
with self.assertRaises(ValueError):
|
|
50
|
+
op.events(None)
|
|
51
|
+
|
|
52
|
+
def test_shots(self):
|
|
53
|
+
with warnings.catch_warnings():
|
|
54
|
+
warnings.simplefilter("ignore")
|
|
55
|
+
s = op.shots()
|
|
56
|
+
self.assertEqual(len(s), EXPECTED["n_shots"])
|
|
57
|
+
self.assertAlmostEqual(float(s["xg"].astype("float64").sum()), EXPECTED["sum_xg"], places=3)
|
|
58
|
+
|
|
59
|
+
def test_player_stats(self):
|
|
60
|
+
with warnings.catch_warnings():
|
|
61
|
+
warnings.simplefilter("ignore")
|
|
62
|
+
p = op.player_match_stats()
|
|
63
|
+
lu = op.lineups()
|
|
64
|
+
self.assertEqual(len(lu), EXPECTED["n_lineup_rows"])
|
|
65
|
+
self.assertEqual(len(p), EXPECTED["n_player_match_rows"])
|
|
66
|
+
self.assertEqual(int(p["minutes_played"].sum()), EXPECTED["sum_minutes"])
|
|
67
|
+
self.assertEqual(int(p["goals"].sum()), EXPECTED["sum_player_goals"])
|
|
68
|
+
self.assertEqual(int(p["assists"].sum()), EXPECTED["sum_player_assists"])
|
|
69
|
+
self.assertAlmostEqual(float(p["xa"].astype("float64").sum()), EXPECTED["sum_player_xa"], places=3)
|
|
70
|
+
top = p.groupby("player_id")["goals"].sum().reset_index().sort_values(["goals", "player_id"], ascending=[False, True]).iloc[0]
|
|
71
|
+
self.assertEqual(int(top["player_id"]), EXPECTED["top_scorer_player_id"])
|
|
72
|
+
self.assertEqual(int(top["goals"]), EXPECTED["top_scorer_goals"])
|
|
73
|
+
|
|
74
|
+
def test_team_stats(self):
|
|
75
|
+
with warnings.catch_warnings():
|
|
76
|
+
warnings.simplefilter("ignore")
|
|
77
|
+
t = op.team_match_stats()
|
|
78
|
+
self.assertEqual(int(t["passes"].sum()), EXPECTED["sum_team_passes"])
|
|
79
|
+
self.assertEqual(len(t), 2 * EXPECTED["n_matches"])
|
|
80
|
+
|
|
81
|
+
def test_coverage_warns_never_errors(self):
|
|
82
|
+
with warnings.catch_warnings(record=True) as w:
|
|
83
|
+
warnings.simplefilter("always")
|
|
84
|
+
cov = op.coverage_report("nla", "2223")
|
|
85
|
+
self.assertEqual(int(cov["gaps"].sum()), EXPECTED["gaps_nla_2223"])
|
|
86
|
+
self.assertTrue(any(issubclass(x.category, op.CoverageWarning) for x in w))
|
|
87
|
+
with warnings.catch_warnings(record=True) as w:
|
|
88
|
+
warnings.simplefilter("always")
|
|
89
|
+
op.matches("nla", "2223")
|
|
90
|
+
self.assertTrue(any(issubclass(x.category, op.CoverageWarning) for x in w))
|
|
91
|
+
with warnings.catch_warnings(record=True) as w:
|
|
92
|
+
warnings.simplefilter("always")
|
|
93
|
+
op.coverage_report("not_a_comp")
|
|
94
|
+
self.assertFalse(any(issubclass(x.category, op.CoverageWarning) for x in w))
|
|
95
|
+
|
|
96
|
+
def test_player_season_stats(self):
|
|
97
|
+
with warnings.catch_warnings():
|
|
98
|
+
warnings.simplefilter("ignore")
|
|
99
|
+
s = op.player_season_stats("nla", "2223")
|
|
100
|
+
s90 = op.player_season_stats("nla", "2223", per90=True, min_minutes=180)
|
|
101
|
+
self.assertEqual(len(s), EXPECTED["n_season_rows_nla_2223"])
|
|
102
|
+
self.assertEqual(int(s["minutes_played"].sum()), EXPECTED["sum_season_minutes_nla_2223"])
|
|
103
|
+
self.assertEqual(int(s["goals"].sum()), EXPECTED["sum_season_goals_nla_2223"])
|
|
104
|
+
self.assertEqual(len(s90), EXPECTED["n_season_rows_nla_2223_min180"])
|
|
105
|
+
best = s90.sort_values(["xg", "player_id"], ascending=[False, True]).iloc[0]
|
|
106
|
+
self.assertEqual(int(best["player_id"]), EXPECTED["top_xg90_player_id_nla_2223"])
|
|
107
|
+
self.assertAlmostEqual(float(best["xg"]), EXPECTED["top_xg90_nla_2223"], places=4)
|
|
108
|
+
|
|
109
|
+
def test_player_filter_and_career(self):
|
|
110
|
+
self.assertEqual(op.name_key(" Kylian Mbappé "), "kylian mbappe")
|
|
111
|
+
with warnings.catch_warnings():
|
|
112
|
+
warnings.simplefilter("ignore")
|
|
113
|
+
m = op.player_match_stats("nla", "2022/23", player="kylian mbappe")
|
|
114
|
+
s = op.shots("usc", player="Lionel Messi")
|
|
115
|
+
c = op.player_career("Luka Modric")
|
|
116
|
+
self.assertEqual(len(m), EXPECTED["mbappe_matches_nla_2223"])
|
|
117
|
+
self.assertEqual(len(s), EXPECTED["messi_shots_usc"])
|
|
118
|
+
self.assertEqual(len(c), EXPECTED["modric_career_rows"])
|
|
119
|
+
self.assertEqual(int(c["goals"].sum()), EXPECTED["modric_career_goals"])
|
|
120
|
+
self.assertEqual(int(c["minutes_played"].sum()), EXPECTED["modric_career_minutes"])
|
|
121
|
+
self.assertTrue(c["first_match"].is_monotonic_increasing)
|
|
122
|
+
|
|
123
|
+
def test_misspelled_player_suggests(self):
|
|
124
|
+
with warnings.catch_warnings(record=True) as w:
|
|
125
|
+
warnings.simplefilter("always")
|
|
126
|
+
r = op.player_season_stats(player="Lionel Messsi")
|
|
127
|
+
self.assertEqual(len(r), 0)
|
|
128
|
+
msgs = [str(x.message) for x in w if issubclass(x.category, op.PlayerWarning)]
|
|
129
|
+
self.assertTrue(any("Did you mean: Lionel Messi" in m for m in msgs))
|
|
130
|
+
self.assertEqual(op.suggest_players("Messi")[0], "Lionel Messi")
|
|
131
|
+
with warnings.catch_warnings(record=True) as w:
|
|
132
|
+
warnings.simplefilter("always")
|
|
133
|
+
self.assertEqual(len(op.player_career("Zzzz Qqqq")), 0)
|
|
134
|
+
self.assertTrue(any("Check the spelling" in str(x.message) for x in w))
|
|
135
|
+
|
|
136
|
+
def test_known_gaps(self):
|
|
137
|
+
self.assertEqual(len(op.known_gaps()), EXPECTED["n_known_gaps"])
|
|
138
|
+
|
|
139
|
+
def test_players_teams(self):
|
|
140
|
+
self.assertGreater(len(op.players()), 0)
|
|
141
|
+
self.assertGreater(len(op.teams()), 0)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
unittest.main()
|