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/_images.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Decoded logo images for matplotlib-style libraries (web libraries use logo_url() instead)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import collections
|
|
6
|
+
import contextlib
|
|
7
|
+
import hashlib
|
|
8
|
+
import io
|
|
9
|
+
import numbers
|
|
10
|
+
import re
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
from urllib.request import url2pathname
|
|
16
|
+
|
|
17
|
+
from sdvplot._cache import MEMORY_CACHES, atomic_write, cache_path, fetch_cached, fetch_immutable, key_lock
|
|
18
|
+
from sdvplot._errors import InputError, IntegrityError, OptionalDependencyError, UnsafeCachePathError, warn
|
|
19
|
+
from sdvplot._marks import _check_mark_type, _check_variant, select_mark
|
|
20
|
+
from sdvplot._resolve import one_team, resolve
|
|
21
|
+
from sdvplot._types import IdSystem, MarkType
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from PIL import Image
|
|
25
|
+
|
|
26
|
+
DEFAULT_SVG_SIZE = 512
|
|
27
|
+
# The largest longest side an image is rendered at: 4096 x 4096 RGBA is 64 MiB, DECODED_BUDGET // 4, the largest image
|
|
28
|
+
# the decoded cache keeps. resvg allocates the whole canvas before Pillow's bomb guard sees it (a far larger one aborted
|
|
29
|
+
# the process), so an SVG's size and aspect ratio are checked before rendering.
|
|
30
|
+
MAX_SIZE = 4096
|
|
31
|
+
MAX_ASPECT = 64 # an SVG more than 64 times longer than it is wide is refused
|
|
32
|
+
_PROBE = 1024 # the box of the probe render that measures an SVG's aspect ratio
|
|
33
|
+
_SHA256 = re.compile(r"[0-9a-f]{64}")
|
|
34
|
+
URL_IMAGE_MAX_BYTES = 10 * 1024 * 1024
|
|
35
|
+
IMAGE_EXTS = frozenset({"png", "jpg", "jpeg", "svg", "webp", "gif", "bmp"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _rasterize(path: Path, sha: str, size: int, ext: str) -> Image.Image:
|
|
39
|
+
from PIL import Image
|
|
40
|
+
|
|
41
|
+
if not 1 <= size <= MAX_SIZE:
|
|
42
|
+
raise InputError(f"an SVG mark is rasterized at 1 to {MAX_SIZE} pixels, got size={size!r}")
|
|
43
|
+
try:
|
|
44
|
+
import importlib.metadata
|
|
45
|
+
|
|
46
|
+
import resvg_py
|
|
47
|
+
except ImportError as e:
|
|
48
|
+
raise OptionalDependencyError("SVG logos need the svg extra: pip install sdvplot[svg]") from e
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
version = importlib.metadata.version("resvg-py")
|
|
52
|
+
except importlib.metadata.PackageNotFoundError as e:
|
|
53
|
+
raise OptionalDependencyError("SVG logos need the svg extra: pip install sdvplot[svg]") from e
|
|
54
|
+
out = cache_path(f"rasters/{sha}_{size}_v{version}.png")
|
|
55
|
+
|
|
56
|
+
# Try to open cached raster; if corrupt, treat as cache miss
|
|
57
|
+
if out.exists():
|
|
58
|
+
try:
|
|
59
|
+
img = Image.open(out)
|
|
60
|
+
img.load()
|
|
61
|
+
return img
|
|
62
|
+
except (OSError, Image.UnidentifiedImageError):
|
|
63
|
+
# Cache miss due to corruption; delete (best effort: the cache may be read-only) and re-render
|
|
64
|
+
with contextlib.suppress(OSError):
|
|
65
|
+
out.unlink()
|
|
66
|
+
|
|
67
|
+
# Render SVG
|
|
68
|
+
try:
|
|
69
|
+
svg = path.read_text(encoding="utf-8-sig")
|
|
70
|
+
except UnicodeDecodeError as e:
|
|
71
|
+
raise ValueError(f"SVG {sha}.{ext}: {e}") from e
|
|
72
|
+
|
|
73
|
+
def render(box: int) -> bytes:
|
|
74
|
+
# given a width and a height, resvg fits the SVG inside that box keeping its aspect ratio: the longest side is
|
|
75
|
+
# box pixels and nothing larger is allocated, however tall, wide or large the SVG says it is
|
|
76
|
+
try:
|
|
77
|
+
return bytes(resvg_py.svg_to_bytes(svg_string=svg, width=box, height=box))
|
|
78
|
+
except ValueError as e:
|
|
79
|
+
raise ValueError(f"SVG {sha}.{ext}: {e}") from e
|
|
80
|
+
|
|
81
|
+
w, h = Image.open(io.BytesIO(render(_PROBE))).size
|
|
82
|
+
if max(w, h) > MAX_ASPECT * min(w, h):
|
|
83
|
+
raise InputError(f"SVG {sha}.{ext} renders {w} x {h}: an aspect ratio beyond 1:{MAX_ASPECT}, not drawn")
|
|
84
|
+
png = render(size)
|
|
85
|
+
|
|
86
|
+
with contextlib.suppress(OSError): # a read-only cache still gets the image, just not cached
|
|
87
|
+
atomic_write(out, png)
|
|
88
|
+
img = Image.open(io.BytesIO(png))
|
|
89
|
+
img.load()
|
|
90
|
+
return img
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def logo_image(
|
|
94
|
+
team: Any,
|
|
95
|
+
league: str,
|
|
96
|
+
*,
|
|
97
|
+
season: Any = None,
|
|
98
|
+
variant: str = "default",
|
|
99
|
+
mark_type: MarkType = "logo",
|
|
100
|
+
size: int | None = None,
|
|
101
|
+
id_system: IdSystem = "auto",
|
|
102
|
+
strict: bool = False,
|
|
103
|
+
) -> Image.Image | None:
|
|
104
|
+
"""The team's mark as a PIL image (downloaded once, then cached).
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
team: One team identifier (abbreviation, name, ESPN id, ...).
|
|
108
|
+
league: The SDV league key, e.g. "nfl".
|
|
109
|
+
season: A season year; None picks the current mark.
|
|
110
|
+
variant: "default", "dark", or a named variant from ``marks()``.
|
|
111
|
+
mark_type: "logo" or "wordmark".
|
|
112
|
+
size: The longest side in pixels, an int from 1 to 4096. Rasters are only scaled down; SVGs are rasterized at
|
|
113
|
+
it (default 512). Without it a raster comes at the archive's size, up to 4096 px (64 MiB decoded): pass
|
|
114
|
+
``size`` when the image is for a plot.
|
|
115
|
+
id_system: The id system of ``team``, as in ``resolve``: "auto" tries each in order; NHL stats ids need
|
|
116
|
+
"nhl_id".
|
|
117
|
+
strict: Raise UnresolvedTeamError instead of warning when the team does not resolve.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
PIL.Image.Image | None: The image, or None when the team does not resolve or has no mark.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
TypeError: If ``team`` is not a single value.
|
|
124
|
+
OptionalDependencyError: If the mark is an SVG and the ``svg`` extra is not installed.
|
|
125
|
+
OfflineError: If the download fails and no cached copy exists.
|
|
126
|
+
DownloadError: (an OfflineError and an OSError) If the CDN answers with an error status (a 4xx or 5xx
|
|
127
|
+
response) and no cached copy exists.
|
|
128
|
+
IntegrityError: (a DownloadError) If the download does not match the manifest's sha256, or is not an image
|
|
129
|
+
PIL can decode.
|
|
130
|
+
UnsafeDownloadError: (an OSError) If the download is refused: larger than the byte cap, past the deadline, or
|
|
131
|
+
redirected away from https.
|
|
132
|
+
UnsafeCachePathError: (a ValueError) If the manifest's sha256 or extension for the mark would put the file
|
|
133
|
+
outside the cache directory.
|
|
134
|
+
InputError: (a ValueError) If ``league`` or ``id_system`` is unknown, ``mark_type`` is not "logo"/"wordmark",
|
|
135
|
+
``variant`` is a name no mark in the archive has, ``season`` is not a year or is outside the seasons
|
|
136
|
+
sdvplot knows for the league, or ``size`` is not an int from 1 to 4096.
|
|
137
|
+
UnresolvedTeamError: (a ValueError) If ``strict=True`` and the team does not resolve.
|
|
138
|
+
ValueError: If an SVG cannot be parsed; an ``InputError`` if it is more than 64 times longer than it is wide.
|
|
139
|
+
|
|
140
|
+
Example:
|
|
141
|
+
::
|
|
142
|
+
|
|
143
|
+
import sdvplot
|
|
144
|
+
|
|
145
|
+
img = sdvplot.logo_image("KC", "nfl", size=64)
|
|
146
|
+
img.size # (64, 64)
|
|
147
|
+
|
|
148
|
+
See Also:
|
|
149
|
+
sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
|
|
150
|
+
sdv-py: https://py.sportsdataverse.org/
|
|
151
|
+
"""
|
|
152
|
+
_check_mark_type(mark_type)
|
|
153
|
+
_check_variant(variant, league)
|
|
154
|
+
if size is not None and (
|
|
155
|
+
isinstance(size, bool) or not isinstance(size, numbers.Integral) or not 1 <= size <= MAX_SIZE
|
|
156
|
+
):
|
|
157
|
+
raise InputError(f"size is the longest side in pixels, an int from 1 to {MAX_SIZE}, got {size!r}")
|
|
158
|
+
team_id = resolve(one_team(team, "logo_image"), league, season=season, id_system=id_system, strict=strict)
|
|
159
|
+
if team_id is None:
|
|
160
|
+
return None
|
|
161
|
+
row = select_mark(team_id, league, season, variant, mark_type)
|
|
162
|
+
if row is None:
|
|
163
|
+
warn(f"no {mark_type} archived for {team!r} ({league})")
|
|
164
|
+
return None
|
|
165
|
+
return load_mark_image(row, size)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def mark_file(row: dict[str, Any]) -> Path:
|
|
169
|
+
"""The cached file of one manifest row's image, downloaded once and checked against its sha256."""
|
|
170
|
+
sha, ext = str(row["sha256"]), str(row["ext"])
|
|
171
|
+
if not _SHA256.fullmatch(sha) or ext not in IMAGE_EXTS:
|
|
172
|
+
raise UnsafeCachePathError(f"manifest row has an invalid sha256 or ext ({sha!r}, {ext!r}); not fetched")
|
|
173
|
+
return fetch_immutable(str(row["archive_url"]), f"images/{sha[:2]}/{sha}.{ext}", sha)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _decode_mark(sha: str, ext: str, url: str, size: int | None) -> Image.Image:
|
|
177
|
+
"""The decoded image of one mark (SVGs rasterized, rasters scaled down to ``size``)."""
|
|
178
|
+
from PIL import Image
|
|
179
|
+
|
|
180
|
+
path = mark_file({"sha256": sha, "ext": ext, "archive_url": url})
|
|
181
|
+
if ext == "svg":
|
|
182
|
+
return _rasterize(path, sha, size or DEFAULT_SVG_SIZE, ext)
|
|
183
|
+
try:
|
|
184
|
+
img: Image.Image = Image.open(path)
|
|
185
|
+
except Image.UnidentifiedImageError as e: # the archive's own file, sha-checked: the archive is wrong
|
|
186
|
+
raise IntegrityError(f"{url}: not an image PIL can decode ({e})") from e
|
|
187
|
+
img.load()
|
|
188
|
+
if size is not None:
|
|
189
|
+
img = img.copy()
|
|
190
|
+
img.thumbnail((size, size))
|
|
191
|
+
return img
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Decoded images kept in memory, least recently used first, bounded by pixel bytes rather than entry count: the archive
|
|
195
|
+
# holds thousands of 4096 x 4096 rasters (67 MB each decoded), so a count bound alone could pin gigabytes.
|
|
196
|
+
DECODED_BUDGET = 256 * 1024 * 1024
|
|
197
|
+
_decoded: collections.OrderedDict[tuple[str, str, str, int | None], Image.Image] = collections.OrderedDict()
|
|
198
|
+
_decoded_bytes = 0
|
|
199
|
+
_decoded_lock = threading.Lock()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _nbytes(img: Image.Image) -> int:
|
|
203
|
+
from PIL import ImageMode
|
|
204
|
+
|
|
205
|
+
# bytes per sample from the mode's array type: 2 for I;16, 4 for I and F, 1 for the 8-bit modes
|
|
206
|
+
return img.width * img.height * len(img.getbands()) * int(ImageMode.getmode(img.mode).typestr[-1])
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _decoded_mark(sha: str, ext: str, url: str, size: int | None) -> Image.Image:
|
|
210
|
+
"""The decoded image of one mark, kept per (sha256, ext, url, size) so repeated calls decode a team's logo once.
|
|
211
|
+
An image over a quarter of the budget is decoded per call instead of pushing everything else out. Threads after the
|
|
212
|
+
same key decode it once (the others wait for it). Callers must go through ``load_mark_image``, which hands out a
|
|
213
|
+
copy."""
|
|
214
|
+
global _decoded_bytes
|
|
215
|
+
key = (sha, ext, url, size)
|
|
216
|
+
with key_lock(key):
|
|
217
|
+
with _decoded_lock:
|
|
218
|
+
if (hit := _decoded.get(key)) is not None:
|
|
219
|
+
_decoded.move_to_end(key)
|
|
220
|
+
return hit
|
|
221
|
+
img = _decode_mark(*key)
|
|
222
|
+
nbytes = _nbytes(img)
|
|
223
|
+
if nbytes <= DECODED_BUDGET // 4:
|
|
224
|
+
with _decoded_lock:
|
|
225
|
+
if key not in _decoded:
|
|
226
|
+
_decoded[key] = img
|
|
227
|
+
_decoded_bytes += nbytes
|
|
228
|
+
while _decoded_bytes > DECODED_BUDGET:
|
|
229
|
+
_, old = _decoded.popitem(last=False)
|
|
230
|
+
_decoded_bytes -= _nbytes(old)
|
|
231
|
+
return img
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _clear_decoded() -> None:
|
|
235
|
+
global _decoded_bytes
|
|
236
|
+
with _decoded_lock:
|
|
237
|
+
_decoded.clear()
|
|
238
|
+
_decoded_bytes = 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
MEMORY_CACHES.append(_clear_decoded) # clear_cache() also drops the decoded images held in memory
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def load_mark_image(row: dict[str, Any], size: int | None = None) -> Image.Image:
|
|
245
|
+
"""The image for one manifest row (as ``select_mark`` returns it): fetched by sha256 once, SVGs rasterized.
|
|
246
|
+
|
|
247
|
+
``size`` is the longest side in pixels: rasters are only scaled down; SVGs are rasterized at it (default 512).
|
|
248
|
+
Decoded images are kept in memory (up to ``DECODED_BUDGET`` bytes of pixels, least recently used dropped first);
|
|
249
|
+
the caller gets its own copy, free to modify, with the source ``format`` kept.
|
|
250
|
+
"""
|
|
251
|
+
img = _decoded_mark(str(row["sha256"]), str(row["ext"]), str(row["archive_url"]), size)
|
|
252
|
+
out = img.copy()
|
|
253
|
+
out.format = img.format # Image.copy() drops it; callers may read img.format / Image.MIME
|
|
254
|
+
return out
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _check_image(body: bytes) -> None:
|
|
258
|
+
from PIL import Image
|
|
259
|
+
|
|
260
|
+
Image.open(io.BytesIO(body)).verify()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def url_file(url: str) -> Path:
|
|
264
|
+
"""The cached file of an image that is not content-addressed (a headshot): keyed by sha256(url), refreshed after
|
|
265
|
+
SDVPLOT_CACHE_TTL, non-images rejected."""
|
|
266
|
+
key = hashlib.sha256(url.encode()).hexdigest()
|
|
267
|
+
return fetch_cached(url, f"urlimages/{key[:2]}/{key}", validate=_check_image, max_bytes=URL_IMAGE_MAX_BYTES)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def load_url_image(url: str) -> Image.Image:
|
|
271
|
+
"""An image that is not content-addressed (a headshot): cached by sha256(url), refreshed after SDVPLOT_CACHE_TTL."""
|
|
272
|
+
from PIL import Image
|
|
273
|
+
|
|
274
|
+
path = url_file(url)
|
|
275
|
+
img: Image.Image = Image.open(path)
|
|
276
|
+
img.load()
|
|
277
|
+
return img
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def load_path_image(path: str) -> Image.Image:
|
|
281
|
+
"""Any image by https URL (cached like a headshot), file:// URI or local path; raises OSError, ValueError or
|
|
282
|
+
OfflineError (an http URL is refused with UnsafeDownloadError, an OSError)."""
|
|
283
|
+
scheme = urlsplit(path).scheme.lower() # URL schemes are case-insensitive; a Windows drive letter is no scheme
|
|
284
|
+
if scheme in ("http", "https"):
|
|
285
|
+
return load_url_image(path)
|
|
286
|
+
if scheme == "file": # what pathlib.Path.as_uri() writes
|
|
287
|
+
path = url2pathname(urlsplit(path).path)
|
|
288
|
+
from PIL import Image
|
|
289
|
+
|
|
290
|
+
img: Image.Image = Image.open(path)
|
|
291
|
+
img.load()
|
|
292
|
+
return img
|
sdvplot/_index.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""The bundled team index: teams.parquet and aliases.parquet, generated by tools/build_index.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import functools
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from sdvplot._errors import InputError
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import polars as pl
|
|
16
|
+
else:
|
|
17
|
+
from sdvplot._lazy import pl
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _team_schema() -> dict[str, pl.DataType]:
|
|
21
|
+
return {
|
|
22
|
+
"league": pl.String(),
|
|
23
|
+
"team_id": pl.String(),
|
|
24
|
+
"abbr": pl.String(),
|
|
25
|
+
"name": pl.String(),
|
|
26
|
+
"short_name": pl.String(),
|
|
27
|
+
"location": pl.String(),
|
|
28
|
+
"program": pl.String(),
|
|
29
|
+
"conference_id": pl.String(),
|
|
30
|
+
"conference": pl.String(),
|
|
31
|
+
"color_primary": pl.String(),
|
|
32
|
+
"color_secondary": pl.String(),
|
|
33
|
+
"color_source": pl.String(),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _alias_schema() -> dict[str, pl.DataType]:
|
|
38
|
+
return {
|
|
39
|
+
"league": pl.String(),
|
|
40
|
+
"id_system": pl.String(),
|
|
41
|
+
"value": pl.String(),
|
|
42
|
+
"team_id": pl.String(),
|
|
43
|
+
"valid_from": pl.Int32(),
|
|
44
|
+
"valid_to": pl.Int32(),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def __getattr__(name: str) -> dict[str, pl.DataType]:
|
|
49
|
+
# TEAM_SCHEMA / ALIAS_SCHEMA stay importable (tests, tools/build_index.py) but are built on first use
|
|
50
|
+
if name == "TEAM_SCHEMA":
|
|
51
|
+
return _team_schema()
|
|
52
|
+
if name == "ALIAS_SCHEMA":
|
|
53
|
+
return _alias_schema()
|
|
54
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_RELOAD_HOOKS: list[Callable[[], None]] = []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@functools.cache
|
|
61
|
+
def data_dir() -> Path:
|
|
62
|
+
"""Where the generated index lives inside the installed package (looked up once: every check_league reads it)."""
|
|
63
|
+
return Path(str(resources.files("sdvplot") / "data"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@functools.cache
|
|
67
|
+
def _read(name: str, directory: str) -> pl.DataFrame:
|
|
68
|
+
return pl.read_parquet(Path(directory) / f"{name}.parquet")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@functools.cache
|
|
72
|
+
def _leagues(directory: str) -> frozenset[str]:
|
|
73
|
+
return frozenset(_read("teams", directory)["league"].unique().to_list())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check_league(league: str) -> None:
|
|
77
|
+
"""The one unknown-league error every public function raises."""
|
|
78
|
+
if not isinstance(league, str): # a list or Series here is a pre-0.1 call: team_colors(teams, league)
|
|
79
|
+
raise InputError(
|
|
80
|
+
f"league must be a league key such as 'nfl', got {type(league).__name__}; team_colors and palette take "
|
|
81
|
+
"the league first: team_colors(league, teams)"
|
|
82
|
+
)
|
|
83
|
+
known = _leagues(str(data_dir()))
|
|
84
|
+
if league not in known:
|
|
85
|
+
raise InputError(f"unknown league {league!r}; known leagues: {sorted(known)}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@functools.cache
|
|
89
|
+
def _season_table() -> dict[str | None, tuple[int, int]]:
|
|
90
|
+
"""league -> (first, last) season a season argument may name, and None -> the bounds for any league; built once per
|
|
91
|
+
index load. The first season is the earliest the aliases date (MLB's 1871 for the index), and a league's own where
|
|
92
|
+
its aliases close a range (``valid_to``), so its history is dated: the NFL's 1920, the WNBA's 1997, the XFL's 2020.
|
|
93
|
+
Aliases that only open a range (the NHL's 2026 renames) say nothing about where a league starts, so such a league
|
|
94
|
+
keeps the index's floor. The last season is the later of the latest one the aliases name and next year, for every
|
|
95
|
+
league (an older index still takes this season and the next)."""
|
|
96
|
+
a = alias_table()
|
|
97
|
+
lo = a.select(pl.min_horizontal(pl.col("valid_from").min(), pl.col("valid_to").min())).item()
|
|
98
|
+
hi = a.select(pl.max_horizontal(pl.col("valid_from").max(), pl.col("valid_to").max())).item()
|
|
99
|
+
if lo is None or hi is None:
|
|
100
|
+
return {}
|
|
101
|
+
last = max(int(hi), datetime.date.today().year + 1)
|
|
102
|
+
out: dict[str | None, tuple[int, int]] = {None: (int(lo), last)}
|
|
103
|
+
dated = set(a.filter(pl.col("valid_to").is_not_null())["league"].to_list())
|
|
104
|
+
firsts = (
|
|
105
|
+
a.filter(pl.col("league").is_in(sorted(dated)))
|
|
106
|
+
.group_by("league")
|
|
107
|
+
.agg(pl.min_horizontal(pl.col("valid_from").min(), pl.col("valid_to").min()).alias("first"))
|
|
108
|
+
)
|
|
109
|
+
for league, first in firsts.iter_rows():
|
|
110
|
+
out[league] = (int(first), last)
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def season_bounds(league: str | None = None) -> tuple[int, int] | None:
|
|
115
|
+
"""The first and last season a season argument may name for ``league`` (None: any league), or None when no alias
|
|
116
|
+
is dated; see ``_season_table``."""
|
|
117
|
+
table = _season_table()
|
|
118
|
+
return table.get(league, table.get(None))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def on_reload(fn: Callable[[], None]) -> None:
|
|
122
|
+
"""Register a cache that must be dropped when the index is reloaded (the resolver's lookup tables)."""
|
|
123
|
+
_RELOAD_HOOKS.append(fn)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def reload_index() -> None:
|
|
127
|
+
"""Forget the loaded index and everything derived from it."""
|
|
128
|
+
_read.cache_clear()
|
|
129
|
+
_leagues.cache_clear()
|
|
130
|
+
_season_table.cache_clear()
|
|
131
|
+
for fn in _RELOAD_HOOKS:
|
|
132
|
+
fn()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def team_table() -> pl.DataFrame:
|
|
136
|
+
return _read("teams", str(data_dir()))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def alias_table() -> pl.DataFrame:
|
|
140
|
+
return _read("aliases", str(data_dir()))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def index_version() -> str:
|
|
144
|
+
path = data_dir() / "INDEX_VERSION"
|
|
145
|
+
return path.read_text().strip() if path.exists() else "unknown"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def teams(league: str | None = None) -> pl.DataFrame:
|
|
149
|
+
"""The bundled team index: one row per (league, team_id), with names, abbreviation, conference and colors.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
league: An SDV league key such as "nfl"; None returns every league.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
polars.DataFrame: The index columns ``league``, ``team_id``, ``abbr``, ``name``, ``short_name``, ``location``,
|
|
156
|
+
``program``, ``conference_id``, ``conference``, ``color_primary``, ``color_secondary`` and ``color_source``:
|
|
157
|
+
"nflverse" or "espn" (published colors), "logo" (derived from the team's archived logo, where no source
|
|
158
|
+
publishes any) or "fallback" (a placeholder).
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
InputError: (a ValueError) If ``league`` is given and is not a known league key.
|
|
162
|
+
|
|
163
|
+
Example:
|
|
164
|
+
::
|
|
165
|
+
|
|
166
|
+
import sdvplot
|
|
167
|
+
|
|
168
|
+
sdvplot.teams("nfl").shape # (32, 12)
|
|
169
|
+
|
|
170
|
+
See Also:
|
|
171
|
+
sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
|
|
172
|
+
sdv-py: https://py.sportsdataverse.org/
|
|
173
|
+
"""
|
|
174
|
+
t = team_table()
|
|
175
|
+
if league is None:
|
|
176
|
+
return t.clone() # never the cached frame: an in-place edit by the caller would leak into the session
|
|
177
|
+
check_league(league)
|
|
178
|
+
return t.filter(pl.col("league") == league)
|
sdvplot/_lazy.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""A stand-in for ``polars`` that imports it on first attribute access, so ``import sdvplot`` stays fast."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _LazyPolars:
|
|
9
|
+
def __getattr__(self, name: str) -> Any:
|
|
10
|
+
import polars
|
|
11
|
+
|
|
12
|
+
return getattr(polars, name)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
pl = _LazyPolars()
|
sdvplot/_manifest.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""The sdv-assets logo manifest: every archived mark with its season range, source and CDN URL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import io
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from sdvplot._cache import MEMORY_CACHES, SAFE_URL, fetch_cached
|
|
10
|
+
from sdvplot._errors import warn
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
import polars as pl
|
|
14
|
+
else:
|
|
15
|
+
from sdvplot._lazy import pl
|
|
16
|
+
|
|
17
|
+
MANIFEST_URL = "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/manifest/marks.csv"
|
|
18
|
+
# The columns sdvplot reads: a contract with sdv-assets (checked live by tests/test_manifest.py)
|
|
19
|
+
REQUIRED_COLUMNS = (
|
|
20
|
+
"level",
|
|
21
|
+
"league",
|
|
22
|
+
"entity_id",
|
|
23
|
+
"entity_name",
|
|
24
|
+
"program",
|
|
25
|
+
"mark_type",
|
|
26
|
+
"variant",
|
|
27
|
+
"valid_from",
|
|
28
|
+
"valid_to",
|
|
29
|
+
"source",
|
|
30
|
+
"sha256",
|
|
31
|
+
"ext",
|
|
32
|
+
"archive_url",
|
|
33
|
+
"first_seen",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _validate(body: bytes) -> None:
|
|
38
|
+
# parse the whole body the way _read() will, so a ragged or header-only manifest never replaces the cached one
|
|
39
|
+
m = pl.read_csv(io.BytesIO(body), infer_schema_length=0)
|
|
40
|
+
missing = [c for c in REQUIRED_COLUMNS if c not in m.columns]
|
|
41
|
+
if missing:
|
|
42
|
+
raise ValueError(f"the logo manifest is missing columns {missing}")
|
|
43
|
+
if m.is_empty():
|
|
44
|
+
raise ValueError("the logo manifest has no rows")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# keyed by mtime so a refreshed file is read again; only the current file matters, so the previous frame (~17 MiB) is
|
|
48
|
+
# dropped rather than kept for the session
|
|
49
|
+
@functools.lru_cache(maxsize=1)
|
|
50
|
+
def _read(path: str, mtime: float) -> pl.DataFrame:
|
|
51
|
+
# every column as text first, so ids keep leading zeros and never become floats; then type the season range
|
|
52
|
+
m = pl.read_csv(path, infer_schema_length=0)
|
|
53
|
+
# archive_url reaches the web adapters' HTML: a row whose URL is not a plain https URL is dropped, never passed on
|
|
54
|
+
ok = m["archive_url"].str.contains(f"^(?:{SAFE_URL})$").fill_null(False)
|
|
55
|
+
if not ok.all():
|
|
56
|
+
bad = m.filter(~ok)["archive_url"]
|
|
57
|
+
warn(
|
|
58
|
+
f"dropped {len(bad)} logo manifest row(s) whose archive_url is not a plain https URL, e.g. {bad[0]!r:.100}"
|
|
59
|
+
)
|
|
60
|
+
m = m.filter(ok)
|
|
61
|
+
return m.with_columns(
|
|
62
|
+
pl.col("valid_from").cast(pl.Int32, strict=False),
|
|
63
|
+
pl.col("valid_to").cast(pl.Int32, strict=False),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
MEMORY_CACHES.append(_read.cache_clear) # clear_cache() also frees the parsed manifest
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_manifest() -> pl.DataFrame:
|
|
71
|
+
"""The logo manifest, from the local cache (refreshed after SDVPLOT_CACHE_TTL days)."""
|
|
72
|
+
path = fetch_cached(MANIFEST_URL, "manifest/marks.csv", validate=_validate)
|
|
73
|
+
return _read(str(path), path.stat().st_mtime)
|