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/_dispatch.py ADDED
@@ -0,0 +1,342 @@
1
+ """One front door for every library: sdvplot.add_logos(target, ...) routes to the adapter for target's library.
2
+
3
+ Each adapter registers at the end of this module (registering imports nothing; the adapter module loads on first
4
+ use). The full adapter contract, its rules and its test hooks are documented in sdvplot.testing; in short, an adapter
5
+ module exposes add_logos, add_wordmarks, add_headshots and axis_logos, plus the test hook
6
+
7
+ _drawn_marks(target) -> list[tuple]
8
+
9
+ which returns one (team_id, x, y, height) or (team_id, x, y, height, url) tuple per image the adapter drew, in draw
10
+ order. team_id is the canonical string id, x/y are the position values passed in (positional, never index labels),
11
+ height is the fraction of the plot height the image was drawn at (measured, not the value asked for), and url the
12
+ image source it drew.
13
+
14
+ add_logos must return the object that was drawn on: the target itself when the library mutates in place
15
+ (matplotlib), or the new object when it builds one (plotnine, altair, tables). The harness reads _drawn_marks from
16
+ the returned object when it is not None, and from the target otherwise.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import importlib
22
+ from dataclasses import dataclass
23
+ from types import ModuleType
24
+ from typing import TYPE_CHECKING, Any, TypeVar, overload
25
+
26
+ from sdvplot._errors import OptionalDependencyError, UnsupportedTargetError
27
+
28
+ if TYPE_CHECKING:
29
+ from bokeh.models import Plot
30
+ from folium import Map
31
+ from great_tables import GT
32
+ from matplotlib.axes import Axes
33
+ from matplotlib.figure import Figure
34
+ from plotly.basedatatypes import BaseFigure
35
+ from plotnine import ggplot
36
+
37
+ # The targets whose verbs return the target itself, or a new object of its type (plotnine, great_tables), so the
38
+ # result has the type the target had. Altair is not one: a Chart comes back as a LayerChart.
39
+ _Target = TypeVar("_Target", bound="Axes | Figure | BaseFigure | ggplot | GT | Plot | Map")
40
+ # The same, for the libraries that draw axis logos
41
+ _AxisTarget = TypeVar("_AxisTarget", bound="Axes | Figure | BaseFigure | ggplot")
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Adapter:
46
+ name: str # display name, e.g. "Plotly"
47
+ package: str # top-level package of the target object's type, e.g. "plotly"
48
+ module: str # adapter module, e.g. "sdvplot.plotly"
49
+ extra: str # pip extra, e.g. "plotly"
50
+
51
+
52
+ ADAPTERS: dict[str, Adapter] = {}
53
+
54
+
55
+ def register_adapter(adapter: Adapter) -> None:
56
+ ADAPTERS[adapter.package] = adapter
57
+
58
+
59
+ def adapter_for(target: Any) -> ModuleType:
60
+ """The adapter module for target's library.
61
+
62
+ Walks type(target).__mro__ and uses the first class whose top-level package is registered, so a user's
63
+ subclass of a library class (even one defined in __main__) still reaches that library's adapter.
64
+ """
65
+ cls = type(target)
66
+ adapter = None
67
+ for klass in cls.__mro__:
68
+ adapter = ADAPTERS.get((klass.__module__ or "").split(".")[0])
69
+ if adapter is not None:
70
+ break
71
+ if adapter is None:
72
+ supported = ", ".join(sorted(a.name for a in ADAPTERS.values())) or "none yet"
73
+ raise UnsupportedTargetError(
74
+ f"sdvplot has no adapter for {cls.__module__}.{cls.__name__} objects; supported: {supported}"
75
+ )
76
+ try:
77
+ return importlib.import_module(adapter.module)
78
+ except OptionalDependencyError:
79
+ raise # the adapter module's requires_extra already named the extra
80
+ except ModuleNotFoundError as e:
81
+ # Only a missing adapter module or target library (ModuleNotFoundError) means "extra not installed";
82
+ # any other ImportError (a broken transitive import inside the adapter) is a real bug: surface it unchanged.
83
+ name = e.name or ""
84
+ if name == adapter.module or name == adapter.package or name.startswith(adapter.package + "."):
85
+ raise OptionalDependencyError(
86
+ f"{adapter.name} support needs the {adapter.extra} extra: pip install sdvplot[{adapter.extra}]"
87
+ ) from e
88
+ raise
89
+
90
+
91
+ @overload
92
+ def add_logos(target: _Target, *args: Any, **kwargs: Any) -> _Target: ...
93
+ @overload
94
+ def add_logos(target: Any, *args: Any, **kwargs: Any) -> Any: ...
95
+ def add_logos(target: Any, *args: Any, **kwargs: Any) -> Any:
96
+ """Add team logos to a plot or table of any supported library.
97
+
98
+ Routes to the adapter for ``target``'s library (matplotlib, plotnine, Plotly, Altair, great_tables, ...) and returns
99
+ the object that was drawn on: ``target`` itself when the library mutates in place, a new object otherwise. Plots and
100
+ tables take different arguments. A plot (matplotlib, plotnine, Plotly, Altair, Bokeh, HoloViews, folium and pygal)
101
+ takes ``x``, ``y`` and ``teams``, and a ``height`` that is a fraction of the plot height. A great_tables ``GT``
102
+ takes ``columns`` and a ``height`` in pixels, with no ``alpha`` or ``variant`` (see
103
+ ``sdvplot.great_tables.gt_sdv_logos``).
104
+
105
+ Args:
106
+ target: The plot or table object. Its type picks the adapter.
107
+ *args: Passed to the adapter. For a plot: ``x``, ``y`` (positions in the target's own coordinates) and
108
+ ``teams`` (the team values to draw), in that order. For a table: ``columns`` (the columns whose cells
109
+ become marks).
110
+ **kwargs: Passed to the adapter: ``league`` (the SDV league key) and ``season`` (for a plot one season or one
111
+ per team, for a table one season).
112
+ For a plot, also ``height`` (a fraction of the plot height, in (0, 1]), ``alpha`` (opacity, 0 to 1) and
113
+ ``variant`` (a mark variant, as in ``logo_url``); for a table, ``height`` is in pixels (default 30).
114
+
115
+ Returns:
116
+ object: The drawn-on plot or table: ``target`` itself, or the new object the adapter built.
117
+
118
+ Raises:
119
+ UnsupportedTargetError: (a TypeError) If no adapter is registered for ``target``'s library, or the adapter
120
+ cannot draw on this kind of ``target`` (a pygal Bar chart, a folium FeatureGroup).
121
+ OptionalDependencyError: (a ModuleNotFoundError) If the adapter's optional extra is not installed, or a mark a
122
+ raster adapter draws is an SVG and the ``svg`` extra is not installed.
123
+ TypeError: If the adapter does not take an argument given (a table takes no ``x``, ``y``, ``alpha``).
124
+ InputError: (a ValueError) If a value is out of range: a plot ``height`` outside (0, 1] or ``alpha`` outside [0,
125
+ 1], or a table ``height`` below 1 pixel (a fraction such as 0.1 is a plot's unit, not a table's); or
126
+ ``league``, ``id_system``, ``variant`` or ``season`` is not one sdvplot knows.
127
+ ValueError: If the adapter's own checks fail (inputs of different lengths, an axis it cannot place on); its page
128
+ lists them.
129
+ UnresolvedTeamError: (a ValueError) If a table adapter is given ``strict=True`` and a value does not resolve.
130
+ OfflineError: If a download the adapter needs (the logo manifest, a mark's image, a headshot) fails and no
131
+ cached copy exists: a DownloadError (also an OSError) for an HTTP error status, an IntegrityError for a file
132
+ that does not match the manifest's sha256. Which downloads an adapter makes is on its page.
133
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
134
+ redirected away from https.
135
+
136
+ Example:
137
+ ::
138
+
139
+ import matplotlib.pyplot as plt
140
+ import sdvplot
141
+
142
+ fig, ax = plt.subplots()
143
+ ax.set_xlim(0, 30)
144
+ ax.set_ylim(-10, 0)
145
+ sdvplot.add_logos(ax, [10, 20], [-3, -7], ["KC", "BUF"], league="nfl", height=0.15)
146
+
147
+ See Also:
148
+ sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
149
+ sdv-py: https://py.sportsdataverse.org/
150
+ """
151
+ return adapter_for(target).add_logos(target, *args, **kwargs)
152
+
153
+
154
+ @overload
155
+ def add_wordmarks(target: _Target, *args: Any, **kwargs: Any) -> _Target: ...
156
+ @overload
157
+ def add_wordmarks(target: Any, *args: Any, **kwargs: Any) -> Any: ...
158
+ def add_wordmarks(target: Any, *args: Any, **kwargs: Any) -> Any:
159
+ """Add team wordmarks to a plot or table of any supported library.
160
+
161
+ Routes to the adapter for ``target``'s library (matplotlib, plotnine, Plotly, Altair, great_tables, ...) and returns
162
+ the object that was drawn on: ``target`` itself when the library mutates in place, a new object otherwise. Plots and
163
+ tables take different arguments. A plot (matplotlib, plotnine, Plotly, Altair, Bokeh, HoloViews, folium and pygal)
164
+ takes ``x``, ``y`` and ``teams``, and a ``height`` that is a fraction of the plot height. A great_tables ``GT``
165
+ takes ``columns`` and a ``height`` in pixels, with no ``alpha`` or ``variant`` (see
166
+ ``sdvplot.great_tables.gt_sdv_wordmarks``).
167
+
168
+ Args:
169
+ target: The plot or table object. Its type picks the adapter.
170
+ *args: Passed to the adapter. For a plot: ``x``, ``y`` (positions in the target's own coordinates) and
171
+ ``teams`` (the team values to draw), in that order. For a table: ``columns`` (the columns whose cells
172
+ become marks).
173
+ **kwargs: Passed to the adapter: ``league`` (the SDV league key) and ``season`` (for a plot one season or one
174
+ per team, for a table one season).
175
+ For a plot, also ``height`` (a fraction of the plot height, in (0, 1]), ``alpha`` (opacity, 0 to 1) and
176
+ ``variant`` (a mark variant, as in ``logo_url``); for a table, ``height`` is in pixels (default 30).
177
+
178
+ Returns:
179
+ object: The drawn-on plot or table: ``target`` itself, or the new object the adapter built.
180
+
181
+ Raises:
182
+ UnsupportedTargetError: (a TypeError) If no adapter is registered for ``target``'s library, or the adapter
183
+ cannot draw on this kind of ``target`` (a pygal Bar chart, a folium FeatureGroup).
184
+ OptionalDependencyError: (a ModuleNotFoundError) If the adapter's optional extra is not installed, or a mark a
185
+ raster adapter draws is an SVG and the ``svg`` extra is not installed.
186
+ TypeError: If the adapter does not take an argument given (a table takes no ``x``, ``y``, ``alpha``).
187
+ InputError: (a ValueError) If a value is out of range: a plot ``height`` outside (0, 1] or ``alpha`` outside [0,
188
+ 1], or a table ``height`` below 1 pixel (a fraction such as 0.1 is a plot's unit, not a table's); or
189
+ ``league``, ``id_system``, ``variant`` or ``season`` is not one sdvplot knows.
190
+ ValueError: If the adapter's own checks fail (inputs of different lengths, an axis it cannot place on); its page
191
+ lists them.
192
+ UnresolvedTeamError: (a ValueError) If a table adapter is given ``strict=True`` and a value does not resolve.
193
+ OfflineError: If a download the adapter needs (the logo manifest, a mark's image, a headshot) fails and no
194
+ cached copy exists: a DownloadError (also an OSError) for an HTTP error status, an IntegrityError for a file
195
+ that does not match the manifest's sha256. Which downloads an adapter makes is on its page.
196
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
197
+ redirected away from https.
198
+
199
+ Example:
200
+ ::
201
+
202
+ import matplotlib.pyplot as plt
203
+ import sdvplot
204
+
205
+ fig, ax = plt.subplots()
206
+ sdvplot.add_wordmarks(ax, [0.5], [0.5], ["KC"], league="nfl", height=0.1)
207
+
208
+ See Also:
209
+ sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
210
+ sdv-py: https://py.sportsdataverse.org/
211
+ """
212
+ return adapter_for(target).add_wordmarks(target, *args, **kwargs)
213
+
214
+
215
+ @overload
216
+ def add_headshots(target: _Target, *args: Any, **kwargs: Any) -> _Target: ...
217
+ @overload
218
+ def add_headshots(target: Any, *args: Any, **kwargs: Any) -> Any: ...
219
+ def add_headshots(target: Any, *args: Any, **kwargs: Any) -> Any:
220
+ """Add player headshots to a plot or table of any supported library.
221
+
222
+ Routes to the adapter for ``target``'s library (matplotlib, plotnine, Plotly, Altair, great_tables, ...) and returns
223
+ the object that was drawn on: ``target`` itself when the library mutates in place, a new object otherwise. For
224
+ headshots, ``teams`` holds player ids and ``league`` picks the ESPN league. Plots and tables take different
225
+ arguments. A plot (matplotlib, plotnine, Plotly, Altair, Bokeh, HoloViews, folium and pygal) takes ``x``, ``y`` and
226
+ ``teams``, and a ``height`` that is a fraction of the plot height. A great_tables ``GT`` takes ``columns`` and a
227
+ ``height`` in pixels, with no ``alpha`` (see ``sdvplot.great_tables.gt_sdv_headshots``).
228
+
229
+ Args:
230
+ target: The plot or table object. Its type picks the adapter.
231
+ *args: Passed to the adapter. For a plot: ``x``, ``y`` (positions in the target's own coordinates) and
232
+ ``teams`` (the player ids to draw), in that order. For a table: ``columns`` (the columns whose cells
233
+ become headshots).
234
+ **kwargs: Passed to the adapter: ``league`` (the SDV league key) and ``id_system`` (``"espn"`` or
235
+ ``"gsis"``, as in ``headshot_url``). For a plot, also ``height`` (a fraction of the plot height, in
236
+ (0, 1]) and ``alpha`` (opacity, 0 to 1); for a table, ``height`` is in pixels (default 30). Headshots take
237
+ no ``season`` or ``variant``.
238
+
239
+ Returns:
240
+ object: The drawn-on plot or table: ``target`` itself, or the new object the adapter built.
241
+
242
+ Raises:
243
+ UnsupportedTargetError: (a TypeError) If no adapter is registered for ``target``'s library, or the adapter
244
+ cannot draw on this kind of ``target`` (a pygal Bar chart, a folium FeatureGroup).
245
+ OptionalDependencyError: (a ModuleNotFoundError) If the adapter's optional extra is not installed, or a mark a
246
+ raster adapter draws is an SVG and the ``svg`` extra is not installed.
247
+ TypeError: If the adapter does not take an argument given (a table takes no ``x``, ``y``, ``alpha``).
248
+ InputError: (a ValueError) If a value is out of range: a plot ``height`` outside (0, 1] or ``alpha`` outside [0,
249
+ 1], or a table ``height`` below 1 pixel (a fraction such as 0.1 is a plot's unit, not a table's); or
250
+ ``league`` has no ESPN headshots or ``id_system`` is not valid for it.
251
+ ValueError: If the adapter's own checks fail (inputs of different lengths, an axis it cannot place on); its page
252
+ lists them.
253
+ OfflineError: If a download the adapter needs (the logo manifest, a mark's image, a headshot) fails and no
254
+ cached copy exists: a DownloadError (also an OSError) for an HTTP error status, an IntegrityError for a file
255
+ that does not match the manifest's sha256. Which downloads an adapter makes is on its page.
256
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
257
+ redirected away from https.
258
+
259
+ Example:
260
+ ::
261
+
262
+ import matplotlib.pyplot as plt
263
+ import sdvplot
264
+
265
+ fig, ax = plt.subplots()
266
+ sdvplot.add_headshots(ax, [0.5], [0.5], ["3139477"], league="nfl", height=0.2)
267
+
268
+ See Also:
269
+ sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
270
+ sdv-py: https://py.sportsdataverse.org/
271
+ """
272
+ return adapter_for(target).add_headshots(target, *args, **kwargs)
273
+
274
+
275
+ @overload
276
+ def axis_logos(target: _AxisTarget, *args: Any, **kwargs: Any) -> _AxisTarget: ...
277
+ @overload
278
+ def axis_logos(target: Any, *args: Any, **kwargs: Any) -> Any: ...
279
+ def axis_logos(target: Any, *args: Any, **kwargs: Any) -> Any:
280
+ """Replace an axis' team labels with team logos on a plot of any supported library.
281
+
282
+ Routes to the adapter for ``target``'s library (matplotlib, plotnine, Plotly, Altair, great_tables, ...) and returns
283
+ the object that was drawn on: ``target`` itself when the library mutates in place, a new object otherwise. The
284
+ adapters that draw axis logos (matplotlib, plotnine, Plotly and Altair) all take ``axis_logos(target, axis, *,
285
+ league, season=None, height=0.1, variant="default", mark_type="logo", id_system="auto")``, and Plotly and Altair
286
+ also take ``embed``. Bokeh, HoloViews, folium, pygal and great_tables have no axis logos: there it raises
287
+ UnsupportedTargetError (a TypeError).
288
+
289
+ Args:
290
+ target: The plot object. Its type picks the adapter.
291
+ *args: Passed to the adapter: ``axis`` (which axis' tick labels to replace, ``"x"`` or ``"y"``).
292
+ **kwargs: Passed to the adapter: ``league`` (the SDV league key, required), ``season`` (one season for every
293
+ label, default ``None``), ``height`` (the mark's height as a fraction of the plot height, default 0.1),
294
+ and ``variant``, ``mark_type`` and ``id_system`` as in ``logo_url`` and ``resolve``.
295
+
296
+ Returns:
297
+ object: The drawn-on plot: ``target`` itself, or the new object the adapter built.
298
+
299
+ Raises:
300
+ UnsupportedTargetError: (a TypeError) If no adapter is registered for ``target``'s library, its library has no
301
+ axis logos (Bokeh, HoloViews, folium, pygal, great_tables), or the adapter cannot draw on this kind of
302
+ ``target``.
303
+ OptionalDependencyError: (a ModuleNotFoundError) If the adapter's optional extra is not installed, or a mark a
304
+ raster adapter draws is an SVG and the ``svg`` extra is not installed.
305
+ InputError: (a ValueError) If ``height`` is outside (0, 1], or ``league``, ``id_system``, ``mark_type``,
306
+ ``variant`` or ``season`` is not one sdvplot knows.
307
+ ValueError: If ``axis`` is not "x"/"y", or the axis is not one the adapter can read team labels from (its page
308
+ lists the cases).
309
+ OfflineError: If a download the adapter needs (the logo manifest, a mark's image) fails and no cached copy
310
+ exists: a DownloadError (also an OSError) for an HTTP error status, an IntegrityError for a file that does
311
+ not match the manifest's sha256. Which downloads an adapter makes is on its page.
312
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
313
+ redirected away from https.
314
+
315
+ Example:
316
+ ::
317
+
318
+ import matplotlib.pyplot as plt
319
+ import sdvplot
320
+
321
+ fig, ax = plt.subplots()
322
+ ax.bar(["KC", "BUF", "BAL"], [12, 10, 9])
323
+ sdvplot.axis_logos(ax, "x", league="nfl", height=0.08)
324
+
325
+ See Also:
326
+ sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
327
+ sdv-py: https://py.sportsdataverse.org/
328
+ """
329
+ return adapter_for(target).axis_logos(target, *args, **kwargs)
330
+
331
+
332
+ # The adapters sdvplot ships. Registering imports nothing: the adapter module loads on first use.
333
+ register_adapter(Adapter("matplotlib", "matplotlib", "sdvplot.matplotlib", "mpl"))
334
+ register_adapter(Adapter("seaborn", "seaborn", "sdvplot.matplotlib", "mpl"))
335
+ register_adapter(Adapter("plotnine", "plotnine", "sdvplot.plotnine", "plotnine"))
336
+ register_adapter(Adapter("great_tables", "great_tables", "sdvplot.great_tables", "tables"))
337
+ register_adapter(Adapter("plotly", "plotly", "sdvplot.plotly", "plotly"))
338
+ register_adapter(Adapter("altair", "altair", "sdvplot.altair", "altair"))
339
+ register_adapter(Adapter("bokeh", "bokeh", "sdvplot.bokeh", "bokeh"))
340
+ register_adapter(Adapter("holoviews", "holoviews", "sdvplot.holoviews", "holoviews"))
341
+ register_adapter(Adapter("folium", "folium", "sdvplot.folium", "folium"))
342
+ register_adapter(Adapter("pygal", "pygal", "sdvplot.pygal", "pygal"))
sdvplot/_errors.py ADDED
@@ -0,0 +1,93 @@
1
+ """Warning and exception types. Every error subclasses SdvplotError and the builtin a caller would already catch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import sys
8
+ import warnings
9
+ from collections.abc import Iterator
10
+ from types import FrameType
11
+
12
+ _PACKAGE = os.path.dirname(os.path.abspath(__file__)) + os.sep
13
+
14
+
15
+ class SdvplotWarning(UserWarning):
16
+ """Something was skipped or degraded (an unresolved team, a stale cache), but the call still returned."""
17
+
18
+
19
+ class SdvplotDeprecationWarning(SdvplotWarning, FutureWarning):
20
+ """A deprecated sdvplot name or argument: it still works, and the message names its replacement and the release
21
+ that removes it. A FutureWarning, so it shows by default."""
22
+
23
+
24
+ def warn(message: str, category: type[Warning] = SdvplotWarning) -> None:
25
+ """``warnings.warn`` at the first frame outside the sdvplot package: the caller's own line, however deep inside
26
+ sdvplot the warning is raised (a fixed ``stacklevel`` named sdvplot's files whenever the call chain changed)."""
27
+ frame: FrameType | None = sys._getframe(1)
28
+ level = 2
29
+ while frame is not None and frame.f_code.co_filename.startswith(_PACKAGE):
30
+ frame, level = frame.f_back, level + 1
31
+ warnings.warn(message, category, stacklevel=level)
32
+
33
+
34
+ class SdvplotError(Exception):
35
+ """The base of sdvplot's errors: an unresolved team, a failed or refused download, a missing extra, an unsupported
36
+ target, and the shared argument checks (``InputError``). A check specific to one helper (an axis name, a column,
37
+ a chart setting) raises a plain ValueError or TypeError."""
38
+
39
+
40
+ class InputError(SdvplotError, ValueError):
41
+ """An argument sdvplot cannot use: an unknown league, id system, color slot, mark type or variant, a color slot
42
+ such as ``"secondary"`` passed as a team, a season that is not a year or is outside the ones sdvplot knows, or a
43
+ height, alpha, image size or candidate count out of range."""
44
+
45
+
46
+ class UnresolvedTeamError(SdvplotError, ValueError):
47
+ """A team value did not resolve and strict=True was set."""
48
+
49
+
50
+ class OfflineError(SdvplotError, RuntimeError):
51
+ """A download failed and no cached copy exists."""
52
+
53
+
54
+ class DownloadError(OfflineError, OSError):
55
+ """A download got an HTTP error status (a 4xx or 5xx response) and no cached copy exists. Also an OSError, so
56
+ ``except OSError`` catches it."""
57
+
58
+
59
+ class IntegrityError(DownloadError):
60
+ """A download is not the file the manifest promises: its sha256 differs (it is not cached), or the archived file is
61
+ not an image PIL can decode."""
62
+
63
+
64
+ class OptionalDependencyError(SdvplotError, ModuleNotFoundError):
65
+ """A feature needs an optional extra that is not installed: an adapter submodule imported without its library
66
+ (``import sdvplot.plotly`` without plotly), an SVG mark without the svg extra. The message names the extra to
67
+ install."""
68
+
69
+
70
+ @contextlib.contextmanager
71
+ def requires_extra(extra: str) -> Iterator[None]:
72
+ """Around an adapter module's library imports: a missing library is an OptionalDependencyError naming the extra
73
+ (still a ModuleNotFoundError, with the missing module's ``name``) instead of a bare ModuleNotFoundError."""
74
+ try:
75
+ yield
76
+ except ModuleNotFoundError as e:
77
+ raise OptionalDependencyError(
78
+ f'{e.name or "a library"} is not installed: this needs the {extra} extra, pip install "sdvplot[{extra}]"',
79
+ name=e.name,
80
+ ) from e
81
+
82
+
83
+ class UnsupportedTargetError(SdvplotError, TypeError):
84
+ """sdvplot cannot draw on this object: no adapter takes its kind of plot or table, or the adapter does not support
85
+ the verb (axis logos on a map, a table, Bokeh, HoloViews or pygal)."""
86
+
87
+
88
+ class UnsafeCachePathError(SdvplotError, ValueError):
89
+ """A manifest value (sha256, ext) or cache path would reach outside the sdvplot cache directory."""
90
+
91
+
92
+ class UnsafeDownloadError(SdvplotError, OSError):
93
+ """A download was refused: too large, too slow, or redirected away from https."""
sdvplot/_headshots.py ADDED
@@ -0,0 +1,131 @@
1
+ """Player headshot URLs: ESPN athlete ids directly, nflverse gsis ids through nflverse's player table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import io
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from sdvplot._cache import MEMORY_CACHES, fetch_cached, safe_url
10
+ from sdvplot._errors import InputError, warn
11
+ from sdvplot._normalize import norm_value
12
+ from sdvplot._types import HeadshotIdSystem
13
+
14
+ if TYPE_CHECKING:
15
+ import polars as pl
16
+ else:
17
+ from sdvplot._lazy import pl
18
+
19
+ ESPN_HEADSHOT_LEAGUES = {
20
+ "nfl": "nfl",
21
+ "nba": "nba",
22
+ "wnba": "wnba",
23
+ "mlb": "mlb",
24
+ "nhl": "nhl",
25
+ "cfb": "college-football",
26
+ "mbb": "mens-college-basketball",
27
+ "wbb": "womens-college-basketball",
28
+ }
29
+ NFLVERSE_PLAYERS_URL = "https://github.com/nflverse/nflverse-data/releases/download/players/players.parquet"
30
+ PLAYER_COLUMNS = ["gsis_id", "espn_id", "headshot"]
31
+
32
+
33
+ @functools.lru_cache(maxsize=1) # keyed by mtime so a refreshed table is read again; only the current file matters
34
+ def _players(path: str, mtime: float) -> dict[str, tuple[str | None, str | None]]:
35
+ p = pl.read_parquet(path, columns=PLAYER_COLUMNS).drop_nulls("gsis_id")
36
+ players, bad = {}, []
37
+ for g, e, h in p.iter_rows():
38
+ if h and not safe_url(h): # it would reach the web adapters' HTML: treat it as missing instead
39
+ bad.append(h)
40
+ h = None
41
+ players[g] = (e, h)
42
+ if bad:
43
+ warn(
44
+ f"ignored {len(bad)} nflverse headshot URL(s) that are not plain https URLs, e.g. {bad[0]!r:.100}; "
45
+ "those players get their ESPN headshot when nflverse has their ESPN id"
46
+ )
47
+ return players
48
+
49
+
50
+ MEMORY_CACHES.append(_players.cache_clear) # clear_cache() also frees the player table
51
+
52
+
53
+ def _is_valid_espn_id(normalized_id: str) -> bool:
54
+ """Check if a normalized id is all ASCII digits, matching sdvplotR's shape validation."""
55
+ return normalized_id.isascii() and normalized_id.isdigit()
56
+
57
+
58
+ def _espn(player_id: str, league: str) -> str | None:
59
+ if not _is_valid_espn_id(player_id):
60
+ return None
61
+ slug = ESPN_HEADSHOT_LEAGUES[league]
62
+ return f"https://a.espncdn.com/combiner/i?img=/i/headshots/{slug}/players/full/{player_id}.png"
63
+
64
+
65
+ def _transform_nfl_headshot(url: str) -> str:
66
+ """Transform an NFL.com headshot URL.
67
+
68
+ Replace /f_auto,q_auto/ with /t_headshot_desktop/f_auto/, then append .png if needed.
69
+ """
70
+ transformed = url.replace("/f_auto,q_auto/", "/t_headshot_desktop/f_auto/")
71
+ if not transformed.endswith(".png"):
72
+ transformed += ".png"
73
+ return transformed
74
+
75
+
76
+ def headshot_url(player_id: Any, league: str, *, id_system: HeadshotIdSystem = "espn") -> str | None:
77
+ """A headshot URL for one player.
78
+
79
+ Args:
80
+ player_id: One player id: an ESPN athlete id, or an nflverse gsis id.
81
+ league: The SDV league key. "espn" ids work for nfl, nba, wnba, mlb, nhl, cfb, mbb and wbb; "gsis" is NFL only.
82
+ id_system: "espn" (ESPN athlete id, any ESPN league) or "gsis" (mapped to ESPN through nflverse's player table,
83
+ preferring nflverse's own headshot).
84
+
85
+ Returns:
86
+ str | None: The image URL, or None when the id is missing, malformed, or not in the player table.
87
+
88
+ Raises:
89
+ InputError: (a ValueError) If ``league`` has no ESPN headshots or ``id_system`` is not valid for ``league``.
90
+ OfflineError: If ``id_system`` is "gsis" and the nflverse player table cannot be downloaded and no
91
+ cached copy exists (a DownloadError, also an OSError, when GitHub answers with an error status).
92
+ UnsafeDownloadError: (an OSError) If ``id_system`` is "gsis" and the player table download is refused: larger
93
+ than the byte cap, past the deadline, or redirected away from https.
94
+
95
+ Example:
96
+ ::
97
+
98
+ import sdvplot
99
+
100
+ sdvplot.headshot_url("3139477", "nfl")
101
+ # 'https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/3139477.png'
102
+ sdvplot.headshot_url("00-0033873", "nfl", id_system="gsis") # Patrick Mahomes, an nfl.com URL ending .png
103
+
104
+ See Also:
105
+ sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
106
+ sdv-py: https://py.sportsdataverse.org/
107
+ """
108
+ # check the arguments first, so a null id never hides a bad league or id_system
109
+ if id_system == "espn" and league not in ESPN_HEADSHOT_LEAGUES:
110
+ raise InputError(f"no ESPN headshots for league {league!r}; supported: {sorted(ESPN_HEADSHOT_LEAGUES)}")
111
+ if id_system != "espn" and (id_system, league) != ("gsis", "nfl"):
112
+ raise InputError(f"id_system must be 'espn' (any league) or 'gsis' (nfl), got {id_system!r} for {league!r}")
113
+ pid = norm_value(player_id)
114
+ if pid is None:
115
+ return None
116
+ if id_system == "espn":
117
+ return _espn(pid, league)
118
+ path = fetch_cached(
119
+ NFLVERSE_PLAYERS_URL,
120
+ "nflverse/players.parquet",
121
+ validate=lambda body: pl.read_parquet(io.BytesIO(body), columns=PLAYER_COLUMNS),
122
+ )
123
+ row = _players(str(path), path.stat().st_mtime).get(str(player_id).strip())
124
+ if row is None:
125
+ return None
126
+ espn_id, headshot = row
127
+ if headshot:
128
+ return _transform_nfl_headshot(headshot)
129
+ if espn_id:
130
+ return _espn(espn_id, "nfl")
131
+ return None