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/testing.py
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""Shared behaviour every adapter must have. Adapter test suites call check_adapter_contract().
|
|
2
|
+
|
|
3
|
+
An adapter module must expose add_logos, add_wordmarks, add_headshots and axis_logos, and the test hook
|
|
4
|
+
|
|
5
|
+
_drawn_marks(target) -> list[tuple]
|
|
6
|
+
|
|
7
|
+
returning one tuple per drawn image, in draw order: (team_id, x, y, height) or (team_id, x, y, height, url). team_id
|
|
8
|
+
is the canonical team id (str; the player id for headshots), x and y are the caller's position values for that image,
|
|
9
|
+
height is the fraction of the plot height the image was drawn at, and url, when present, is the image source the
|
|
10
|
+
adapter used (so the harness can check it drew the right mark). The hook measures height from what was drawn, never
|
|
11
|
+
from the value the adapter was asked for or stored: the image's extent after a draw (matplotlib, plotnine), the size
|
|
12
|
+
in the emitted spec or document (Plotly layout images, Vega-Lite marks, Bokeh and folium sizes), or the rendered SVG
|
|
13
|
+
(pygal). The harness compares heights within 1% (relative), which absorbs pixel and attribute
|
|
14
|
+
rounding (pygal writes three decimals) but not an ignored height or a wrong reference height.
|
|
15
|
+
|
|
16
|
+
An adapter that draws axis logos sets _SUPPORTS_AXIS_LOGOS = True (the default when the attribute is absent) and
|
|
17
|
+
exposes two more hooks:
|
|
18
|
+
|
|
19
|
+
_drawn_axis_marks(target, axis) -> list[tuple[str, Any, float]] # (team_id, tick position, height), tick order
|
|
20
|
+
_visible_axis_labels(target, axis) -> list[str] # the tick labels still shown as text
|
|
21
|
+
|
|
22
|
+
where height is measured as for _drawn_marks.
|
|
23
|
+
|
|
24
|
+
An adapter that cannot (a map, a table) sets _SUPPORTS_AXIS_LOGOS = False and raises TypeError from axis_logos.
|
|
25
|
+
|
|
26
|
+
add_* must return the object that was drawn on. Libraries that mutate the target (matplotlib) return it; libraries that
|
|
27
|
+
build a new object (plotnine, altair, tables) return the new one. The harness reads hooks from the returned object when
|
|
28
|
+
it is not None, and from the target otherwise.
|
|
29
|
+
|
|
30
|
+
The contract can fail: each rule below raises an AssertionError whose message starts with "rule N". The rules use
|
|
31
|
+
coordinates that are not row positions (x=[10, 20], y=[-3, -7]) so swapped, shared or positional x/y are caught.
|
|
32
|
+
|
|
33
|
+
Warnings are counted over one call and one read of the drawn marks (an adapter that resolves at render time, such as
|
|
34
|
+
plotnine, warns there): a call that skips nothing gives no SdvplotWarning, and each reason a call skips points for
|
|
35
|
+
(unknown teams, unknown player ids, ...) gives exactly one, however many values, layers or marks it covers.
|
|
36
|
+
|
|
37
|
+
0. Registration: sdvplot's front door routes make_target() to this adapter.
|
|
38
|
+
1. Resolution: the canonical ids of the teams passed, at their own x/y (and the team's own mark, when url is drawn),
|
|
39
|
+
with no warning.
|
|
40
|
+
2. Warn and skip: an unknown team is dropped together with its own x/y, with exactly one SdvplotWarning per call
|
|
41
|
+
(one unknown or several), never a raise.
|
|
42
|
+
3. pandas/polars parity: a pandas Series with a non-default index draws the same marks as a polars Series.
|
|
43
|
+
4. Height semantics: on every verb (add_logos here; rules 5-7 for the others), height is the fraction of the plot
|
|
44
|
+
height drawn, measured by the hooks; 0 and values above 1 raise ValueError when the verb is called (not later,
|
|
45
|
+
when the marks are rendered).
|
|
46
|
+
5. Wordmarks: add_wordmarks follows rules 1, 2 and 4.
|
|
47
|
+
6. Headshots: add_headshots draws player ids at their own x/y, skips an unknown id with exactly one warning, and
|
|
48
|
+
follows rule 4.
|
|
49
|
+
7. Axis logos: known team categories become images in tick order, an unknown one gives exactly one warning and stays
|
|
50
|
+
readable text, and the images follow rule 4; or, without axis support, axis_logos raises TypeError.
|
|
51
|
+
8. Alpha: on every verb that takes alpha (add_logos, add_wordmarks, add_headshots, and axis_logos when its signature
|
|
52
|
+
has alpha), alpha outside [0, 1] raises ValueError.
|
|
53
|
+
|
|
54
|
+
Table adapters (great_tables) have rows, columns and pixel heights instead, so they get their own harness,
|
|
55
|
+
check_table_adapter_contract(), with rules T0-T6 (messages start with "rule T<n>"). A table adapter's add_* take
|
|
56
|
+
(table, columns, *, league, height=<pixels>) and return the new table, and it exposes two hooks:
|
|
57
|
+
|
|
58
|
+
_drawn_cells(table) -> list[tuple] # (team_id, row, column, height_px[, src]) per image, in display order
|
|
59
|
+
_rendered_html(table) -> str # the table as it renders
|
|
60
|
+
|
|
61
|
+
In _drawn_cells, row is the 0-based display row of a body cell and -1 for a column label.
|
|
62
|
+
|
|
63
|
+
T0. Registration: the front door routes the table to this adapter.
|
|
64
|
+
T1. Resolution: a column ["LV", "LAR"] renders both teams' canonical ids at rows 0 and 1 (and their own marks),
|
|
65
|
+
with no warning.
|
|
66
|
+
T2. Warn and keep: ["XXX", "LV"] renders LV at row 1, keeps "XXX" as text, warns exactly once when called and never
|
|
67
|
+
when rendered; all-unknown input renders no image and also warns exactly once.
|
|
68
|
+
T3. pandas/polars parity: a pandas frame with a non-default index renders the same cells as a polars frame.
|
|
69
|
+
T4. Height: height=24 is 24 px on every image (_drawn_cells reads it from the rendered HTML); 0, negative,
|
|
70
|
+
fractional (below 1 px: a plot's unit, not a table's) and non-numeric heights raise ValueError.
|
|
71
|
+
T5. Wordmarks: add_wordmarks satisfies T1-T4.
|
|
72
|
+
T6. Headshots: add_headshots satisfies T1-T4 for player ids.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
from __future__ import annotations
|
|
76
|
+
|
|
77
|
+
import inspect
|
|
78
|
+
import math
|
|
79
|
+
import warnings
|
|
80
|
+
from collections.abc import Callable, Sequence
|
|
81
|
+
from types import ModuleType
|
|
82
|
+
from typing import Any
|
|
83
|
+
|
|
84
|
+
from sdvplot._errors import SdvplotWarning
|
|
85
|
+
from sdvplot._resolve import resolve
|
|
86
|
+
|
|
87
|
+
_HEIGHT_TOLERANCE = 0.01 # relative; heights are measured from what was drawn, so rounding must pass
|
|
88
|
+
|
|
89
|
+
__all__ = ["check_adapter_contract", "check_table_adapter_contract"]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def __dir__() -> list[str]: # dir() and tab completion show the public API only
|
|
93
|
+
return list(__all__)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _fail(rule: str, msg: str) -> None:
|
|
97
|
+
raise AssertionError(f"{rule}: {msg}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _count(rec: list[warnings.WarningMessage]) -> int:
|
|
101
|
+
return sum(issubclass(w.category, SdvplotWarning) for w in rec)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _draw(
|
|
105
|
+
adapter: ModuleType, verb: str, target: Any, read: Callable[[Any], Any], *args: Any, **kw: Any
|
|
106
|
+
) -> tuple[Any, list[tuple[Any, ...]], int]:
|
|
107
|
+
"""adapter.<verb>(target, ...), then ``read`` (a hook) on what was drawn on: (drawn object, marks, warnings).
|
|
108
|
+
|
|
109
|
+
The drawn object is the one the verb returns (None means it mutated the target in place). The warning count is
|
|
110
|
+
the SdvplotWarnings raised by the call and the read together. Exceptions propagate.
|
|
111
|
+
"""
|
|
112
|
+
with warnings.catch_warnings(record=True) as rec:
|
|
113
|
+
warnings.simplefilter("always")
|
|
114
|
+
out = getattr(adapter, verb)(target, *args, **kw)
|
|
115
|
+
drawn = target if out is None else out
|
|
116
|
+
marks = [tuple(m) for m in read(drawn)]
|
|
117
|
+
return drawn, marks, _count(rec)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _call(
|
|
121
|
+
adapter: ModuleType, verb: str, make_target: Callable[[], Any], rule: str, *args: Any, **kw: Any
|
|
122
|
+
) -> tuple[list[tuple[Any, ...]], int]:
|
|
123
|
+
"""Run adapter.<verb> on a fresh target; return (drawn marks, warnings). A raise becomes a named AssertionError."""
|
|
124
|
+
try:
|
|
125
|
+
_, marks, count = _draw(adapter, verb, make_target(), adapter._drawn_marks, *args, **kw)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
raise AssertionError(f"{rule}: {verb} raised {e!r}") from e
|
|
128
|
+
return marks, count
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _once(rule: str, count: int, what: str) -> None:
|
|
132
|
+
if count != 1:
|
|
133
|
+
_fail(rule, f"{what} must give exactly one SdvplotWarning per call, gave {count}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _quiet(rule: str, count: int, what: str) -> None:
|
|
137
|
+
if count:
|
|
138
|
+
_fail(rule, f"{what} must not warn when nothing is skipped, warned {count} times")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _xyz(marks: list[tuple[Any, ...]]) -> list[tuple[Any, ...]]:
|
|
142
|
+
return [m[:3] for m in marks]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _check_urls(rule: str, marks: list[tuple[Any, ...]], want: dict[str | None, Callable[[], str | None]]) -> None:
|
|
146
|
+
"""Only adapters that report the url (5-tuples) are checked; the expected url is looked up lazily."""
|
|
147
|
+
for m in marks:
|
|
148
|
+
if len(m) >= 5 and m[0] in want and m[4] != (expected := want[m[0]]()):
|
|
149
|
+
_fail(rule, f"{m[0]!r} was drawn with {m[4]!r}, expected its mark {expected!r}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _check_marks(
|
|
153
|
+
adapter: ModuleType,
|
|
154
|
+
verb: str,
|
|
155
|
+
make_target: Callable[[], Any],
|
|
156
|
+
league: str,
|
|
157
|
+
pair: Sequence[str],
|
|
158
|
+
ids: Sequence[str | None],
|
|
159
|
+
urls: dict[str | None, Callable[[], str | None]],
|
|
160
|
+
names: tuple[str, str],
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Rules 1 and 2 (named by ``names``) for one verb."""
|
|
163
|
+
r1, r2 = names
|
|
164
|
+
a, b = pair
|
|
165
|
+
id_a, id_b = ids
|
|
166
|
+
xs, ys = [10.0, 20.0], [-3.0, -7.0]
|
|
167
|
+
want_two = [(id_a, 10, -3), (id_b, 20, -7)]
|
|
168
|
+
|
|
169
|
+
marks, count = _call(adapter, verb, make_target, r1, xs, ys, [a, b], league=league)
|
|
170
|
+
if _xyz(marks) != want_two:
|
|
171
|
+
_fail(r1, f"expected marks {want_two}, drew {_xyz(marks)}")
|
|
172
|
+
_quiet(r1, count, "known teams")
|
|
173
|
+
_check_urls(r1, marks, urls)
|
|
174
|
+
|
|
175
|
+
marks, count = _call(adapter, verb, make_target, r2, xs, ys, ["XXX", a], league=league)
|
|
176
|
+
if _xyz(marks) != [(id_a, 20, -7)]:
|
|
177
|
+
_fail(
|
|
178
|
+
r2, f"an unknown team must be skipped with its own x/y; expected [({id_a!r}, 20, -7)], drew {_xyz(marks)}"
|
|
179
|
+
)
|
|
180
|
+
_once(r2, count, "an unknown team")
|
|
181
|
+
marks, count = _call(adapter, verb, make_target, r2, xs, ys, ["XXX", "YYY"], league=league)
|
|
182
|
+
if marks:
|
|
183
|
+
_fail(r2, f"all-unknown input must draw nothing, drew {marks}")
|
|
184
|
+
_once(r2, count, "all-unknown input")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _check_height(rule: str, verb: str, heights: Callable[[float, bool], list[float]]) -> None:
|
|
188
|
+
"""Rule 4 (named by ``rule``) for one verb. ``heights(h, read)`` calls the verb with height=h on a fresh target
|
|
189
|
+
and, when ``read``, returns the heights the hook measured: each must be h (within _HEIGHT_TOLERANCE). 0 and values
|
|
190
|
+
above 1 must raise ValueError from the call itself, not later when the marks are read (rendered)."""
|
|
191
|
+
for h in (0.1, 0.25):
|
|
192
|
+
try:
|
|
193
|
+
got = heights(h, True)
|
|
194
|
+
except Exception as e:
|
|
195
|
+
raise AssertionError(f"{rule}: {verb} raised {e!r}") from e
|
|
196
|
+
if not got or not all(math.isclose(g, h, rel_tol=_HEIGHT_TOLERANCE) for g in got):
|
|
197
|
+
_fail(rule, f"height={h} must be the height of every mark {verb} draws, it drew heights {got}")
|
|
198
|
+
for bad in (0, 1.5):
|
|
199
|
+
try:
|
|
200
|
+
heights(bad, False)
|
|
201
|
+
except ValueError:
|
|
202
|
+
continue
|
|
203
|
+
_fail(rule, f"height={bad} must raise ValueError from {verb} (height is a fraction in (0, 1])")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _check_alpha(rule: str, verb: str, draw: Callable[[float], Any]) -> None:
|
|
207
|
+
"""Rule 8 for one verb: ``draw(alpha)`` draws with that alpha on a fresh target; outside [0, 1] it must raise."""
|
|
208
|
+
for bad in (-0.1, 1.5):
|
|
209
|
+
try:
|
|
210
|
+
draw(bad)
|
|
211
|
+
except ValueError:
|
|
212
|
+
continue
|
|
213
|
+
_fail(rule, f"alpha={bad} must raise ValueError from {verb} (alpha is an opacity in [0, 1])")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def check_adapter_contract( # noqa: C901 - one short block per contract rule, run in order (R0 to R9)
|
|
217
|
+
adapter: ModuleType,
|
|
218
|
+
make_target: Callable[[], Any],
|
|
219
|
+
*,
|
|
220
|
+
league: str = "nfl",
|
|
221
|
+
known: tuple[str, str] = ("LV", "LAR"),
|
|
222
|
+
known_wordmarks: tuple[str, str] = ("LV", "LAC"),
|
|
223
|
+
players: tuple[str, str] = ("3139477", "4241479"),
|
|
224
|
+
make_axis_target: Callable[[list[str]], Any] | None = None,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Raise an AssertionError naming the broken rule if the adapter breaks the sdvplot adapter contract.
|
|
227
|
+
|
|
228
|
+
The rules, and the test hooks the adapter must expose, are in this module's docstring.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
adapter: The adapter module under test (it exposes ``add_logos``, ``add_wordmarks``, ``add_headshots``,
|
|
232
|
+
``axis_logos`` and the ``_drawn_marks`` hook).
|
|
233
|
+
make_target: Builds a fresh target (a plot) to draw on each time it is called.
|
|
234
|
+
league: The SDV league key the checks run in.
|
|
235
|
+
known: Two team ids of ``league`` that have logos.
|
|
236
|
+
known_wordmarks: Two team ids of ``league`` that have wordmarks.
|
|
237
|
+
players: Two player ids of ``league`` that have headshots.
|
|
238
|
+
make_axis_target: ``make_axis_target(categories)`` builds a target whose x axis shows those categories, in
|
|
239
|
+
order. Required when the adapter supports axis logos (rule 7).
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
None: Returns normally when every rule holds.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
AssertionError: Naming the broken rule ("rule N: ..."), when the adapter breaks it.
|
|
246
|
+
UnsupportedTargetError: (a TypeError) If no adapter is registered for the library of ``make_target()``'s object
|
|
247
|
+
(register the adapter before checking it).
|
|
248
|
+
InputError: (a ValueError) If ``league`` is unknown.
|
|
249
|
+
OfflineError: If the logo manifest, which the checks read for the expected marks, is neither cached nor
|
|
250
|
+
downloadable (a DownloadError, also an OSError, when the CDN answers with an error status). A download error
|
|
251
|
+
inside an adapter call is reported as the AssertionError of the rule that made the call.
|
|
252
|
+
UnsafeDownloadError: (an OSError) If the manifest download is refused: larger than the byte cap, past the
|
|
253
|
+
deadline, or redirected away from https.
|
|
254
|
+
|
|
255
|
+
Example:
|
|
256
|
+
::
|
|
257
|
+
|
|
258
|
+
import matplotlib.pyplot as plt
|
|
259
|
+
from sdvplot import matplotlib as adapter
|
|
260
|
+
from sdvplot.testing import check_adapter_contract
|
|
261
|
+
|
|
262
|
+
def make_target():
|
|
263
|
+
_, ax = plt.subplots()
|
|
264
|
+
ax.set_xlim(0, 30)
|
|
265
|
+
ax.set_ylim(-10, 0)
|
|
266
|
+
return ax
|
|
267
|
+
|
|
268
|
+
def make_axis_target(categories):
|
|
269
|
+
_, ax = plt.subplots()
|
|
270
|
+
ax.bar(categories, range(1, len(categories) + 1))
|
|
271
|
+
return ax
|
|
272
|
+
|
|
273
|
+
check_adapter_contract(adapter, make_target=make_target, make_axis_target=make_axis_target)
|
|
274
|
+
|
|
275
|
+
See Also:
|
|
276
|
+
Add an adapter: https://sdvplot.sportsdataverse.org/docs/adapters/add-an-adapter ;
|
|
277
|
+
sdvplot.testing.check_table_adapter_contract: the harness for table adapters
|
|
278
|
+
"""
|
|
279
|
+
import pandas as pd
|
|
280
|
+
import polars as pl
|
|
281
|
+
|
|
282
|
+
from sdvplot._dispatch import adapter_for
|
|
283
|
+
from sdvplot._headshots import headshot_url
|
|
284
|
+
from sdvplot._marks import logo_url
|
|
285
|
+
|
|
286
|
+
# rule 0: the front door reaches this adapter
|
|
287
|
+
r0 = "rule 0 (registration)"
|
|
288
|
+
routed = adapter_for(make_target())
|
|
289
|
+
if routed is not adapter:
|
|
290
|
+
_fail(r0, f"sdvplot routes this target to {routed.__name__}, not {adapter.__name__}")
|
|
291
|
+
|
|
292
|
+
a, b = known
|
|
293
|
+
id_a, id_b = resolve([a, b], league)
|
|
294
|
+
urls = {id_a: lambda: logo_url(a, league), id_b: lambda: logo_url(b, league)}
|
|
295
|
+
_check_marks(
|
|
296
|
+
adapter, "add_logos", make_target, league, known, (id_a, id_b), urls,
|
|
297
|
+
("rule 1 (resolution)", "rule 2 (unknown team: warn and skip)"),
|
|
298
|
+
) # fmt: skip
|
|
299
|
+
|
|
300
|
+
# rule 3: pandas (non-default index) and polars inputs draw identical marks
|
|
301
|
+
r3 = "rule 3 (pandas/polars parity)"
|
|
302
|
+
xs, ys = [10.0, 20.0], [-3.0, -7.0]
|
|
303
|
+
want_two = [(id_a, 10, -3), (id_b, 20, -7)]
|
|
304
|
+
pmarks, _ = _call(
|
|
305
|
+
adapter, "add_logos", make_target, r3,
|
|
306
|
+
pd.Series(xs, index=[5, 6]), pd.Series(ys, index=[5, 6]), pd.Series([a, b], index=[5, 6]), league=league,
|
|
307
|
+
) # fmt: skip
|
|
308
|
+
lmarks, _ = _call(
|
|
309
|
+
adapter, "add_logos", make_target, r3, pl.Series(xs), pl.Series(ys), pl.Series([a, b]), league=league
|
|
310
|
+
)
|
|
311
|
+
if _xyz(pmarks) != _xyz(lmarks) or _xyz(lmarks) != want_two:
|
|
312
|
+
_fail(r3, f"pandas (index [5, 6]) drew {_xyz(pmarks)}, polars drew {_xyz(lmarks)}, expected {want_two}")
|
|
313
|
+
|
|
314
|
+
def heights(verb: str, values: Sequence[str]) -> Callable[[float, bool], list[float]]:
|
|
315
|
+
def draw(h: float, read: bool) -> list[float]:
|
|
316
|
+
_, marks, _ = _draw(adapter, verb, make_target(), adapter._drawn_marks if read else lambda _: [], xs, ys,
|
|
317
|
+
list(values), league=league, height=h) # fmt: skip
|
|
318
|
+
return [m[3] for m in marks]
|
|
319
|
+
|
|
320
|
+
return draw
|
|
321
|
+
|
|
322
|
+
def with_alpha(verb: str, value: str) -> Callable[[float], Any]:
|
|
323
|
+
return lambda alpha: _draw(adapter, verb, make_target(), lambda _: [], xs[:1], ys[:1], [value],
|
|
324
|
+
league=league, alpha=alpha) # fmt: skip
|
|
325
|
+
|
|
326
|
+
_check_height("rule 4 (height semantics)", "add_logos", heights("add_logos", known))
|
|
327
|
+
|
|
328
|
+
# rule 5: wordmarks follow rules 1, 2 and 4
|
|
329
|
+
wa, wb = known_wordmarks
|
|
330
|
+
wid_a, wid_b = resolve([wa, wb], league)
|
|
331
|
+
wurls = {
|
|
332
|
+
wid_a: lambda: logo_url(wa, league, mark_type="wordmark"),
|
|
333
|
+
wid_b: lambda: logo_url(wb, league, mark_type="wordmark"),
|
|
334
|
+
}
|
|
335
|
+
_check_marks(
|
|
336
|
+
adapter, "add_wordmarks", make_target, league, known_wordmarks, (wid_a, wid_b), wurls,
|
|
337
|
+
("rule 5 (wordmarks: resolution)", "rule 5 (wordmarks: warn and skip)"),
|
|
338
|
+
) # fmt: skip
|
|
339
|
+
_check_height("rule 5 (wordmarks: height)", "add_wordmarks", heights("add_wordmarks", known_wordmarks))
|
|
340
|
+
|
|
341
|
+
# rule 6: headshots
|
|
342
|
+
r6 = "rule 6 (headshots)"
|
|
343
|
+
p, q = players
|
|
344
|
+
marks, count = _call(adapter, "add_headshots", make_target, r6, xs, ys, [p, q], league=league)
|
|
345
|
+
if _xyz(marks) != [(p, 10, -3), (q, 20, -7)]:
|
|
346
|
+
_fail(r6, f"expected headshots [({p!r}, 10, -3), ({q!r}, 20, -7)], drew {_xyz(marks)}")
|
|
347
|
+
_quiet(r6, count, "known player ids")
|
|
348
|
+
_check_urls(r6, marks, {p: lambda: headshot_url(p, league), q: lambda: headshot_url(q, league)})
|
|
349
|
+
marks, count = _call(adapter, "add_headshots", make_target, r6, xs, ys, ["not-an-id", p], league=league)
|
|
350
|
+
if _xyz(marks) != [(p, 20, -7)]:
|
|
351
|
+
_fail(r6, f"an unknown player id must be skipped with its own x/y, drew {_xyz(marks)}")
|
|
352
|
+
_once(r6, count, "an unknown player id")
|
|
353
|
+
_check_height("rule 6 (headshots: height)", "add_headshots", heights("add_headshots", players))
|
|
354
|
+
|
|
355
|
+
# rule 7: axis logos
|
|
356
|
+
r7 = "rule 7 (axis logos)"
|
|
357
|
+
alpha_draws = {verb: with_alpha(verb, value) for verb, value in
|
|
358
|
+
(("add_logos", a), ("add_wordmarks", wa), ("add_headshots", p))} # fmt: skip
|
|
359
|
+
if getattr(adapter, "_SUPPORTS_AXIS_LOGOS", True):
|
|
360
|
+
if make_axis_target is None:
|
|
361
|
+
_fail(r7, "make_axis_target is required for an adapter that supports axis logos")
|
|
362
|
+
assert make_axis_target is not None
|
|
363
|
+
make_axis = make_axis_target
|
|
364
|
+
|
|
365
|
+
def read_axis(drawn: Any) -> Any:
|
|
366
|
+
return adapter._drawn_axis_marks(drawn, "x")
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
drawn, axis_marks, count = _draw(adapter, "axis_logos", make_axis([a, "XXX", b]), read_axis, "x",
|
|
370
|
+
league=league) # fmt: skip
|
|
371
|
+
except Exception as e:
|
|
372
|
+
raise AssertionError(f"{r7}: axis_logos raised {e!r}") from e
|
|
373
|
+
with warnings.catch_warnings(): # counted above: a hook that renders again (plotnine) warns again
|
|
374
|
+
warnings.simplefilter("ignore", SdvplotWarning)
|
|
375
|
+
shown = list(adapter._visible_axis_labels(drawn, "x"))
|
|
376
|
+
if [m[0] for m in axis_marks] != [id_a, id_b]:
|
|
377
|
+
_fail(r7, f"expected axis images for [{id_a!r}, {id_b!r}] in tick order, drew {axis_marks}")
|
|
378
|
+
if not axis_marks[0][1] < axis_marks[1][1]:
|
|
379
|
+
_fail(r7, f"axis images must sit at increasing tick positions, got {axis_marks}")
|
|
380
|
+
_once(r7, count, "an unknown axis category")
|
|
381
|
+
if "XXX" not in shown or a in shown or b in shown:
|
|
382
|
+
_fail(r7, f"only the unknown category may stay as text, visible labels are {shown}")
|
|
383
|
+
|
|
384
|
+
def axis_heights(h: float, read: bool) -> list[float]:
|
|
385
|
+
_, marks, _ = _draw(adapter, "axis_logos", make_axis([a, b]), read_axis if read else lambda _: [], "x",
|
|
386
|
+
league=league, height=h) # fmt: skip
|
|
387
|
+
return [m[2] for m in marks]
|
|
388
|
+
|
|
389
|
+
_check_height("rule 7 (axis logos: height)", "axis_logos", axis_heights)
|
|
390
|
+
if "alpha" in inspect.signature(adapter.axis_logos).parameters: # no shipped adapter's axis logos take alpha
|
|
391
|
+
alpha_draws["axis_logos"] = lambda alpha: _draw(adapter, "axis_logos", make_axis([a, b]), lambda _: [],
|
|
392
|
+
"x", league=league, alpha=alpha) # fmt: skip
|
|
393
|
+
else:
|
|
394
|
+
try:
|
|
395
|
+
adapter.axis_logos(make_target(), "x", league=league)
|
|
396
|
+
except TypeError:
|
|
397
|
+
pass
|
|
398
|
+
else:
|
|
399
|
+
_fail(r7, "an adapter without axis-logo support must raise TypeError from axis_logos")
|
|
400
|
+
|
|
401
|
+
# rule 8: alpha, on every verb that takes it
|
|
402
|
+
for verb, draw in alpha_draws.items():
|
|
403
|
+
_check_alpha("rule 8 (alpha)", verb, draw)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _table_call(
|
|
407
|
+
adapter: ModuleType, verb: str, make_table: Callable[[Any], Any], rule: str, frame: Any, **kw: Any
|
|
408
|
+
) -> tuple[list[tuple[Any, ...]], int, int, str]:
|
|
409
|
+
"""Run adapter.<verb> on the "team" column of a table built from frame; return (cells, warnings when called,
|
|
410
|
+
warnings when rendered, rendered html). A raise becomes a named AssertionError."""
|
|
411
|
+
t = make_table(frame)
|
|
412
|
+
with warnings.catch_warnings(record=True) as called:
|
|
413
|
+
warnings.simplefilter("always")
|
|
414
|
+
try:
|
|
415
|
+
out = getattr(adapter, verb)(t, "team", **kw)
|
|
416
|
+
except Exception as e:
|
|
417
|
+
raise AssertionError(f"{rule}: {verb} raised {e!r}") from e
|
|
418
|
+
drawn = t if out is None else out
|
|
419
|
+
with warnings.catch_warnings(record=True) as rendered:
|
|
420
|
+
warnings.simplefilter("always")
|
|
421
|
+
cells = [tuple(c) for c in adapter._drawn_cells(drawn)]
|
|
422
|
+
text = adapter._rendered_html(drawn)
|
|
423
|
+
count = sum(issubclass(w.category, SdvplotWarning) for w in called)
|
|
424
|
+
late = sum(issubclass(w.category, SdvplotWarning) for w in rendered)
|
|
425
|
+
return cells, count, late, text
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _check_table_verb(
|
|
429
|
+
adapter: ModuleType,
|
|
430
|
+
verb: str,
|
|
431
|
+
make_table: Callable[[Any], Any],
|
|
432
|
+
league: str,
|
|
433
|
+
pair: Sequence[str],
|
|
434
|
+
ids: Sequence[str | None],
|
|
435
|
+
unknown: str,
|
|
436
|
+
urls: dict[str | None, Callable[[], str | None]],
|
|
437
|
+
rules: tuple[str, str, str, str],
|
|
438
|
+
) -> None:
|
|
439
|
+
"""Rules T1-T4 (named by ``rules``) for one verb."""
|
|
440
|
+
import pandas as pd
|
|
441
|
+
import polars as pl
|
|
442
|
+
|
|
443
|
+
r1, r2, r3, r4 = rules
|
|
444
|
+
a, b = pair
|
|
445
|
+
want = [(ids[0], 0, "team"), (ids[1], 1, "team")]
|
|
446
|
+
|
|
447
|
+
cells, count, late, _ = _table_call(adapter, verb, make_table, r1, pl.DataFrame({"team": [a, b], "v": [1, 2]}),
|
|
448
|
+
league=league) # fmt: skip
|
|
449
|
+
if [c[:3] for c in cells] != want:
|
|
450
|
+
_fail(r1, f"expected cells {want}, rendered {[c[:3] for c in cells]}")
|
|
451
|
+
if count or late:
|
|
452
|
+
_fail(r1, f"known values must not warn, warned {count} times when called and {late} when rendered")
|
|
453
|
+
_check_urls(r1, cells, urls)
|
|
454
|
+
|
|
455
|
+
frame = pl.DataFrame({"team": [unknown, a], "v": [1, 2]})
|
|
456
|
+
cells, count, late, text = _table_call(adapter, verb, make_table, r2, frame, league=league)
|
|
457
|
+
if [c[:3] for c in cells] != [(ids[0], 1, "team")]:
|
|
458
|
+
_fail(r2, f"expected only [({ids[0]!r}, 1, 'team')], rendered {[c[:3] for c in cells]}")
|
|
459
|
+
if count != 1:
|
|
460
|
+
_fail(r2, f"an unknown value must warn exactly once when called, warned {count} times")
|
|
461
|
+
if late:
|
|
462
|
+
_fail(r2, "rendering must not warn: unknown values are reported when the function is called")
|
|
463
|
+
if unknown not in text:
|
|
464
|
+
_fail(r2, f"an unknown value must stay as text, {unknown!r} is not in the rendered table")
|
|
465
|
+
frame = pl.DataFrame({"team": [unknown, unknown + "2"], "v": [1, 2]})
|
|
466
|
+
cells, count, _, _ = _table_call(adapter, verb, make_table, r2, frame, league=league)
|
|
467
|
+
if cells:
|
|
468
|
+
_fail(r2, f"all-unknown input must render no image, rendered {cells}")
|
|
469
|
+
if count != 1:
|
|
470
|
+
_fail(r2, f"all-unknown input must warn exactly once when called, warned {count} times")
|
|
471
|
+
|
|
472
|
+
pcells, _, _, _ = _table_call(adapter, verb, make_table, r3,
|
|
473
|
+
pd.DataFrame({"team": [a, b], "v": [1, 2]}, index=[5, 6]), league=league) # fmt: skip
|
|
474
|
+
lcells, _, _, _ = _table_call(adapter, verb, make_table, r3, pl.DataFrame({"team": [a, b], "v": [1, 2]}),
|
|
475
|
+
league=league) # fmt: skip
|
|
476
|
+
if pcells != lcells or [c[:3] for c in lcells] != want:
|
|
477
|
+
_fail(r3, f"pandas (index [5, 6]) rendered {pcells}, polars rendered {lcells}, expected {want}")
|
|
478
|
+
|
|
479
|
+
cells, _, _, _ = _table_call(adapter, verb, make_table, r4, pl.DataFrame({"team": [a, b]}), league=league,
|
|
480
|
+
height=24) # fmt: skip
|
|
481
|
+
if len(cells) != 2 or not all(math.isclose(c[3], 24) for c in cells):
|
|
482
|
+
_fail(r4, f"height=24 must be 24 px on every image, rendered heights {[c[3] for c in cells]}")
|
|
483
|
+
for bad in (0, -5, 0.5, "30px"):
|
|
484
|
+
try:
|
|
485
|
+
getattr(adapter, verb)(make_table(pl.DataFrame({"team": [a]})), "team", league=league, height=bad)
|
|
486
|
+
except ValueError:
|
|
487
|
+
pass
|
|
488
|
+
else:
|
|
489
|
+
_fail(r4, f"height={bad!r} must raise ValueError (height is a number of pixels, at least 1)")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def check_table_adapter_contract(
|
|
493
|
+
adapter: ModuleType,
|
|
494
|
+
make_table: Callable[[Any], Any],
|
|
495
|
+
*,
|
|
496
|
+
league: str = "nfl",
|
|
497
|
+
known: tuple[str, str] = ("LV", "LAR"),
|
|
498
|
+
known_wordmarks: tuple[str, str] = ("LV", "LAC"),
|
|
499
|
+
players: tuple[str, str] = ("3139477", "4241479"),
|
|
500
|
+
) -> None:
|
|
501
|
+
"""Raise an AssertionError naming the broken rule (T0-T6) if a table adapter breaks the table contract.
|
|
502
|
+
|
|
503
|
+
The rules and the ``_drawn_cells`` and ``_rendered_html`` hooks are in this module's docstring.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
adapter: The table adapter module under test (it exposes ``add_logos``, ``add_wordmarks``, ``add_headshots``
|
|
507
|
+
and the two hooks).
|
|
508
|
+
make_table: ``make_table(frame)`` builds the adapter's table from a pandas or polars DataFrame (for
|
|
509
|
+
great_tables: ``GT``).
|
|
510
|
+
league: The SDV league key the checks run in.
|
|
511
|
+
known: Two team ids of ``league`` that have logos.
|
|
512
|
+
known_wordmarks: Two team ids of ``league`` that have wordmarks.
|
|
513
|
+
players: Two player ids of ``league`` that have headshots.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
None: Returns normally when every rule holds.
|
|
517
|
+
|
|
518
|
+
Raises:
|
|
519
|
+
AssertionError: Naming the broken rule ("rule T<n>: ..."), when the adapter breaks it.
|
|
520
|
+
InputError: (a ValueError) If ``league`` is unknown.
|
|
521
|
+
OfflineError: If the logo manifest, which the checks read for the expected marks, is neither cached nor
|
|
522
|
+
downloadable (a DownloadError, also an OSError, when the CDN answers with an error status). A download error
|
|
523
|
+
inside an adapter call is reported as the AssertionError of the rule that made the call.
|
|
524
|
+
UnsafeDownloadError: (an OSError) If the manifest download is refused: larger than the byte cap, past the
|
|
525
|
+
deadline, or redirected away from https.
|
|
526
|
+
|
|
527
|
+
Example:
|
|
528
|
+
::
|
|
529
|
+
|
|
530
|
+
from great_tables import GT
|
|
531
|
+
from sdvplot import great_tables as adapter
|
|
532
|
+
from sdvplot.testing import check_table_adapter_contract
|
|
533
|
+
|
|
534
|
+
check_table_adapter_contract(adapter, make_table=GT)
|
|
535
|
+
|
|
536
|
+
See Also:
|
|
537
|
+
Add an adapter: https://sdvplot.sportsdataverse.org/docs/adapters/add-an-adapter ;
|
|
538
|
+
sdvplot.testing.check_adapter_contract: the harness for plot adapters
|
|
539
|
+
"""
|
|
540
|
+
import polars as pl
|
|
541
|
+
|
|
542
|
+
from sdvplot._dispatch import adapter_for
|
|
543
|
+
from sdvplot._headshots import headshot_url
|
|
544
|
+
from sdvplot._marks import logo_url
|
|
545
|
+
|
|
546
|
+
r0 = "rule T0 (registration)"
|
|
547
|
+
try:
|
|
548
|
+
routed = adapter_for(make_table(pl.DataFrame({"team": list(known)})))
|
|
549
|
+
except Exception as e:
|
|
550
|
+
raise AssertionError(f"{r0}: sdvplot cannot route this table: {e!r}") from e
|
|
551
|
+
if routed is not adapter:
|
|
552
|
+
_fail(r0, f"sdvplot routes this table to {routed.__name__}, not {adapter.__name__}")
|
|
553
|
+
|
|
554
|
+
a, b = known
|
|
555
|
+
ids = resolve([a, b], league)
|
|
556
|
+
_check_table_verb(
|
|
557
|
+
adapter, "add_logos", make_table, league, known, ids, "XXX",
|
|
558
|
+
{ids[0]: lambda: logo_url(a, league), ids[1]: lambda: logo_url(b, league)},
|
|
559
|
+
("rule T1 (resolution)", "rule T2 (unknown value: warn and keep)", "rule T3 (pandas/polars parity)",
|
|
560
|
+
"rule T4 (height in pixels)"),
|
|
561
|
+
) # fmt: skip
|
|
562
|
+
|
|
563
|
+
wa, wb = known_wordmarks
|
|
564
|
+
wids = resolve([wa, wb], league)
|
|
565
|
+
_check_table_verb(
|
|
566
|
+
adapter, "add_wordmarks", make_table, league, known_wordmarks, wids, "XXX",
|
|
567
|
+
{wids[0]: lambda: logo_url(wa, league, mark_type="wordmark"),
|
|
568
|
+
wids[1]: lambda: logo_url(wb, league, mark_type="wordmark")},
|
|
569
|
+
("rule T5 (wordmarks: resolution)", "rule T5 (wordmarks: warn and keep)", "rule T5 (wordmarks: parity)",
|
|
570
|
+
"rule T5 (wordmarks: height)"),
|
|
571
|
+
) # fmt: skip
|
|
572
|
+
|
|
573
|
+
p, q = players
|
|
574
|
+
_check_table_verb(
|
|
575
|
+
adapter, "add_headshots", make_table, league, players, players, "not-an-id",
|
|
576
|
+
{p: lambda: headshot_url(p, league), q: lambda: headshot_url(q, league)},
|
|
577
|
+
("rule T6 (headshots: resolution)", "rule T6 (headshots: warn and keep)", "rule T6 (headshots: parity)",
|
|
578
|
+
"rule T6 (headshots: height)"),
|
|
579
|
+
) # fmt: skip
|
sdvplot/typing.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Types for annotating code that calls sdvplot: the ``Literal`` aliases of its closed argument vocabularies.
|
|
2
|
+
|
|
3
|
+
Use them where you keep an argument in a variable, so a type checker accepts it::
|
|
4
|
+
|
|
5
|
+
import sdvplot
|
|
6
|
+
from sdvplot.typing import Which
|
|
7
|
+
|
|
8
|
+
def team_palette(league: str, which: Which = "primary") -> dict:
|
|
9
|
+
return sdvplot.palette(league, which=which)
|
|
10
|
+
|
|
11
|
+
``league`` and ``variant`` have no alias: their values come from the bundled team index and the logo archive, so the
|
|
12
|
+
set is open (annotate them as ``str``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from sdvplot._types import HeadshotIdSystem, IdSystem, MarkType, Which
|
|
16
|
+
|
|
17
|
+
__all__ = ["HeadshotIdSystem", "IdSystem", "MarkType", "Which"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def __dir__() -> list[str]: # dir() and tab completion show the public API only
|
|
21
|
+
return list(__all__)
|