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/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__)