sdvplot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sdvplot/__init__.py +68 -0
- sdvplot/_cache.py +484 -0
- sdvplot/_colors.py +206 -0
- sdvplot/_contrast.py +60 -0
- sdvplot/_court.py +110 -0
- sdvplot/_deprecate.py +39 -0
- sdvplot/_dispatch.py +342 -0
- sdvplot/_errors.py +93 -0
- sdvplot/_headshots.py +131 -0
- sdvplot/_images.py +292 -0
- sdvplot/_index.py +178 -0
- sdvplot/_lazy.py +15 -0
- sdvplot/_manifest.py +73 -0
- sdvplot/_marks.py +296 -0
- sdvplot/_normalize.py +82 -0
- sdvplot/_placement.py +168 -0
- sdvplot/_resolve.py +363 -0
- sdvplot/_surface.py +220 -0
- sdvplot/_tables.py +87 -0
- sdvplot/_tiers.py +135 -0
- sdvplot/_types.py +38 -0
- sdvplot/_versions.py +30 -0
- sdvplot/_web.py +61 -0
- sdvplot/altair.py +652 -0
- sdvplot/bokeh.py +323 -0
- sdvplot/data/INDEX_VERSION +1 -0
- sdvplot/data/aliases.parquet +0 -0
- sdvplot/data/teams.parquet +0 -0
- sdvplot/folium.py +316 -0
- sdvplot/great_tables/__init__.py +267 -0
- sdvplot/great_tables/_cells.py +1814 -0
- sdvplot/great_tables/_export.py +844 -0
- sdvplot/great_tables/_layout.py +1802 -0
- sdvplot/great_tables/_marks.py +798 -0
- sdvplot/great_tables/_themes.py +2134 -0
- sdvplot/holoviews.py +303 -0
- sdvplot/matplotlib.py +934 -0
- sdvplot/plotly.py +595 -0
- sdvplot/plotnine.py +1104 -0
- sdvplot/plottable.py +141 -0
- sdvplot/py.typed +0 -0
- sdvplot/pygal.py +416 -0
- sdvplot/reactable.py +488 -0
- sdvplot/testing.py +579 -0
- sdvplot/typing.py +21 -0
- sdvplot-0.1.0.dist-info/METADATA +188 -0
- sdvplot-0.1.0.dist-info/RECORD +49 -0
- sdvplot-0.1.0.dist-info/WHEEL +4 -0
- sdvplot-0.1.0.dist-info/licenses/LICENSE +21 -0
sdvplot/_resolve.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Turn whatever team identifiers a user has into canonical (league, team_id) keys, never guessing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import numbers
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from typing import TYPE_CHECKING, Any, overload
|
|
9
|
+
|
|
10
|
+
from sdvplot import _index
|
|
11
|
+
from sdvplot._errors import InputError, UnresolvedTeamError, warn
|
|
12
|
+
from sdvplot._normalize import _is_na, check_season, norm_season, norm_value
|
|
13
|
+
from sdvplot._types import IdSystem
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
import polars as pl
|
|
19
|
+
else:
|
|
20
|
+
from sdvplot._lazy import pl
|
|
21
|
+
|
|
22
|
+
# The order "auto" tries id systems in; the first system with a candidate decides
|
|
23
|
+
PRIORITY: tuple[str, ...] = (
|
|
24
|
+
"team_id",
|
|
25
|
+
"espn",
|
|
26
|
+
"espn_abbr",
|
|
27
|
+
"nhl",
|
|
28
|
+
"nflverse",
|
|
29
|
+
"mlbstats",
|
|
30
|
+
"nba_api",
|
|
31
|
+
"hockeytech",
|
|
32
|
+
"ncaa",
|
|
33
|
+
"pff",
|
|
34
|
+
"cricinfo",
|
|
35
|
+
"cfbd",
|
|
36
|
+
"bref",
|
|
37
|
+
"sportsipy",
|
|
38
|
+
"fangraphs",
|
|
39
|
+
"sdvplotr", # sdvplotR's clean_team_abbrs keys: after every id system, so it only fills gaps (R43)
|
|
40
|
+
"name",
|
|
41
|
+
)
|
|
42
|
+
# Systems "auto" never tries, only an explicit id_system: NHL stats API ids 1-28 are other teams' ESPN ids (R49)
|
|
43
|
+
EXPLICIT_ONLY: tuple[str, ...] = ("nhl_id",)
|
|
44
|
+
Candidates = list[tuple[str, int | None, int | None]]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@functools.cache
|
|
48
|
+
def _lookup(league: str) -> dict[str, dict[str, Candidates]]:
|
|
49
|
+
"""id_system -> normalized value -> [(team_id, valid_from, valid_to)] for one league."""
|
|
50
|
+
out: dict[str, dict[str, Candidates]] = {}
|
|
51
|
+
for r in _index.alias_table().filter(pl.col("league") == league).iter_rows(named=True):
|
|
52
|
+
key = norm_value(r["value"])
|
|
53
|
+
if key is not None:
|
|
54
|
+
out.setdefault(r["id_system"], {}).setdefault(key, []).append(
|
|
55
|
+
(r["team_id"], r["valid_from"], r["valid_to"])
|
|
56
|
+
)
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_index.on_reload(_lookup.cache_clear)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@functools.cache
|
|
64
|
+
def _latest(league: str) -> int | None:
|
|
65
|
+
"""The latest season any of the league's id-system aliases names (a range's end, else its start); None if none
|
|
66
|
+
is dated. A value given without a season is read in this season first: the code's current holder."""
|
|
67
|
+
a = _index.alias_table().filter((pl.col("league") == league) & (pl.col("id_system") != "mark"))
|
|
68
|
+
latest = a.select(pl.max_horizontal(pl.col("valid_from").max(), pl.col("valid_to").max())).item()
|
|
69
|
+
return None if latest is None else int(latest)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_index.on_reload(_latest.cache_clear)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _covers(lo: int | None, hi: int | None, season: int) -> bool:
|
|
76
|
+
return (lo is None or season >= lo) and (hi is None or season <= hi)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _match(
|
|
80
|
+
key: str,
|
|
81
|
+
season: int | None,
|
|
82
|
+
systems: Sequence[str],
|
|
83
|
+
table: dict[str, dict[str, Candidates]],
|
|
84
|
+
latest: int | None = None,
|
|
85
|
+
) -> Any:
|
|
86
|
+
"""The team_id for one key, None if unknown, or a sorted tuple of the team_ids when the deciding system names
|
|
87
|
+
several teams (ambiguous).
|
|
88
|
+
|
|
89
|
+
With a season, a first pass only considers aliases whose range covers it, so a reused code such as "LA" goes
|
|
90
|
+
to the team that used it that season. Then, or first without a season, a pass reads the ``latest`` season, so a
|
|
91
|
+
code two franchises used (KCA: the 1955-67 Kansas City Athletics, the Royals since) means its current holder.
|
|
92
|
+
A last pass ignores ranges, so a unique code still resolves when no range covers the season (a modern
|
|
93
|
+
abbreviation used on old data)."""
|
|
94
|
+
for when in [*(s for s in dict.fromkeys((season, latest)) if s is not None), None]:
|
|
95
|
+
for system in systems:
|
|
96
|
+
cands = table.get(system, {}).get(key)
|
|
97
|
+
if not cands:
|
|
98
|
+
continue
|
|
99
|
+
if when is not None:
|
|
100
|
+
cands = [c for c in cands if _covers(c[1], c[2], when)]
|
|
101
|
+
if not cands:
|
|
102
|
+
continue
|
|
103
|
+
ids = {c[0] for c in cands}
|
|
104
|
+
return next(iter(ids)) if len(ids) == 1 else tuple(sorted(ids))
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _scalar(values: Any) -> tuple[bool, Any]:
|
|
109
|
+
"""(True, the value) for one value, 0-d numpy arrays included (np.array("KC") is "KC"); else (False, values)."""
|
|
110
|
+
if getattr(values, "ndim", None) == 0 and hasattr(values, "item"):
|
|
111
|
+
values = values.item()
|
|
112
|
+
# numbers.Number covers numpy scalars such as np.int64
|
|
113
|
+
one = values is None or isinstance(values, (str, bytes, numbers.Number)) or type(values).__name__ == "NAType"
|
|
114
|
+
return one, values
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def one_team(team: Any, fn: str) -> Any:
|
|
118
|
+
"""team as a scalar, or a TypeError naming fn: the mark functions answer for one team at a time."""
|
|
119
|
+
one, value = _scalar(team)
|
|
120
|
+
if not one:
|
|
121
|
+
raise TypeError(f"{fn}() takes one team, got {type(team).__name__}; use resolve() for several")
|
|
122
|
+
return value
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _unpack(values: Any) -> tuple[list[Any], Callable[[list[Any]], Any]]:
|
|
126
|
+
"""values as a list, plus a function that wraps a same-length result back into the caller's container."""
|
|
127
|
+
one, values = _scalar(values)
|
|
128
|
+
if one:
|
|
129
|
+
return [values], lambda out: out[0]
|
|
130
|
+
if isinstance(values, (list, tuple)):
|
|
131
|
+
return list(values), list
|
|
132
|
+
if hasattr(values, "tolist") and not hasattr(values, "to_list"): # numpy arrays
|
|
133
|
+
kind = getattr(values.dtype, "kind", "")
|
|
134
|
+
if kind in ("m", "M"):
|
|
135
|
+
import numpy as np # importable: values is a numpy array
|
|
136
|
+
|
|
137
|
+
if np.datetime_data(values.dtype)[0] in ("ns", "ps", "fs", "as"):
|
|
138
|
+
# tolist() turns sub-microsecond datetime64/timedelta64 (pandas' default) into integers
|
|
139
|
+
values = values.astype(f"{kind}8[us]")
|
|
140
|
+
return list(values.tolist()), list
|
|
141
|
+
return _unpack_series(values)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _unpack_series(values: Any) -> tuple[list[Any], Callable[[list[Any]], Any]]:
|
|
145
|
+
"""A pandas/polars (or any narwhals-supported) Series in; a same-library String Series of results out."""
|
|
146
|
+
import narwhals as nw
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
s = nw.from_native(values, series_only=True)
|
|
150
|
+
except TypeError as e:
|
|
151
|
+
raise TypeError(
|
|
152
|
+
f"resolve() takes a scalar, list, tuple, numpy array or a pandas/polars Series, got {type(values).__name__}"
|
|
153
|
+
) from e
|
|
154
|
+
backend = nw.get_native_namespace(s)
|
|
155
|
+
# Capture the original index for pandas-like Series
|
|
156
|
+
original_input = values
|
|
157
|
+
idx = nw.maybe_get_index(s)
|
|
158
|
+
|
|
159
|
+
def wrap_result(out: list[Any]) -> Any:
|
|
160
|
+
result = nw.new_series(s.name, out, nw.String(), backend=backend).to_native()
|
|
161
|
+
# Restore the index for pandas-like objects
|
|
162
|
+
if idx is not None and hasattr(result, "index"):
|
|
163
|
+
result.index = original_input.index
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
return s.to_list(), wrap_result
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _seasons(season: Any, n: int, league: str | None = None) -> list[int | None]:
|
|
170
|
+
one, season = _scalar(season)
|
|
171
|
+
if one or _is_na(season):
|
|
172
|
+
return [norm_season(season, league)] * n # the league's own bounds: one error naming them
|
|
173
|
+
try:
|
|
174
|
+
items, _ = _unpack(season)
|
|
175
|
+
except TypeError: # _unpack's message names values; this is the season argument
|
|
176
|
+
raise InputError(f"season must be a year, or one per team, got {type(season).__name__}") from None
|
|
177
|
+
if len(items) != n:
|
|
178
|
+
raise InputError(f"season has {len(items)} values but there are {n} teams")
|
|
179
|
+
return [norm_season(s, league) for s in items]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _report(unresolved: dict[str, str], league: str, strict: bool) -> None:
|
|
183
|
+
shown = ", ".join(f"{v!r} ({why})" for v, why in unresolved.items())
|
|
184
|
+
msg = f"{len(unresolved)} value(s) did not resolve to a {league} team: {shown}"
|
|
185
|
+
if any(why.startswith("ambiguous") for why in unresolved.values()):
|
|
186
|
+
# the candidates are listed: what picks one is a season (a reused code) or the id system the values come from
|
|
187
|
+
msg += "; pass season= for a code reused across eras, or id_system= for the id system of the values"
|
|
188
|
+
if strict:
|
|
189
|
+
raise UnresolvedTeamError(msg)
|
|
190
|
+
warn(msg + ". Use sdvplot.suggest() for candidates, or strict=True to raise.")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _ambiguous(team_ids: tuple[str, ...], league: str) -> str:
|
|
194
|
+
"""'ambiguous: 2390 Miami Hurricanes or 193 Miami (OH) RedHawks': the teams one value could mean."""
|
|
195
|
+
names = dict(_index.teams(league).select("team_id", "name").iter_rows())
|
|
196
|
+
return "ambiguous: " + " or ".join(f"{tid} {names.get(tid, '')}".strip() for tid in team_ids)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# The result comes back in the container the values came in (_unpack). The last overload takes what the others do not
|
|
200
|
+
# name: numpy scalars, pandas NA, other narwhals backends. pandas ships no types, so without pandas-stubs (as here)
|
|
201
|
+
# pd.Series is Any and the last overload is never reached: hence its ignore.
|
|
202
|
+
@overload
|
|
203
|
+
def resolve(
|
|
204
|
+
values: str | bytes | int | float | None,
|
|
205
|
+
league: str,
|
|
206
|
+
*,
|
|
207
|
+
season: Any = None,
|
|
208
|
+
id_system: IdSystem = "auto",
|
|
209
|
+
strict: bool = False,
|
|
210
|
+
) -> str | None: ...
|
|
211
|
+
@overload
|
|
212
|
+
def resolve(
|
|
213
|
+
values: pl.Series, league: str, *, season: Any = None, id_system: IdSystem = "auto", strict: bool = False
|
|
214
|
+
) -> pl.Series: ...
|
|
215
|
+
@overload
|
|
216
|
+
def resolve(
|
|
217
|
+
values: list[Any] | tuple[Any, ...] | np.ndarray[Any, Any],
|
|
218
|
+
league: str,
|
|
219
|
+
*,
|
|
220
|
+
season: Any = None,
|
|
221
|
+
id_system: IdSystem = "auto",
|
|
222
|
+
strict: bool = False,
|
|
223
|
+
) -> list[str | None]: ...
|
|
224
|
+
@overload
|
|
225
|
+
def resolve(
|
|
226
|
+
values: pd.Series, league: str, *, season: Any = None, id_system: IdSystem = "auto", strict: bool = False
|
|
227
|
+
) -> pd.Series: ...
|
|
228
|
+
@overload
|
|
229
|
+
def resolve( # type: ignore[overload-cannot-match]
|
|
230
|
+
values: Any, league: str, *, season: Any = None, id_system: IdSystem = "auto", strict: bool = False
|
|
231
|
+
) -> Any: ...
|
|
232
|
+
def resolve(values: Any, league: str, *, season: Any = None, id_system: IdSystem = "auto", strict: bool = False) -> Any:
|
|
233
|
+
"""Canonical team_id(s) for team values in one league.
|
|
234
|
+
|
|
235
|
+
Accepts ids and names from any supported source (ESPN, nflverse, MLB Stats, nba_api, HockeyTech, CFBD, sdvplotR) and
|
|
236
|
+
returns the bundled index's canonical team_id, in the same container the values came in. Values that do not resolve
|
|
237
|
+
come back as None with one SdvplotWarning, or raise with ``strict=True``.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
values: A scalar, list/tuple, numpy array, or pandas/polars Series of team identifiers in any supported id
|
|
241
|
+
system. NHL stats API ids overlap ESPN's, so "auto" never reads a number as one: pass
|
|
242
|
+
``id_system="nhl_id"`` for them (NHL tri-codes such as "NJD" resolve under "auto").
|
|
243
|
+
league: The SDV league key, e.g. "nfl", "cfb", "ohl". Required: the same abbreviation means different teams in
|
|
244
|
+
different leagues.
|
|
245
|
+
season: One season for all values, or one per value. Picks the right team for a reused code; without one,
|
|
246
|
+
a reused code means its current holder (KCA: the Royals, not the 1955-67 Kansas City Athletics).
|
|
247
|
+
id_system: "auto" (try the priority order) or one system name.
|
|
248
|
+
strict: Raise UnresolvedTeamError instead of warning when a value does not resolve.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
str | list | Series | None: The same shape as ``values``: a team_id string (or None), a list, or a Series of the
|
|
252
|
+
caller's library.
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
TypeError: If ``values`` is not a scalar, list, tuple, numpy array, or pandas/polars Series.
|
|
256
|
+
InputError: (a ValueError) If ``league`` or ``id_system`` is unknown, or ``season`` is neither a year nor a
|
|
257
|
+
list of them (a ``pd.Timestamp``, ``"2020-21"``), is outside the seasons sdvplot knows for the league
|
|
258
|
+
(from its first dated season in the bundled index, 1920 for the NFL, to next year), or is a list whose
|
|
259
|
+
length does not match the teams.
|
|
260
|
+
UnresolvedTeamError: If ``strict=True`` and a value does not resolve.
|
|
261
|
+
|
|
262
|
+
Example:
|
|
263
|
+
::
|
|
264
|
+
|
|
265
|
+
import sdvplot
|
|
266
|
+
|
|
267
|
+
sdvplot.resolve("LV", "nfl") # '13'
|
|
268
|
+
sdvplot.resolve(["KC", "SF"], "nfl") # ['12', '25']
|
|
269
|
+
|
|
270
|
+
See Also:
|
|
271
|
+
sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
|
|
272
|
+
sdv-py: https://py.sportsdataverse.org/
|
|
273
|
+
"""
|
|
274
|
+
_index.check_league(league)
|
|
275
|
+
_systems(id_system)
|
|
276
|
+
items, wrap = _unpack(values)
|
|
277
|
+
out, unresolved = _resolve_ids(items, league, _seasons(season, len(items), league), id_system)
|
|
278
|
+
if unresolved:
|
|
279
|
+
_report(unresolved, league, strict)
|
|
280
|
+
return wrap(out)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _systems(id_system: str) -> tuple[str, ...]:
|
|
284
|
+
if id_system != "auto" and id_system not in PRIORITY + EXPLICIT_ONLY:
|
|
285
|
+
raise InputError(f"unknown id_system {id_system!r}; use 'auto' or one of {list(PRIORITY + EXPLICIT_ONLY)}")
|
|
286
|
+
return PRIORITY if id_system == "auto" else (id_system,)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _resolve_ids(
|
|
290
|
+
items: list[Any], league: str, seasons: list[int | None], id_system: str
|
|
291
|
+
) -> tuple[list[str | None], dict[str, str]]:
|
|
292
|
+
"""resolve() without the report: the ids (None where a value does not resolve), and each unresolved value with
|
|
293
|
+
why ("unknown", or "ambiguous: " and the teams it could mean). The caller decides whether to warn."""
|
|
294
|
+
_index.check_league(league)
|
|
295
|
+
systems = _systems(id_system)
|
|
296
|
+
for year in {s for s in seasons if s is not None}: # every season path resolves here: the league's own range
|
|
297
|
+
check_season(year, league)
|
|
298
|
+
table, latest = _lookup(league), _latest(league)
|
|
299
|
+
out: list[str | None] = []
|
|
300
|
+
unresolved: dict[str, str] = {}
|
|
301
|
+
memo: dict[tuple[str, int | None], Any] = {}
|
|
302
|
+
for value, s in zip(items, seasons, strict=True):
|
|
303
|
+
key = norm_value(value)
|
|
304
|
+
if key is None:
|
|
305
|
+
out.append(None)
|
|
306
|
+
continue
|
|
307
|
+
if (key, s) not in memo:
|
|
308
|
+
memo[(key, s)] = _match(key, s, systems, table, latest)
|
|
309
|
+
hit = memo[(key, s)]
|
|
310
|
+
if hit is None or isinstance(hit, tuple):
|
|
311
|
+
unresolved[str(value)] = "unknown" if hit is None else _ambiguous(hit, league)
|
|
312
|
+
out.append(None)
|
|
313
|
+
else:
|
|
314
|
+
out.append(hit)
|
|
315
|
+
return out, unresolved
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def suggest(value: Any, league: str, *, n: int = 5) -> list[tuple[str, str]]:
|
|
319
|
+
"""Up to n (team_id, name) candidates for a value that did not resolve, best first.
|
|
320
|
+
|
|
321
|
+
It never picks one for you: similar names can be different teams ("Bethany (KS)" and "Bethany (WV)").
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
value: The team value that failed to resolve.
|
|
325
|
+
league: The SDV league key, e.g. "nfl".
|
|
326
|
+
n: The most candidates to return.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
list[tuple[str, str]]: ``(team_id, name)`` pairs, best match first; empty when nothing is close.
|
|
330
|
+
|
|
331
|
+
Raises:
|
|
332
|
+
InputError: (a ValueError) If ``league`` is unknown, or ``n`` is not an int of at least 1.
|
|
333
|
+
|
|
334
|
+
Example:
|
|
335
|
+
::
|
|
336
|
+
|
|
337
|
+
import sdvplot
|
|
338
|
+
|
|
339
|
+
sdvplot.suggest("Kansas Cty Chiefs", "nfl", n=2) # [('12', 'Kansas City Chiefs')]
|
|
340
|
+
|
|
341
|
+
See Also:
|
|
342
|
+
sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
|
|
343
|
+
sdv-py: https://py.sportsdataverse.org/
|
|
344
|
+
"""
|
|
345
|
+
import difflib
|
|
346
|
+
|
|
347
|
+
_index.check_league(league)
|
|
348
|
+
if isinstance(n, bool) or not isinstance(n, numbers.Integral) or n < 1:
|
|
349
|
+
raise InputError(f"n is the most candidates to return, an int of at least 1, got {n!r}")
|
|
350
|
+
key = norm_value(value)
|
|
351
|
+
if key is None:
|
|
352
|
+
return []
|
|
353
|
+
merged: dict[str, list[str]] = {}
|
|
354
|
+
for system in _lookup(league).values():
|
|
355
|
+
for k, cands in system.items():
|
|
356
|
+
merged.setdefault(k, []).extend(c[0] for c in cands)
|
|
357
|
+
names = dict(_index.teams(league).select("team_id", "name").iter_rows())
|
|
358
|
+
out: list[tuple[str, str]] = []
|
|
359
|
+
for k in difflib.get_close_matches(key, list(merged), n=n * 3, cutoff=0.6):
|
|
360
|
+
for tid in merged[k]:
|
|
361
|
+
if tid not in {t for t, _ in out}:
|
|
362
|
+
out.append((tid, names.get(tid, tid)))
|
|
363
|
+
return out[:n]
|
sdvplot/_surface.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""sportypy playing surfaces in team colors: the port of sdvplotR's sdv_surface()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import importlib
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from sdvplot._colors import team_colors
|
|
11
|
+
from sdvplot._contrast import contrast, on_color
|
|
12
|
+
from sdvplot._errors import InputError, OptionalDependencyError
|
|
13
|
+
|
|
14
|
+
# SDV league -> (sportypy.surfaces module, class). Always the sport module: the top-level sportypy.surfaces.NCAACourt
|
|
15
|
+
# is the tennis court and NCAAField the football field, shadowing the basketball court and the baseball field.
|
|
16
|
+
SURFACES: dict[str, tuple[str, str]] = {
|
|
17
|
+
"nfl": ("football", "NFLField"),
|
|
18
|
+
"aaf": ("football", "NFLField"),
|
|
19
|
+
"ufl": ("football", "NFLField"),
|
|
20
|
+
"usfl": ("football", "NFLField"),
|
|
21
|
+
"xfl": ("football", "NFLField"),
|
|
22
|
+
"cfb": ("football", "NCAAField"),
|
|
23
|
+
"nba": ("basketball", "NBACourt"),
|
|
24
|
+
"nbagl": ("basketball", "NBAGLeagueCourt"),
|
|
25
|
+
"wnba": ("basketball", "WNBACourt"),
|
|
26
|
+
"mbb": ("basketball", "NCAACourt"),
|
|
27
|
+
"wbb": ("basketball", "NCAACourt"),
|
|
28
|
+
"mlb": ("baseball", "MLBField"),
|
|
29
|
+
"milb": ("baseball", "MiLBField"),
|
|
30
|
+
"ncaa_baseball": ("baseball", "NCAAField"),
|
|
31
|
+
"nhl": ("hockey", "NHLRink"),
|
|
32
|
+
"whl": ("hockey", "NHLRink"),
|
|
33
|
+
"pwhl": ("hockey", "NHLRink"),
|
|
34
|
+
"ahl": ("hockey", "AHLRink"),
|
|
35
|
+
"echl": ("hockey", "ECHLRink"),
|
|
36
|
+
"ohl": ("hockey", "OHLRink"),
|
|
37
|
+
"qmjhl": ("hockey", "QMJHLRink"),
|
|
38
|
+
"ushl": ("hockey", "USHLRink"),
|
|
39
|
+
"phf": ("hockey", "PHFRink"),
|
|
40
|
+
"ncaa_mhockey": ("hockey", "NCAARink"),
|
|
41
|
+
"ncaa_whockey": ("hockey", "NCAARink"),
|
|
42
|
+
"soccer": ("soccer", "FIFAPitch"),
|
|
43
|
+
}
|
|
44
|
+
SURFACE_BASE = {"basketball": "#d2ab6f", "football": "#196f0c", "hockey": "#ffffff"} # sportyR's default colors
|
|
45
|
+
_DRAW_KWARGS = ("display_range", "xlim", "ylim", "rotation")
|
|
46
|
+
_NUMBER_FONT = "Clarendon-Regular" # sportypy's football yard-line number font (data/surface_dimensions.json)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _has_font(family: str) -> bool:
|
|
50
|
+
"""Whether matplotlib can find ``family`` itself; asking with no fallback logs nothing."""
|
|
51
|
+
from matplotlib import font_manager
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
font_manager.findfont(font_manager.FontProperties(family=family), fallback_to_default=False)
|
|
55
|
+
except ValueError:
|
|
56
|
+
return False
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@contextlib.contextmanager
|
|
61
|
+
def _polygon_limits(ax: Any) -> Iterator[None]:
|
|
62
|
+
"""Within the block, ``ax.add_patch`` takes a polygon's data limits from its vertices in one call.
|
|
63
|
+
|
|
64
|
+
matplotlib walks every segment of an added patch as a Bezier curve to find its extrema, and sportypy draws its
|
|
65
|
+
circles and arcs as 10,000-point polygons: a rink or court walked ~1.3 M segments, 16-19 s. A straight segment's
|
|
66
|
+
extrema are its two ends, so a polygon's vertices are exactly the points matplotlib's walk measures (the CLOSEPOLY
|
|
67
|
+
slot is not a point; one vertex alone is no segment). Anything else (a curve, a NaN, a transform not in data on
|
|
68
|
+
both axes, a non-rectilinear Axes) goes through matplotlib's own walk.
|
|
69
|
+
"""
|
|
70
|
+
import numpy as np
|
|
71
|
+
from matplotlib.patches import Polygon
|
|
72
|
+
from matplotlib.path import Path
|
|
73
|
+
|
|
74
|
+
straight = (Path.LINETO, Path.CLOSEPOLY)
|
|
75
|
+
|
|
76
|
+
walk = ax._update_patch_limits # the Axes' own updater, or one set on it before this block
|
|
77
|
+
own = vars(ax).get("_update_patch_limits")
|
|
78
|
+
|
|
79
|
+
def update(patch: Any) -> None:
|
|
80
|
+
path, transform = patch.get_path(), patch.get_transform()
|
|
81
|
+
codes, vertices = path.codes, path.vertices
|
|
82
|
+
if not (
|
|
83
|
+
isinstance(patch, Polygon)
|
|
84
|
+
and len(vertices) > 1
|
|
85
|
+
and (codes is None or (codes[0] == Path.MOVETO and np.isin(codes[1:], straight).all()))
|
|
86
|
+
and np.isfinite(vertices).all()
|
|
87
|
+
and ax.name == "rectilinear"
|
|
88
|
+
and transform.contains_branch(ax.transData)
|
|
89
|
+
):
|
|
90
|
+
walk(patch)
|
|
91
|
+
return
|
|
92
|
+
points = vertices if codes is None else vertices[codes != Path.CLOSEPOLY]
|
|
93
|
+
ax.update_datalim((transform - ax.transData).transform(points))
|
|
94
|
+
|
|
95
|
+
ax._update_patch_limits = update
|
|
96
|
+
try:
|
|
97
|
+
yield
|
|
98
|
+
finally:
|
|
99
|
+
if own is None:
|
|
100
|
+
del ax._update_patch_limits
|
|
101
|
+
else:
|
|
102
|
+
ax._update_patch_limits = own
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def color_updates(sport: str, primary: str, secondary: str | None) -> dict[str, str]:
|
|
106
|
+
"""sportypy ``color_updates`` for a team (sdvplotR's ``surface_color_updates()``): basketball paints the lane and
|
|
107
|
+
apron with a readable ink on top, football the end zones, hockey the center line, faceoff circle and spot and the
|
|
108
|
+
boards (the secondary color when the primary does not read on ice); baseball and soccer nothing."""
|
|
109
|
+
if sport == "basketball":
|
|
110
|
+
ink = on_color(primary)
|
|
111
|
+
return {
|
|
112
|
+
"painted_area": primary,
|
|
113
|
+
"court_apron": primary,
|
|
114
|
+
"restricted_arc": ink,
|
|
115
|
+
"free_throw_circle_dash": ink,
|
|
116
|
+
"lane_lower_defensive_box": ink,
|
|
117
|
+
"baseline_lower_defensive_box": ink,
|
|
118
|
+
}
|
|
119
|
+
if sport == "football":
|
|
120
|
+
return {"offensive_endzone": primary, "defensive_endzone": primary}
|
|
121
|
+
if sport == "hockey":
|
|
122
|
+
ice = SURFACE_BASE["hockey"]
|
|
123
|
+
readable = secondary is not None and contrast(primary, ice) < 3 and contrast(secondary, ice) >= 3
|
|
124
|
+
accent = secondary if readable and secondary is not None else primary
|
|
125
|
+
return {
|
|
126
|
+
"center_line": accent,
|
|
127
|
+
"center_faceoff_circle": accent,
|
|
128
|
+
"center_faceoff_spot": accent,
|
|
129
|
+
"boards": primary,
|
|
130
|
+
}
|
|
131
|
+
return {}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def surface(
|
|
135
|
+
league: str,
|
|
136
|
+
team: Any = None,
|
|
137
|
+
*,
|
|
138
|
+
season: Any = None,
|
|
139
|
+
ax: Any = None,
|
|
140
|
+
center_logo: bool | float = False,
|
|
141
|
+
**sportypy_kwargs: Any,
|
|
142
|
+
) -> Any:
|
|
143
|
+
"""Draw the league's playing surface with sportypy, in a team's colors.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
league: The SDV league key, e.g. "nfl", "nba", "nhl", "cfb", "soccer".
|
|
147
|
+
team: A team to color the surface by (end zones, lane and apron, center line and boards); None for the plain
|
|
148
|
+
surface.
|
|
149
|
+
season: The season, for teams whose colors changed.
|
|
150
|
+
ax: The matplotlib Axes to draw on; None makes a new figure.
|
|
151
|
+
center_logo: True to draw the team's logo at center ice / court / field (0.25 of the Axes height), or a
|
|
152
|
+
height fraction.
|
|
153
|
+
**sportypy_kwargs: Passed to sportypy: ``display_range``, ``xlim``, ``ylim`` and ``rotation`` go to
|
|
154
|
+
``draw()``; the rest (``field_updates``, ``color_updates``, ``units``, ...) to the surface class.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
matplotlib.axes.Axes: The Axes sportypy drew on.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
InputError: (a ValueError) If sportypy has no surface for ``league``; and, when ``team`` is given, if
|
|
161
|
+
``season`` is not a year or is outside the seasons sdvplot knows for the league, or ``center_logo`` is a
|
|
162
|
+
height outside (0, 1]. Without ``team``, ``season`` and ``center_logo`` are not used.
|
|
163
|
+
OptionalDependencyError: If the surfaces extra (sportypy) is not installed, or ``team`` and ``center_logo`` are
|
|
164
|
+
set and the team's logo is an SVG without the ``svg`` extra.
|
|
165
|
+
OfflineError: If ``team`` and ``center_logo`` are set and the logo manifest or the team's logo is neither
|
|
166
|
+
cached nor downloadable (a DownloadError, also an OSError, when the CDN answers with an error status; an
|
|
167
|
+
IntegrityError when it sends a file that does not match the manifest's sha256, or one PIL cannot decode).
|
|
168
|
+
UnsafeDownloadError: (an OSError) If ``team`` and ``center_logo`` are set and a download is refused: larger
|
|
169
|
+
than the byte cap, past the deadline, or redirected away from https.
|
|
170
|
+
UnsafeCachePathError: (a ValueError) If ``team`` and ``center_logo`` are set and the manifest's sha256 or
|
|
171
|
+
extension for the logo would put the file outside the cache directory.
|
|
172
|
+
|
|
173
|
+
Example:
|
|
174
|
+
::
|
|
175
|
+
|
|
176
|
+
import sdvplot
|
|
177
|
+
|
|
178
|
+
ax = sdvplot.surface("nfl", "KC", center_logo=True)
|
|
179
|
+
|
|
180
|
+
See Also:
|
|
181
|
+
sdvplotR sdv_surface(): https://sdvplotR.sportsdataverse.org/ ;
|
|
182
|
+
sportypy: https://sportypy.sportsdataverse.org/
|
|
183
|
+
"""
|
|
184
|
+
if league not in SURFACES:
|
|
185
|
+
raise InputError(f"no sportypy surface for league {league!r}; supported: {sorted(SURFACES)}")
|
|
186
|
+
sport, cls_name = SURFACES[league]
|
|
187
|
+
try:
|
|
188
|
+
module = importlib.import_module(f"sportypy.surfaces.{sport}")
|
|
189
|
+
except ModuleNotFoundError as e:
|
|
190
|
+
raise OptionalDependencyError(
|
|
191
|
+
"sdvplot.surface() needs the surfaces extra: pip install sdvplot[surfaces]"
|
|
192
|
+
) from e
|
|
193
|
+
draw_kwargs = {k: sportypy_kwargs.pop(k) for k in _DRAW_KWARGS if k in sportypy_kwargs}
|
|
194
|
+
if sport == "football" and not _has_font(_NUMBER_FONT):
|
|
195
|
+
# sportypy numbers football fields in Clarendon-Regular, which it does not ship: matplotlib then logs "findfont:
|
|
196
|
+
# Font family 'Clarendon-Regular' not found" for every number it measures and draws its default font anyway.
|
|
197
|
+
# Naming that default draws the same numbers (bar sportypy's Clarendon-only nudge of one "1") and logs nothing.
|
|
198
|
+
sportypy_kwargs["field_updates"] = {"number_font": "DejaVu Sans", **sportypy_kwargs.get("field_updates", {})}
|
|
199
|
+
if team is not None:
|
|
200
|
+
primary, secondary = (team_colors(league, [team], which=w, season=season)[0] for w in ("primary", "secondary"))
|
|
201
|
+
if primary is not None:
|
|
202
|
+
sportypy_kwargs["color_updates"] = {
|
|
203
|
+
**color_updates(sport, primary, secondary),
|
|
204
|
+
**sportypy_kwargs.get("color_updates", {}),
|
|
205
|
+
}
|
|
206
|
+
drawing = getattr(module, cls_name)(**sportypy_kwargs)
|
|
207
|
+
if ax is None: # as sportypy's draw() does, so the limits shortcut has an Axes to go on
|
|
208
|
+
import matplotlib.pyplot as plt
|
|
209
|
+
|
|
210
|
+
fig, ax = plt.subplots()
|
|
211
|
+
fig.patch.set_facecolor(drawing.feature_colors["plot_background"])
|
|
212
|
+
with _polygon_limits(ax):
|
|
213
|
+
drawn = drawing.draw(ax=ax, **draw_kwargs)
|
|
214
|
+
if center_logo and team is not None:
|
|
215
|
+
from sdvplot.matplotlib import add_logos
|
|
216
|
+
|
|
217
|
+
top = max((a.get_zorder() for a in drawn.get_children()), default=0)
|
|
218
|
+
add_logos(drawn, [0.0], [0.0], [team], league=league, season=season, zorder=top + 1,
|
|
219
|
+
height=0.25 if center_logo is True else float(center_logo)) # fmt: skip
|
|
220
|
+
return drawn
|
sdvplot/_tables.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""What the table adapters share (sdvplot.great_tables, sdvplot.reactable): pixel heights and a mark's ``<img>`` markup.
|
|
2
|
+
|
|
3
|
+
No table library is imported here, so either adapter works without the other installed.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import html
|
|
9
|
+
import math
|
|
10
|
+
import numbers
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import polars as pl
|
|
14
|
+
|
|
15
|
+
from sdvplot import _index
|
|
16
|
+
from sdvplot._errors import InputError
|
|
17
|
+
from sdvplot._normalize import norm_season
|
|
18
|
+
from sdvplot._placement import place
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _position(row: Any) -> Any:
|
|
22
|
+
return int(row) if isinstance(row, numbers.Integral) and not isinstance(row, bool) else row
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def row_positions(rows: Any) -> Any:
|
|
26
|
+
"""A great_tables row selection with numpy integers made plain ``int``s.
|
|
27
|
+
|
|
28
|
+
great_tables' row resolver silently skips any position that is not an ``int``, so ``rows=[np.int64(1)]`` (or a
|
|
29
|
+
numpy array of positions) would select nothing. Expressions, callables and ``None`` pass through unchanged.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(rows, numbers.Integral) and not isinstance(rows, bool):
|
|
32
|
+
return [int(rows)]
|
|
33
|
+
if getattr(rows, "ndim", None) == 1 and hasattr(rows, "tolist"): # a numpy array of positions
|
|
34
|
+
rows = rows.tolist()
|
|
35
|
+
if isinstance(rows, (list, tuple)):
|
|
36
|
+
return [_position(r) for r in rows]
|
|
37
|
+
return rows
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_px(height: Any) -> float:
|
|
41
|
+
"""``height`` as a float, or ValueError unless it is a finite number of pixels, at least 1."""
|
|
42
|
+
if isinstance(height, bool) or not isinstance(height, numbers.Real) or not math.isfinite(height) or height <= 0:
|
|
43
|
+
raise InputError(f"height is the image height in pixels, a number > 0, got {height!r}")
|
|
44
|
+
if height < 1: # a plot's height is a fraction of the plot; a table's is pixels, so 0.1 would draw a 0.1 px image
|
|
45
|
+
raise InputError(
|
|
46
|
+
f"height is the image height in pixels for a table (such as 30), got {height!r}; a fraction of the plot "
|
|
47
|
+
"height is the unit for plots, not tables"
|
|
48
|
+
)
|
|
49
|
+
return float(height)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def img_tag(src: str, height: float, alt: str, *, team: str | None = None, margin: bool = False) -> str:
|
|
53
|
+
"""An ``<img>`` ``height`` pixels tall; ``team`` adds the ``data-sdvplot-team`` attribute the test hooks read."""
|
|
54
|
+
style = f"height:{height:g}px;vertical-align:middle" + (";margin-right:0.35em" if margin else "")
|
|
55
|
+
data = f' data-sdvplot-team="{html.escape(team)}"' if team is not None else ""
|
|
56
|
+
return f'<img src="{html.escape(src)}" style="{style}" alt="{html.escape(alt)}"{data}>'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def mark_html(
|
|
60
|
+
values: list[Any],
|
|
61
|
+
*,
|
|
62
|
+
league: str,
|
|
63
|
+
kind: str,
|
|
64
|
+
height: float,
|
|
65
|
+
season: Any = None,
|
|
66
|
+
variant: str = "default",
|
|
67
|
+
id_system: str = "auto",
|
|
68
|
+
strict: bool = False,
|
|
69
|
+
include_name: bool = False,
|
|
70
|
+
) -> list[str | None]:
|
|
71
|
+
"""The ``<img>`` of each value's mark, or None where there is none (one SdvplotWarning for those, from place()).
|
|
72
|
+
|
|
73
|
+
``season`` is one season for every value (sdvplotR's rule for tables); a list of seasons is a ValueError. The alt
|
|
74
|
+
text is the team's name from the bundled index (the player id for headshots).
|
|
75
|
+
"""
|
|
76
|
+
s = norm_season(season)
|
|
77
|
+
n = len(values)
|
|
78
|
+
placed = place(list(range(n)), [0] * n, values, league=league, season=s, kind=kind, variant=variant,
|
|
79
|
+
id_system=id_system, strict=strict) # fmt: skip
|
|
80
|
+
names: dict[str, str] = {}
|
|
81
|
+
if kind != "headshot":
|
|
82
|
+
rows = _index.team_table().filter(pl.col("league") == league).select("team_id", "name").iter_rows()
|
|
83
|
+
names = {tid: name for tid, name in rows if name}
|
|
84
|
+
out: list[str | None] = [None] * n
|
|
85
|
+
for p in placed:
|
|
86
|
+
out[p.x] = img_tag(p.url, height, names.get(p.team_id, p.team_id), team=p.team_id, margin=include_name)
|
|
87
|
+
return out
|