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 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.
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ pandas>=1.5
2
+ pyarrow>=10
@@ -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()