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/_marks.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Choosing a team's mark from the manifest: variant, then season within each variant, then source preference."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import weakref
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from sdvplot import _index
|
|
9
|
+
from sdvplot._cache import MEMORY_CACHES
|
|
10
|
+
from sdvplot._errors import InputError, UnresolvedTeamError, warn
|
|
11
|
+
from sdvplot._manifest import load_manifest
|
|
12
|
+
from sdvplot._normalize import norm_season
|
|
13
|
+
from sdvplot._resolve import _covers, one_team, resolve
|
|
14
|
+
from sdvplot._types import IdSystem, MarkType
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
import polars as pl
|
|
18
|
+
else:
|
|
19
|
+
from sdvplot._lazy import pl
|
|
20
|
+
|
|
21
|
+
# Official sources first, then archived copies, then derived crops (sdv-assets source names)
|
|
22
|
+
SOURCE_RANK: dict[str, int] = {
|
|
23
|
+
"espn": 0,
|
|
24
|
+
"nflverse": 1,
|
|
25
|
+
"nhl": 1,
|
|
26
|
+
"mlbstatic": 1,
|
|
27
|
+
"fox": 1,
|
|
28
|
+
"hockeytech": 2,
|
|
29
|
+
"cricinfo": 2,
|
|
30
|
+
"ncaa.com": 3,
|
|
31
|
+
"nwhl.co": 4,
|
|
32
|
+
"shiftstats": 4,
|
|
33
|
+
"wayback": 4,
|
|
34
|
+
"aaf.com": 5,
|
|
35
|
+
"aaf-strip-crop": 6,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
MARK_TYPES = ("logo", "wordmark")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _check_mark_type(mark_type: str) -> None:
|
|
43
|
+
if mark_type not in MARK_TYPES:
|
|
44
|
+
raise InputError(f"mark_type must be one of {list(MARK_TYPES)}, got {mark_type!r}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# (the manifest frame, every variant it holds): rebuilt when a refreshed manifest is a new object, as _RANKED is. The
|
|
48
|
+
# frame is held weakly here and in _RANKED, so a refreshed or cleared one is freed (_manifest keeps only the latest)
|
|
49
|
+
_VARIANTS: list[tuple[weakref.ref[pl.DataFrame], frozenset[str]]] = []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _check_variant(variant: str, league: str) -> None:
|
|
53
|
+
"""A variant no mark in the archive has is a typo, not one this team lacks (that falls back): ValueError listing
|
|
54
|
+
the league's variants. "default" and "dark" are always valid."""
|
|
55
|
+
if variant in ("default", "dark"):
|
|
56
|
+
return
|
|
57
|
+
manifest = load_manifest()
|
|
58
|
+
if not _VARIANTS or _VARIANTS[0][0]() is not manifest:
|
|
59
|
+
_VARIANTS[:] = [(weakref.ref(manifest), frozenset(manifest["variant"].drop_nulls().to_list()))]
|
|
60
|
+
if not isinstance(variant, str) or variant not in _VARIANTS[0][1]: # a list would be unhashable in the set
|
|
61
|
+
_index.check_league(league)
|
|
62
|
+
known = sorted({"default", "dark", *_ranked(league)["variant"].drop_nulls().to_list()})
|
|
63
|
+
raise InputError(f"unknown variant {variant!r}: no mark in the archive has it; {league} marks come in {known}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _union(col: str, bound: pl.Expr) -> pl.Expr:
|
|
67
|
+
"""One side of the union of several ranges: null (unbounded) if any range is unbounded there."""
|
|
68
|
+
return pl.when(pl.col(col).is_null().any()).then(None).otherwise(bound).alias(f"_alias_{col}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _mark_aliases(league: str) -> pl.DataFrame:
|
|
72
|
+
"""key ("source:entity_id", normalized like every alias value) -> canonical team_id, unique mappings only, with
|
|
73
|
+
the season range of the mapping (R36: an old-abbreviation mark is dated by its relocation alias)."""
|
|
74
|
+
return (
|
|
75
|
+
_index.alias_table()
|
|
76
|
+
.filter((pl.col("league") == league) & (pl.col("id_system") == "mark"))
|
|
77
|
+
.with_columns(pl.col("value").str.strip_chars().str.to_lowercase().alias("_key"))
|
|
78
|
+
.group_by("_key")
|
|
79
|
+
.agg(
|
|
80
|
+
pl.col("team_id").first(),
|
|
81
|
+
pl.col("team_id").n_unique().alias("_n"),
|
|
82
|
+
_union("valid_from", pl.col("valid_from").min()),
|
|
83
|
+
_union("valid_to", pl.col("valid_to").max()),
|
|
84
|
+
)
|
|
85
|
+
.filter(pl.col("_n") == 1)
|
|
86
|
+
.drop("_n")
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# league -> (the loaded manifest frame, weakly; its rows mapped and ranked). _manifest caches that frame per
|
|
91
|
+
# path+mtime, so a refreshed manifest is a new object and rebuilds; an index reload clears it (R45)
|
|
92
|
+
_RANKED: dict[str, tuple[weakref.ref[pl.DataFrame], pl.DataFrame]] = {}
|
|
93
|
+
_index.on_reload(_RANKED.clear)
|
|
94
|
+
# league -> (its ranked table, that table's rows as dicts by team_id, filled per team on first use): select_mark's
|
|
95
|
+
# lookup, a dict hit instead of a polars filter per call
|
|
96
|
+
_TEAM_ROWS: dict[str, tuple[pl.DataFrame, dict[str, list[dict[str, Any]]]]] = {}
|
|
97
|
+
for _clear in (_VARIANTS.clear, _RANKED.clear, _TEAM_ROWS.clear):
|
|
98
|
+
MEMORY_CACHES.append(_clear) # clear_cache() also drops what was built from the manifest it deletes
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ranked(league: str) -> pl.DataFrame:
|
|
102
|
+
"""Every team-level manifest row of one league that maps to one canonical team, with its effective range,
|
|
103
|
+
best first; marks() only filters it on team_id."""
|
|
104
|
+
manifest = load_manifest()
|
|
105
|
+
hit = _RANKED.get(league)
|
|
106
|
+
if hit is not None and hit[0]() is manifest:
|
|
107
|
+
return hit[1]
|
|
108
|
+
m = (
|
|
109
|
+
manifest.filter((pl.col("level") == "team") & (pl.col("league") == league))
|
|
110
|
+
.with_columns(
|
|
111
|
+
pl.concat_str(pl.col("source"), pl.lit(":"), pl.col("entity_id"))
|
|
112
|
+
.str.strip_chars()
|
|
113
|
+
.str.to_lowercase()
|
|
114
|
+
.alias("_key")
|
|
115
|
+
)
|
|
116
|
+
.join(_mark_aliases(league), on="_key", how="inner")
|
|
117
|
+
.with_columns( # the intersection of the row's range and its alias's (null = unbounded on that side)
|
|
118
|
+
pl.max_horizontal("valid_from", "_alias_valid_from").alias("valid_from"),
|
|
119
|
+
pl.min_horizontal("valid_to", "_alias_valid_to").alias("valid_to"),
|
|
120
|
+
)
|
|
121
|
+
.drop("_key", "_alias_valid_from", "_alias_valid_to")
|
|
122
|
+
)
|
|
123
|
+
ranked = (
|
|
124
|
+
m.with_columns(
|
|
125
|
+
pl.col("source").replace_strict(SOURCE_RANK, default=5, return_dtype=pl.Int8).alias("source_rank"),
|
|
126
|
+
pl.col("valid_to").is_null().alias("_open"),
|
|
127
|
+
)
|
|
128
|
+
.sort(
|
|
129
|
+
by=["source_rank", "_open", "valid_to", "first_seen", "valid_from", "sha256"],
|
|
130
|
+
descending=[False, True, True, True, True, False],
|
|
131
|
+
nulls_last=True,
|
|
132
|
+
)
|
|
133
|
+
.drop("_open")
|
|
134
|
+
)
|
|
135
|
+
_RANKED[league] = (weakref.ref(manifest), ranked)
|
|
136
|
+
return ranked
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _team_rows(team_id: str, league: str) -> list[dict[str, Any]]:
|
|
140
|
+
"""``_ranked(league)``'s rows for one team as dicts, best first; computed once per team per manifest load."""
|
|
141
|
+
ranked = _ranked(league)
|
|
142
|
+
hit = _TEAM_ROWS.get(league)
|
|
143
|
+
if hit is None or hit[0] is not ranked:
|
|
144
|
+
hit = _TEAM_ROWS[league] = (ranked, {})
|
|
145
|
+
if team_id not in hit[1]:
|
|
146
|
+
hit[1][team_id] = ranked.filter(pl.col("team_id") == team_id).to_dicts()
|
|
147
|
+
return hit[1][team_id]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def marks(team: Any, league: str, *, season: Any = None, id_system: IdSystem = "auto") -> pl.DataFrame:
|
|
151
|
+
"""Every archived mark for one team, best first.
|
|
152
|
+
|
|
153
|
+
Manifest entity ids are per-source, so rows reach a team only through its "mark" aliases; rows without a unique
|
|
154
|
+
mapping are dropped, never matched on the raw id. ``valid_from``/``valid_to`` are each row's effective range: the
|
|
155
|
+
manifest's, narrowed by the mark alias's (an open side takes the alias's).
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
team: One team identifier (abbreviation, name, ESPN id, ...).
|
|
159
|
+
league: The SDV league key, e.g. "nfl".
|
|
160
|
+
season: A season year, used to resolve a reused code.
|
|
161
|
+
id_system: "auto" or one id-system name, as in ``resolve``.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
polars.DataFrame: One row per archived mark (logos and wordmarks, every variant and source) with its
|
|
165
|
+
``variant``, ``mark_type``, ``archive_url`` and ``source_rank``.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
TypeError: If ``team`` is not a single value.
|
|
169
|
+
InputError: (a ValueError) If ``league`` or ``id_system`` is unknown, or ``season`` is not a year or is
|
|
170
|
+
outside the seasons sdvplot knows for the league.
|
|
171
|
+
UnresolvedTeamError: (a ValueError) If the team is null or does not resolve.
|
|
172
|
+
OfflineError: If the logo manifest cannot be downloaded and no cached copy exists (a DownloadError, also an
|
|
173
|
+
OSError, when the CDN answers with an error status).
|
|
174
|
+
UnsafeDownloadError: (an OSError) If the manifest download is refused: larger than the byte cap, past the
|
|
175
|
+
deadline, or redirected away from https.
|
|
176
|
+
|
|
177
|
+
Example:
|
|
178
|
+
::
|
|
179
|
+
|
|
180
|
+
import sdvplot
|
|
181
|
+
|
|
182
|
+
sdvplot.marks("KC", "nfl").shape # (19, 21)
|
|
183
|
+
|
|
184
|
+
See Also:
|
|
185
|
+
sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
|
|
186
|
+
sdv-py: https://py.sportsdataverse.org/
|
|
187
|
+
"""
|
|
188
|
+
team_id = resolve(one_team(team, "marks"), league, season=season, id_system=id_system, strict=True)
|
|
189
|
+
if team_id is None: # a null team: strict resolve() lets nulls through as None
|
|
190
|
+
raise UnresolvedTeamError(f"marks() needs a team, got {team!r}")
|
|
191
|
+
return _ranked(league).filter(pl.col("team_id") == team_id)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def select_mark(
|
|
195
|
+
team: Any, league: str, season: Any = None, variant: str = "default", mark_type: str = "logo"
|
|
196
|
+
) -> dict[str, Any] | None:
|
|
197
|
+
"""The best mark: the requested variant; then, keeping its polarity (R44), "default" and an on_light variant
|
|
198
|
+
("on_light", "*_on_light"), or for "dark" an on_dark variant and then "default"; then any variant. Within each,
|
|
199
|
+
season-covering rows first (explicit ranges before open-ended ones), else any row; within a set, official
|
|
200
|
+
sources and current marks first. An unknown or ambiguous team gives None with the resolver's warning."""
|
|
201
|
+
_check_mark_type(mark_type)
|
|
202
|
+
s = norm_season(season, league)
|
|
203
|
+
_check_variant(variant, league)
|
|
204
|
+
team_id = resolve(one_team(team, "select_mark"), league, season=s)
|
|
205
|
+
if team_id is None:
|
|
206
|
+
return None
|
|
207
|
+
# a team has tens of rows: choosing in Python costs less than one polars filter per step (R45); the rows are
|
|
208
|
+
# shared with _TEAM_ROWS, so the chosen one is handed out as a copy
|
|
209
|
+
rows = [r for r in _team_rows(team_id, league) if r["mark_type"] == mark_type]
|
|
210
|
+
side = "dark" if variant == "dark" else "light"
|
|
211
|
+
|
|
212
|
+
def polarity(v: str) -> bool:
|
|
213
|
+
return v == f"on_{side}" or v.endswith(f"_on_{side}")
|
|
214
|
+
|
|
215
|
+
def requested(v: str) -> bool:
|
|
216
|
+
return v == variant
|
|
217
|
+
|
|
218
|
+
def default(v: str) -> bool:
|
|
219
|
+
return v == "default"
|
|
220
|
+
|
|
221
|
+
order = [requested, polarity, default] if side == "dark" else [requested, default, polarity]
|
|
222
|
+
# sorted best-first, so the first row of any set is its best
|
|
223
|
+
for keep in [*order, None]:
|
|
224
|
+
found = [r for r in rows if keep is None or keep(r["variant"])]
|
|
225
|
+
if s is not None:
|
|
226
|
+
covering = [r for r in found if _covers(r["valid_from"], r["valid_to"], s)]
|
|
227
|
+
dated = [r for r in covering if r["valid_from"] is not None or r["valid_to"] is not None]
|
|
228
|
+
for df in (dated, covering):
|
|
229
|
+
if df:
|
|
230
|
+
return dict(df[0])
|
|
231
|
+
if found:
|
|
232
|
+
return dict(found[0])
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def logo_url(
|
|
237
|
+
team: Any,
|
|
238
|
+
league: str,
|
|
239
|
+
*,
|
|
240
|
+
season: Any = None,
|
|
241
|
+
variant: str = "default",
|
|
242
|
+
mark_type: MarkType = "logo",
|
|
243
|
+
id_system: IdSystem = "auto",
|
|
244
|
+
strict: bool = False,
|
|
245
|
+
) -> str | None:
|
|
246
|
+
"""The CDN URL of a team's logo or wordmark, chosen for the season.
|
|
247
|
+
|
|
248
|
+
Picks the requested variant (falling back to a default or polarity variant), then within each variant the archived
|
|
249
|
+
mark whose season range covers ``season`` (relocated franchises get their era's mark), then the most
|
|
250
|
+
authoritative source. Unknown teams return None with one SdvplotWarning.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
team: One team identifier (abbreviation, name, ESPN id, ...).
|
|
254
|
+
league: The SDV league key, e.g. "nfl", "cfb", "nhl".
|
|
255
|
+
season: A season year; None picks the current mark.
|
|
256
|
+
variant: "default", "dark", or a named variant from ``marks()``.
|
|
257
|
+
mark_type: "logo" or "wordmark".
|
|
258
|
+
id_system: The id system of ``team``, as in ``resolve``: "auto" tries each in order; NHL stats ids need
|
|
259
|
+
"nhl_id".
|
|
260
|
+
strict: Raise UnresolvedTeamError instead of warning when the team does not resolve.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
str | None: The archive URL (content-addressed, immutable), or None when no mark exists.
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
TypeError: If ``team`` is not a single value.
|
|
267
|
+
InputError: (a ValueError) If ``league`` or ``id_system`` is unknown, ``mark_type`` is not "logo"/"wordmark",
|
|
268
|
+
``variant`` is a name no mark in the archive has (a typo; the message lists the league's variants), or
|
|
269
|
+
``season`` is not a year or is outside the seasons sdvplot knows for the league.
|
|
270
|
+
UnresolvedTeamError: (a ValueError) If ``strict=True`` and the team does not resolve.
|
|
271
|
+
OfflineError: If the logo manifest cannot be downloaded and no cached copy exists (a DownloadError, also an
|
|
272
|
+
OSError, when the CDN answers with an error status).
|
|
273
|
+
UnsafeDownloadError: (an OSError) If the manifest download is refused: larger than the byte cap, past the
|
|
274
|
+
deadline, or redirected away from https.
|
|
275
|
+
|
|
276
|
+
Example:
|
|
277
|
+
::
|
|
278
|
+
|
|
279
|
+
import sdvplot
|
|
280
|
+
|
|
281
|
+
sdvplot.logo_url("KC", "nfl") # 'https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/3d/3d77....png'
|
|
282
|
+
|
|
283
|
+
See Also:
|
|
284
|
+
sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
|
|
285
|
+
sdv-py: https://py.sportsdataverse.org/
|
|
286
|
+
"""
|
|
287
|
+
_check_mark_type(mark_type)
|
|
288
|
+
_check_variant(variant, league)
|
|
289
|
+
team_id = resolve(one_team(team, "logo_url"), league, season=season, id_system=id_system, strict=strict)
|
|
290
|
+
if team_id is None:
|
|
291
|
+
return None
|
|
292
|
+
row = select_mark(team_id, league, season, variant, mark_type)
|
|
293
|
+
if row is None:
|
|
294
|
+
warn(f"no {mark_type} archived for {team!r} ({league})")
|
|
295
|
+
return None
|
|
296
|
+
return str(row["archive_url"])
|
sdvplot/_normalize.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""How raw team values and seasons are compared: one normal form, so 14, "14", 14.0, "14.0" and " 14 " are equal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import numbers
|
|
7
|
+
import re
|
|
8
|
+
import unicodedata
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from sdvplot import _index
|
|
12
|
+
from sdvplot._errors import InputError
|
|
13
|
+
|
|
14
|
+
_FLOAT_ID = re.compile(r"-?\d+\.0+")
|
|
15
|
+
# typographic punctuation providers write inconsistently (sdvplotR fold_accents): curly apostrophes, en/em dashes
|
|
16
|
+
_PUNCT = str.maketrans({"\u2018": "'", "\u2019": "'", "\u2013": "-", "\u2014": "-"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_na(value: Any) -> bool:
|
|
20
|
+
return value is None or type(value).__name__ in ("NAType", "NaTType")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def norm_value(value: Any) -> str | None:
|
|
24
|
+
"""A team value as a comparison key: trimmed, accents and typographic punctuation folded, case-folded,
|
|
25
|
+
integral numbers without a decimal point."""
|
|
26
|
+
if _is_na(value):
|
|
27
|
+
return None
|
|
28
|
+
if isinstance(value, bool):
|
|
29
|
+
return str(value).casefold()
|
|
30
|
+
if isinstance(value, numbers.Integral):
|
|
31
|
+
return str(int(value))
|
|
32
|
+
if isinstance(value, numbers.Real):
|
|
33
|
+
f = float(value)
|
|
34
|
+
if math.isnan(f):
|
|
35
|
+
return None
|
|
36
|
+
return str(int(f)) if f.is_integer() else str(f)
|
|
37
|
+
s = str(value).strip()
|
|
38
|
+
if not s.isascii(): # "San José State" == "San Jose State": decompose, then drop the combining accents
|
|
39
|
+
s = "".join(c for c in unicodedata.normalize("NFKD", s.translate(_PUNCT)) if not unicodedata.combining(c))
|
|
40
|
+
s = s.casefold()
|
|
41
|
+
if _FLOAT_ID.fullmatch(s): # "13.0": an id that went through a float before it became text
|
|
42
|
+
s = s.split(".")[0]
|
|
43
|
+
return s or None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_SPLIT_SEASON = re.compile(r"\s*(\d{4})\s*[-/]\s*(\d{2}|\d{4})\s*")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_season(year: int, league: str | None = None) -> None:
|
|
50
|
+
"""InputError unless ``year`` is a season sdvplot knows for ``league`` (any league when None): the bounds come from
|
|
51
|
+
the bundled index (``_index.season_bounds``)."""
|
|
52
|
+
bounds = _index.season_bounds(league)
|
|
53
|
+
if bounds is not None and not bounds[0] <= year <= bounds[1]:
|
|
54
|
+
scope = "" if league is None else f" for {league}"
|
|
55
|
+
raise InputError(
|
|
56
|
+
f"season {year} is outside the seasons sdvplot knows{scope} ({bounds[0]} to {bounds[1]}); pass a year such "
|
|
57
|
+
"as 2020"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def norm_season(value: Any, league: str | None = None) -> int | None:
|
|
62
|
+
"""A season as an int year. Accepts 2020, 2020.0, "2020"; None/NaN mean no season; anything else is an error, and
|
|
63
|
+
so is a year outside the seasons the bundled index holds for ``league`` (any league when None)."""
|
|
64
|
+
if _is_na(value):
|
|
65
|
+
return None
|
|
66
|
+
year = None
|
|
67
|
+
if isinstance(value, numbers.Real) and not isinstance(value, bool):
|
|
68
|
+
f = float(value)
|
|
69
|
+
if math.isnan(f):
|
|
70
|
+
return None
|
|
71
|
+
if f.is_integer():
|
|
72
|
+
year = int(f)
|
|
73
|
+
elif isinstance(value, str) and value.strip().isdigit():
|
|
74
|
+
year = int(value.strip())
|
|
75
|
+
if year is None:
|
|
76
|
+
hint = ""
|
|
77
|
+
if isinstance(value, str) and (m := _SPLIT_SEASON.fullmatch(value)):
|
|
78
|
+
end = int(m.group(1)) + 1
|
|
79
|
+
hint = f"; for a split season pass its ending year ({end} for {value.strip()!r})"
|
|
80
|
+
raise InputError(f"season must be a year such as 2020, got {value!r}{hint}")
|
|
81
|
+
check_season(year, league)
|
|
82
|
+
return year
|
sdvplot/_placement.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""From the caller's (x, y, teams) to the marks an adapter draws: one validation and resolution step for every adapter.
|
|
2
|
+
|
|
3
|
+
Every adapter calls check_height, check_alpha and place, then only draws. That keeps the shared contract (resolution,
|
|
4
|
+
warn and skip, pandas/polars parity, height) in one place instead of one copy per library.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import numbers
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
14
|
+
|
|
15
|
+
from sdvplot._errors import InputError, warn
|
|
16
|
+
from sdvplot._headshots import headshot_url
|
|
17
|
+
from sdvplot._marks import select_mark
|
|
18
|
+
from sdvplot._normalize import norm_value
|
|
19
|
+
from sdvplot._resolve import _resolve_ids, _seasons, _unpack, resolve
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from sdvplot._types import HeadshotIdSystem, IdSystem
|
|
23
|
+
|
|
24
|
+
KINDS = ("logo", "wordmark", "headshot")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Placement:
|
|
29
|
+
"""One mark to draw: whose it is, where, and which image."""
|
|
30
|
+
|
|
31
|
+
team_id: str # canonical team id; the player id for headshots; the path for place_images
|
|
32
|
+
x: Any # the caller's x value (number, category or datetime), read positionally
|
|
33
|
+
y: Any
|
|
34
|
+
url: str # archive_url of the selected mark, or the headshot URL
|
|
35
|
+
aspect: float | None # width / height from the manifest row; None for headshots
|
|
36
|
+
mark: dict[str, Any] | None # the selected manifest row; None for headshots
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _real(value: Any) -> bool:
|
|
40
|
+
return isinstance(value, numbers.Real) and not isinstance(value, bool) and not math.isnan(value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def check_height(height: Any) -> float:
|
|
44
|
+
"""``height`` as a float, or ValueError unless it is a fraction of the plot height in (0, 1]."""
|
|
45
|
+
if not _real(height) or not 0 < height <= 1:
|
|
46
|
+
raise InputError(f"height is a fraction of the plot height in (0, 1], got {height!r}")
|
|
47
|
+
return float(height)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check_alpha(alpha: Any) -> float:
|
|
51
|
+
"""``alpha`` as a float, or ValueError unless it is an opacity in [0, 1]."""
|
|
52
|
+
if not _real(alpha) or not 0 <= alpha <= 1:
|
|
53
|
+
raise InputError(f"alpha is an opacity in [0, 1], got {alpha!r}")
|
|
54
|
+
return float(alpha)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _missing(value: Any) -> bool:
|
|
58
|
+
if value is None or type(value).__name__ in ("NAType", "NaTType"):
|
|
59
|
+
return True
|
|
60
|
+
return isinstance(value, float) and math.isnan(value)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _warn_skipped(reason: str, values: list[Any]) -> None:
|
|
64
|
+
if values:
|
|
65
|
+
shown = ", ".join(repr(v) for v in values[:10]) + (f" and {len(values) - 10} more" if len(values) > 10 else "")
|
|
66
|
+
warn(f"skipped {len(values)} point(s) {reason}: {shown}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def place(
|
|
70
|
+
x: Any,
|
|
71
|
+
y: Any,
|
|
72
|
+
teams: Any,
|
|
73
|
+
*,
|
|
74
|
+
league: str,
|
|
75
|
+
season: Any = None,
|
|
76
|
+
kind: str = "logo",
|
|
77
|
+
variant: str = "default",
|
|
78
|
+
id_system: str = "auto",
|
|
79
|
+
strict: bool = False,
|
|
80
|
+
_warn: bool = True,
|
|
81
|
+
) -> list[Placement]:
|
|
82
|
+
"""The marks to draw for each (x, y, team), in input order, skipping (with one warning per reason) the points
|
|
83
|
+
whose team is unknown, whose x or y is missing, or that have no mark.
|
|
84
|
+
|
|
85
|
+
``x``, ``y`` and ``teams`` are read positionally (a pandas index is ignored). For ``kind="headshot"``, ``teams``
|
|
86
|
+
holds player ids and ``id_system`` must be ``"espn"`` or ``"gsis"`` (as in ``headshot_url``); otherwise
|
|
87
|
+
``strict=True`` raises UnresolvedTeamError for a team that does not resolve, as ``resolve`` does. ``_warn=False``
|
|
88
|
+
skips the same points without warning, for an adapter that already warned for them (no process-wide warning
|
|
89
|
+
filter is touched, so it is thread-safe).
|
|
90
|
+
"""
|
|
91
|
+
skipped = _warn_skipped if _warn else lambda reason, values: None
|
|
92
|
+
if kind not in KINDS:
|
|
93
|
+
raise InputError(f"kind must be one of {KINDS}, got {kind!r}")
|
|
94
|
+
xs, _ = _unpack(x)
|
|
95
|
+
ys, _ = _unpack(y)
|
|
96
|
+
ts, _ = _unpack(teams)
|
|
97
|
+
if not len(xs) == len(ys) == len(ts):
|
|
98
|
+
raise ValueError(f"x, y and teams must have the same length, got {len(xs)}, {len(ys)} and {len(ts)}")
|
|
99
|
+
out: list[Placement] = []
|
|
100
|
+
missing_xy: list[Any] = []
|
|
101
|
+
if kind == "headshot":
|
|
102
|
+
no_image: list[Any] = []
|
|
103
|
+
for xi, yi, pid in zip(xs, ys, ts, strict=True):
|
|
104
|
+
if _missing(pid):
|
|
105
|
+
continue
|
|
106
|
+
if _missing(xi) or _missing(yi):
|
|
107
|
+
missing_xy.append(pid)
|
|
108
|
+
continue
|
|
109
|
+
# id_system is a team or a headshot id system by kind; the callee validates it
|
|
110
|
+
url = headshot_url(pid, league, id_system=cast("HeadshotIdSystem", id_system))
|
|
111
|
+
if url is None:
|
|
112
|
+
no_image.append(pid)
|
|
113
|
+
continue
|
|
114
|
+
# the id the URL was built from: headshot_url reads an espn id through norm_value, so an id that went
|
|
115
|
+
# through a float ("3139477.0") is 3139477; gsis ids are looked up as given
|
|
116
|
+
key = norm_value(pid) if id_system == "espn" else None
|
|
117
|
+
out.append(Placement(key or str(pid).strip(), xi, yi, url, None, None))
|
|
118
|
+
skipped("with no headshot", no_image)
|
|
119
|
+
else:
|
|
120
|
+
seasons = _seasons(season, len(ts))
|
|
121
|
+
if _warn or strict: # one warning for unknown values, or the strict error
|
|
122
|
+
ids = resolve(ts, league, season=seasons, id_system=cast("IdSystem", id_system), strict=strict)
|
|
123
|
+
else:
|
|
124
|
+
ids, _ = _resolve_ids(ts, league, seasons, id_system)
|
|
125
|
+
rows: dict[tuple[str, int | None], dict[str, Any] | None] = {}
|
|
126
|
+
no_mark: list[Any] = []
|
|
127
|
+
for xi, yi, raw, team_id, s in zip(xs, ys, ts, ids, seasons, strict=True):
|
|
128
|
+
if team_id is None:
|
|
129
|
+
continue # unknown or null: the resolver already warned
|
|
130
|
+
if _missing(xi) or _missing(yi):
|
|
131
|
+
missing_xy.append(raw)
|
|
132
|
+
continue
|
|
133
|
+
if (team_id, s) not in rows:
|
|
134
|
+
rows[(team_id, s)] = select_mark(team_id, league, s, variant, kind)
|
|
135
|
+
row = rows[(team_id, s)]
|
|
136
|
+
if row is None:
|
|
137
|
+
no_mark.append(raw)
|
|
138
|
+
continue
|
|
139
|
+
w, h = row.get("width"), row.get("height")
|
|
140
|
+
aspect = float(w) / float(h) if w and h else None
|
|
141
|
+
out.append(Placement(team_id, xi, yi, str(row["archive_url"]), aspect, row))
|
|
142
|
+
skipped(f"with no {kind} archived", no_mark)
|
|
143
|
+
skipped("with a missing x or y", missing_xy)
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def place_images(x: Any, y: Any, paths: Any, *, _warn: bool = True) -> list[Placement]:
|
|
148
|
+
"""``place`` for arbitrary images: one Placement per (x, y, path), keyed by the path (a local file or URL).
|
|
149
|
+
|
|
150
|
+
A null path is skipped silently (as a null team is); a missing x or y is skipped with one warning (none with
|
|
151
|
+
``_warn=False``, as ``place``).
|
|
152
|
+
"""
|
|
153
|
+
paths = os.fspath(paths) if isinstance(paths, os.PathLike) else paths # one pathlib.Path is one point
|
|
154
|
+
xs, ys, ps = _unpack(x)[0], _unpack(y)[0], _unpack(paths)[0]
|
|
155
|
+
if not len(xs) == len(ys) == len(ps):
|
|
156
|
+
raise ValueError(f"x, y and paths must have the same length, got {len(xs)}, {len(ys)} and {len(ps)}")
|
|
157
|
+
out: list[Placement] = []
|
|
158
|
+
missing_xy: list[Any] = []
|
|
159
|
+
for xi, yi, path in zip(xs, ys, ps, strict=True):
|
|
160
|
+
if _missing(path):
|
|
161
|
+
continue
|
|
162
|
+
if _missing(xi) or _missing(yi):
|
|
163
|
+
missing_xy.append(path)
|
|
164
|
+
continue
|
|
165
|
+
out.append(Placement(str(path), xi, yi, str(path), None, None))
|
|
166
|
+
if _warn:
|
|
167
|
+
_warn_skipped("with a missing x or y", missing_xy)
|
|
168
|
+
return out
|