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.
Files changed (49) hide show
  1. sdvplot/__init__.py +68 -0
  2. sdvplot/_cache.py +484 -0
  3. sdvplot/_colors.py +206 -0
  4. sdvplot/_contrast.py +60 -0
  5. sdvplot/_court.py +110 -0
  6. sdvplot/_deprecate.py +39 -0
  7. sdvplot/_dispatch.py +342 -0
  8. sdvplot/_errors.py +93 -0
  9. sdvplot/_headshots.py +131 -0
  10. sdvplot/_images.py +292 -0
  11. sdvplot/_index.py +178 -0
  12. sdvplot/_lazy.py +15 -0
  13. sdvplot/_manifest.py +73 -0
  14. sdvplot/_marks.py +296 -0
  15. sdvplot/_normalize.py +82 -0
  16. sdvplot/_placement.py +168 -0
  17. sdvplot/_resolve.py +363 -0
  18. sdvplot/_surface.py +220 -0
  19. sdvplot/_tables.py +87 -0
  20. sdvplot/_tiers.py +135 -0
  21. sdvplot/_types.py +38 -0
  22. sdvplot/_versions.py +30 -0
  23. sdvplot/_web.py +61 -0
  24. sdvplot/altair.py +652 -0
  25. sdvplot/bokeh.py +323 -0
  26. sdvplot/data/INDEX_VERSION +1 -0
  27. sdvplot/data/aliases.parquet +0 -0
  28. sdvplot/data/teams.parquet +0 -0
  29. sdvplot/folium.py +316 -0
  30. sdvplot/great_tables/__init__.py +267 -0
  31. sdvplot/great_tables/_cells.py +1814 -0
  32. sdvplot/great_tables/_export.py +844 -0
  33. sdvplot/great_tables/_layout.py +1802 -0
  34. sdvplot/great_tables/_marks.py +798 -0
  35. sdvplot/great_tables/_themes.py +2134 -0
  36. sdvplot/holoviews.py +303 -0
  37. sdvplot/matplotlib.py +934 -0
  38. sdvplot/plotly.py +595 -0
  39. sdvplot/plotnine.py +1104 -0
  40. sdvplot/plottable.py +141 -0
  41. sdvplot/py.typed +0 -0
  42. sdvplot/pygal.py +416 -0
  43. sdvplot/reactable.py +488 -0
  44. sdvplot/testing.py +579 -0
  45. sdvplot/typing.py +21 -0
  46. sdvplot-0.1.0.dist-info/METADATA +188 -0
  47. sdvplot-0.1.0.dist-info/RECORD +49 -0
  48. sdvplot-0.1.0.dist-info/WHEEL +4 -0
  49. 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)