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/_marks.py ADDED
@@ -0,0 +1,296 @@
1
+ """Choosing a team's mark from the manifest: variant, then season within each variant, then source preference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import weakref
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from sdvplot import _index
9
+ from sdvplot._cache import MEMORY_CACHES
10
+ from sdvplot._errors import InputError, UnresolvedTeamError, warn
11
+ from sdvplot._manifest import load_manifest
12
+ from sdvplot._normalize import norm_season
13
+ from sdvplot._resolve import _covers, one_team, resolve
14
+ from sdvplot._types import IdSystem, MarkType
15
+
16
+ if TYPE_CHECKING:
17
+ import polars as pl
18
+ else:
19
+ from sdvplot._lazy import pl
20
+
21
+ # Official sources first, then archived copies, then derived crops (sdv-assets source names)
22
+ SOURCE_RANK: dict[str, int] = {
23
+ "espn": 0,
24
+ "nflverse": 1,
25
+ "nhl": 1,
26
+ "mlbstatic": 1,
27
+ "fox": 1,
28
+ "hockeytech": 2,
29
+ "cricinfo": 2,
30
+ "ncaa.com": 3,
31
+ "nwhl.co": 4,
32
+ "shiftstats": 4,
33
+ "wayback": 4,
34
+ "aaf.com": 5,
35
+ "aaf-strip-crop": 6,
36
+ }
37
+
38
+
39
+ MARK_TYPES = ("logo", "wordmark")
40
+
41
+
42
+ def _check_mark_type(mark_type: str) -> None:
43
+ if mark_type not in MARK_TYPES:
44
+ raise InputError(f"mark_type must be one of {list(MARK_TYPES)}, got {mark_type!r}")
45
+
46
+
47
+ # (the manifest frame, every variant it holds): rebuilt when a refreshed manifest is a new object, as _RANKED is. The
48
+ # frame is held weakly here and in _RANKED, so a refreshed or cleared one is freed (_manifest keeps only the latest)
49
+ _VARIANTS: list[tuple[weakref.ref[pl.DataFrame], frozenset[str]]] = []
50
+
51
+
52
+ def _check_variant(variant: str, league: str) -> None:
53
+ """A variant no mark in the archive has is a typo, not one this team lacks (that falls back): ValueError listing
54
+ the league's variants. "default" and "dark" are always valid."""
55
+ if variant in ("default", "dark"):
56
+ return
57
+ manifest = load_manifest()
58
+ if not _VARIANTS or _VARIANTS[0][0]() is not manifest:
59
+ _VARIANTS[:] = [(weakref.ref(manifest), frozenset(manifest["variant"].drop_nulls().to_list()))]
60
+ if not isinstance(variant, str) or variant not in _VARIANTS[0][1]: # a list would be unhashable in the set
61
+ _index.check_league(league)
62
+ known = sorted({"default", "dark", *_ranked(league)["variant"].drop_nulls().to_list()})
63
+ raise InputError(f"unknown variant {variant!r}: no mark in the archive has it; {league} marks come in {known}")
64
+
65
+
66
+ def _union(col: str, bound: pl.Expr) -> pl.Expr:
67
+ """One side of the union of several ranges: null (unbounded) if any range is unbounded there."""
68
+ return pl.when(pl.col(col).is_null().any()).then(None).otherwise(bound).alias(f"_alias_{col}")
69
+
70
+
71
+ def _mark_aliases(league: str) -> pl.DataFrame:
72
+ """key ("source:entity_id", normalized like every alias value) -> canonical team_id, unique mappings only, with
73
+ the season range of the mapping (R36: an old-abbreviation mark is dated by its relocation alias)."""
74
+ return (
75
+ _index.alias_table()
76
+ .filter((pl.col("league") == league) & (pl.col("id_system") == "mark"))
77
+ .with_columns(pl.col("value").str.strip_chars().str.to_lowercase().alias("_key"))
78
+ .group_by("_key")
79
+ .agg(
80
+ pl.col("team_id").first(),
81
+ pl.col("team_id").n_unique().alias("_n"),
82
+ _union("valid_from", pl.col("valid_from").min()),
83
+ _union("valid_to", pl.col("valid_to").max()),
84
+ )
85
+ .filter(pl.col("_n") == 1)
86
+ .drop("_n")
87
+ )
88
+
89
+
90
+ # league -> (the loaded manifest frame, weakly; its rows mapped and ranked). _manifest caches that frame per
91
+ # path+mtime, so a refreshed manifest is a new object and rebuilds; an index reload clears it (R45)
92
+ _RANKED: dict[str, tuple[weakref.ref[pl.DataFrame], pl.DataFrame]] = {}
93
+ _index.on_reload(_RANKED.clear)
94
+ # league -> (its ranked table, that table's rows as dicts by team_id, filled per team on first use): select_mark's
95
+ # lookup, a dict hit instead of a polars filter per call
96
+ _TEAM_ROWS: dict[str, tuple[pl.DataFrame, dict[str, list[dict[str, Any]]]]] = {}
97
+ for _clear in (_VARIANTS.clear, _RANKED.clear, _TEAM_ROWS.clear):
98
+ MEMORY_CACHES.append(_clear) # clear_cache() also drops what was built from the manifest it deletes
99
+
100
+
101
+ def _ranked(league: str) -> pl.DataFrame:
102
+ """Every team-level manifest row of one league that maps to one canonical team, with its effective range,
103
+ best first; marks() only filters it on team_id."""
104
+ manifest = load_manifest()
105
+ hit = _RANKED.get(league)
106
+ if hit is not None and hit[0]() is manifest:
107
+ return hit[1]
108
+ m = (
109
+ manifest.filter((pl.col("level") == "team") & (pl.col("league") == league))
110
+ .with_columns(
111
+ pl.concat_str(pl.col("source"), pl.lit(":"), pl.col("entity_id"))
112
+ .str.strip_chars()
113
+ .str.to_lowercase()
114
+ .alias("_key")
115
+ )
116
+ .join(_mark_aliases(league), on="_key", how="inner")
117
+ .with_columns( # the intersection of the row's range and its alias's (null = unbounded on that side)
118
+ pl.max_horizontal("valid_from", "_alias_valid_from").alias("valid_from"),
119
+ pl.min_horizontal("valid_to", "_alias_valid_to").alias("valid_to"),
120
+ )
121
+ .drop("_key", "_alias_valid_from", "_alias_valid_to")
122
+ )
123
+ ranked = (
124
+ m.with_columns(
125
+ pl.col("source").replace_strict(SOURCE_RANK, default=5, return_dtype=pl.Int8).alias("source_rank"),
126
+ pl.col("valid_to").is_null().alias("_open"),
127
+ )
128
+ .sort(
129
+ by=["source_rank", "_open", "valid_to", "first_seen", "valid_from", "sha256"],
130
+ descending=[False, True, True, True, True, False],
131
+ nulls_last=True,
132
+ )
133
+ .drop("_open")
134
+ )
135
+ _RANKED[league] = (weakref.ref(manifest), ranked)
136
+ return ranked
137
+
138
+
139
+ def _team_rows(team_id: str, league: str) -> list[dict[str, Any]]:
140
+ """``_ranked(league)``'s rows for one team as dicts, best first; computed once per team per manifest load."""
141
+ ranked = _ranked(league)
142
+ hit = _TEAM_ROWS.get(league)
143
+ if hit is None or hit[0] is not ranked:
144
+ hit = _TEAM_ROWS[league] = (ranked, {})
145
+ if team_id not in hit[1]:
146
+ hit[1][team_id] = ranked.filter(pl.col("team_id") == team_id).to_dicts()
147
+ return hit[1][team_id]
148
+
149
+
150
+ def marks(team: Any, league: str, *, season: Any = None, id_system: IdSystem = "auto") -> pl.DataFrame:
151
+ """Every archived mark for one team, best first.
152
+
153
+ Manifest entity ids are per-source, so rows reach a team only through its "mark" aliases; rows without a unique
154
+ mapping are dropped, never matched on the raw id. ``valid_from``/``valid_to`` are each row's effective range: the
155
+ manifest's, narrowed by the mark alias's (an open side takes the alias's).
156
+
157
+ Args:
158
+ team: One team identifier (abbreviation, name, ESPN id, ...).
159
+ league: The SDV league key, e.g. "nfl".
160
+ season: A season year, used to resolve a reused code.
161
+ id_system: "auto" or one id-system name, as in ``resolve``.
162
+
163
+ Returns:
164
+ polars.DataFrame: One row per archived mark (logos and wordmarks, every variant and source) with its
165
+ ``variant``, ``mark_type``, ``archive_url`` and ``source_rank``.
166
+
167
+ Raises:
168
+ TypeError: If ``team`` is not a single value.
169
+ InputError: (a ValueError) If ``league`` or ``id_system`` is unknown, or ``season`` is not a year or is
170
+ outside the seasons sdvplot knows for the league.
171
+ UnresolvedTeamError: (a ValueError) If the team is null or does not resolve.
172
+ OfflineError: If the logo manifest cannot be downloaded and no cached copy exists (a DownloadError, also an
173
+ OSError, when the CDN answers with an error status).
174
+ UnsafeDownloadError: (an OSError) If the manifest download is refused: larger than the byte cap, past the
175
+ deadline, or redirected away from https.
176
+
177
+ Example:
178
+ ::
179
+
180
+ import sdvplot
181
+
182
+ sdvplot.marks("KC", "nfl").shape # (19, 21)
183
+
184
+ See Also:
185
+ sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
186
+ sdv-py: https://py.sportsdataverse.org/
187
+ """
188
+ team_id = resolve(one_team(team, "marks"), league, season=season, id_system=id_system, strict=True)
189
+ if team_id is None: # a null team: strict resolve() lets nulls through as None
190
+ raise UnresolvedTeamError(f"marks() needs a team, got {team!r}")
191
+ return _ranked(league).filter(pl.col("team_id") == team_id)
192
+
193
+
194
+ def select_mark(
195
+ team: Any, league: str, season: Any = None, variant: str = "default", mark_type: str = "logo"
196
+ ) -> dict[str, Any] | None:
197
+ """The best mark: the requested variant; then, keeping its polarity (R44), "default" and an on_light variant
198
+ ("on_light", "*_on_light"), or for "dark" an on_dark variant and then "default"; then any variant. Within each,
199
+ season-covering rows first (explicit ranges before open-ended ones), else any row; within a set, official
200
+ sources and current marks first. An unknown or ambiguous team gives None with the resolver's warning."""
201
+ _check_mark_type(mark_type)
202
+ s = norm_season(season, league)
203
+ _check_variant(variant, league)
204
+ team_id = resolve(one_team(team, "select_mark"), league, season=s)
205
+ if team_id is None:
206
+ return None
207
+ # a team has tens of rows: choosing in Python costs less than one polars filter per step (R45); the rows are
208
+ # shared with _TEAM_ROWS, so the chosen one is handed out as a copy
209
+ rows = [r for r in _team_rows(team_id, league) if r["mark_type"] == mark_type]
210
+ side = "dark" if variant == "dark" else "light"
211
+
212
+ def polarity(v: str) -> bool:
213
+ return v == f"on_{side}" or v.endswith(f"_on_{side}")
214
+
215
+ def requested(v: str) -> bool:
216
+ return v == variant
217
+
218
+ def default(v: str) -> bool:
219
+ return v == "default"
220
+
221
+ order = [requested, polarity, default] if side == "dark" else [requested, default, polarity]
222
+ # sorted best-first, so the first row of any set is its best
223
+ for keep in [*order, None]:
224
+ found = [r for r in rows if keep is None or keep(r["variant"])]
225
+ if s is not None:
226
+ covering = [r for r in found if _covers(r["valid_from"], r["valid_to"], s)]
227
+ dated = [r for r in covering if r["valid_from"] is not None or r["valid_to"] is not None]
228
+ for df in (dated, covering):
229
+ if df:
230
+ return dict(df[0])
231
+ if found:
232
+ return dict(found[0])
233
+ return None
234
+
235
+
236
+ def logo_url(
237
+ team: Any,
238
+ league: str,
239
+ *,
240
+ season: Any = None,
241
+ variant: str = "default",
242
+ mark_type: MarkType = "logo",
243
+ id_system: IdSystem = "auto",
244
+ strict: bool = False,
245
+ ) -> str | None:
246
+ """The CDN URL of a team's logo or wordmark, chosen for the season.
247
+
248
+ Picks the requested variant (falling back to a default or polarity variant), then within each variant the archived
249
+ mark whose season range covers ``season`` (relocated franchises get their era's mark), then the most
250
+ authoritative source. Unknown teams return None with one SdvplotWarning.
251
+
252
+ Args:
253
+ team: One team identifier (abbreviation, name, ESPN id, ...).
254
+ league: The SDV league key, e.g. "nfl", "cfb", "nhl".
255
+ season: A season year; None picks the current mark.
256
+ variant: "default", "dark", or a named variant from ``marks()``.
257
+ mark_type: "logo" or "wordmark".
258
+ id_system: The id system of ``team``, as in ``resolve``: "auto" tries each in order; NHL stats ids need
259
+ "nhl_id".
260
+ strict: Raise UnresolvedTeamError instead of warning when the team does not resolve.
261
+
262
+ Returns:
263
+ str | None: The archive URL (content-addressed, immutable), or None when no mark exists.
264
+
265
+ Raises:
266
+ TypeError: If ``team`` is not a single value.
267
+ InputError: (a ValueError) If ``league`` or ``id_system`` is unknown, ``mark_type`` is not "logo"/"wordmark",
268
+ ``variant`` is a name no mark in the archive has (a typo; the message lists the league's variants), or
269
+ ``season`` is not a year or is outside the seasons sdvplot knows for the league.
270
+ UnresolvedTeamError: (a ValueError) If ``strict=True`` and the team does not resolve.
271
+ OfflineError: If the logo manifest cannot be downloaded and no cached copy exists (a DownloadError, also an
272
+ OSError, when the CDN answers with an error status).
273
+ UnsafeDownloadError: (an OSError) If the manifest download is refused: larger than the byte cap, past the
274
+ deadline, or redirected away from https.
275
+
276
+ Example:
277
+ ::
278
+
279
+ import sdvplot
280
+
281
+ sdvplot.logo_url("KC", "nfl") # 'https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/3d/3d77....png'
282
+
283
+ See Also:
284
+ sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
285
+ sdv-py: https://py.sportsdataverse.org/
286
+ """
287
+ _check_mark_type(mark_type)
288
+ _check_variant(variant, league)
289
+ team_id = resolve(one_team(team, "logo_url"), league, season=season, id_system=id_system, strict=strict)
290
+ if team_id is None:
291
+ return None
292
+ row = select_mark(team_id, league, season, variant, mark_type)
293
+ if row is None:
294
+ warn(f"no {mark_type} archived for {team!r} ({league})")
295
+ return None
296
+ return str(row["archive_url"])
sdvplot/_normalize.py ADDED
@@ -0,0 +1,82 @@
1
+ """How raw team values and seasons are compared: one normal form, so 14, "14", 14.0, "14.0" and " 14 " are equal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import numbers
7
+ import re
8
+ import unicodedata
9
+ from typing import Any
10
+
11
+ from sdvplot import _index
12
+ from sdvplot._errors import InputError
13
+
14
+ _FLOAT_ID = re.compile(r"-?\d+\.0+")
15
+ # typographic punctuation providers write inconsistently (sdvplotR fold_accents): curly apostrophes, en/em dashes
16
+ _PUNCT = str.maketrans({"\u2018": "'", "\u2019": "'", "\u2013": "-", "\u2014": "-"})
17
+
18
+
19
+ def _is_na(value: Any) -> bool:
20
+ return value is None or type(value).__name__ in ("NAType", "NaTType")
21
+
22
+
23
+ def norm_value(value: Any) -> str | None:
24
+ """A team value as a comparison key: trimmed, accents and typographic punctuation folded, case-folded,
25
+ integral numbers without a decimal point."""
26
+ if _is_na(value):
27
+ return None
28
+ if isinstance(value, bool):
29
+ return str(value).casefold()
30
+ if isinstance(value, numbers.Integral):
31
+ return str(int(value))
32
+ if isinstance(value, numbers.Real):
33
+ f = float(value)
34
+ if math.isnan(f):
35
+ return None
36
+ return str(int(f)) if f.is_integer() else str(f)
37
+ s = str(value).strip()
38
+ if not s.isascii(): # "San José State" == "San Jose State": decompose, then drop the combining accents
39
+ s = "".join(c for c in unicodedata.normalize("NFKD", s.translate(_PUNCT)) if not unicodedata.combining(c))
40
+ s = s.casefold()
41
+ if _FLOAT_ID.fullmatch(s): # "13.0": an id that went through a float before it became text
42
+ s = s.split(".")[0]
43
+ return s or None
44
+
45
+
46
+ _SPLIT_SEASON = re.compile(r"\s*(\d{4})\s*[-/]\s*(\d{2}|\d{4})\s*")
47
+
48
+
49
+ def check_season(year: int, league: str | None = None) -> None:
50
+ """InputError unless ``year`` is a season sdvplot knows for ``league`` (any league when None): the bounds come from
51
+ the bundled index (``_index.season_bounds``)."""
52
+ bounds = _index.season_bounds(league)
53
+ if bounds is not None and not bounds[0] <= year <= bounds[1]:
54
+ scope = "" if league is None else f" for {league}"
55
+ raise InputError(
56
+ f"season {year} is outside the seasons sdvplot knows{scope} ({bounds[0]} to {bounds[1]}); pass a year such "
57
+ "as 2020"
58
+ )
59
+
60
+
61
+ def norm_season(value: Any, league: str | None = None) -> int | None:
62
+ """A season as an int year. Accepts 2020, 2020.0, "2020"; None/NaN mean no season; anything else is an error, and
63
+ so is a year outside the seasons the bundled index holds for ``league`` (any league when None)."""
64
+ if _is_na(value):
65
+ return None
66
+ year = None
67
+ if isinstance(value, numbers.Real) and not isinstance(value, bool):
68
+ f = float(value)
69
+ if math.isnan(f):
70
+ return None
71
+ if f.is_integer():
72
+ year = int(f)
73
+ elif isinstance(value, str) and value.strip().isdigit():
74
+ year = int(value.strip())
75
+ if year is None:
76
+ hint = ""
77
+ if isinstance(value, str) and (m := _SPLIT_SEASON.fullmatch(value)):
78
+ end = int(m.group(1)) + 1
79
+ hint = f"; for a split season pass its ending year ({end} for {value.strip()!r})"
80
+ raise InputError(f"season must be a year such as 2020, got {value!r}{hint}")
81
+ check_season(year, league)
82
+ return year
sdvplot/_placement.py ADDED
@@ -0,0 +1,168 @@
1
+ """From the caller's (x, y, teams) to the marks an adapter draws: one validation and resolution step for every adapter.
2
+
3
+ Every adapter calls check_height, check_alpha and place, then only draws. That keeps the shared contract (resolution,
4
+ warn and skip, pandas/polars parity, height) in one place instead of one copy per library.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import numbers
11
+ import os
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING, Any, cast
14
+
15
+ from sdvplot._errors import InputError, warn
16
+ from sdvplot._headshots import headshot_url
17
+ from sdvplot._marks import select_mark
18
+ from sdvplot._normalize import norm_value
19
+ from sdvplot._resolve import _resolve_ids, _seasons, _unpack, resolve
20
+
21
+ if TYPE_CHECKING:
22
+ from sdvplot._types import HeadshotIdSystem, IdSystem
23
+
24
+ KINDS = ("logo", "wordmark", "headshot")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Placement:
29
+ """One mark to draw: whose it is, where, and which image."""
30
+
31
+ team_id: str # canonical team id; the player id for headshots; the path for place_images
32
+ x: Any # the caller's x value (number, category or datetime), read positionally
33
+ y: Any
34
+ url: str # archive_url of the selected mark, or the headshot URL
35
+ aspect: float | None # width / height from the manifest row; None for headshots
36
+ mark: dict[str, Any] | None # the selected manifest row; None for headshots
37
+
38
+
39
+ def _real(value: Any) -> bool:
40
+ return isinstance(value, numbers.Real) and not isinstance(value, bool) and not math.isnan(value)
41
+
42
+
43
+ def check_height(height: Any) -> float:
44
+ """``height`` as a float, or ValueError unless it is a fraction of the plot height in (0, 1]."""
45
+ if not _real(height) or not 0 < height <= 1:
46
+ raise InputError(f"height is a fraction of the plot height in (0, 1], got {height!r}")
47
+ return float(height)
48
+
49
+
50
+ def check_alpha(alpha: Any) -> float:
51
+ """``alpha`` as a float, or ValueError unless it is an opacity in [0, 1]."""
52
+ if not _real(alpha) or not 0 <= alpha <= 1:
53
+ raise InputError(f"alpha is an opacity in [0, 1], got {alpha!r}")
54
+ return float(alpha)
55
+
56
+
57
+ def _missing(value: Any) -> bool:
58
+ if value is None or type(value).__name__ in ("NAType", "NaTType"):
59
+ return True
60
+ return isinstance(value, float) and math.isnan(value)
61
+
62
+
63
+ def _warn_skipped(reason: str, values: list[Any]) -> None:
64
+ if values:
65
+ shown = ", ".join(repr(v) for v in values[:10]) + (f" and {len(values) - 10} more" if len(values) > 10 else "")
66
+ warn(f"skipped {len(values)} point(s) {reason}: {shown}")
67
+
68
+
69
+ def place(
70
+ x: Any,
71
+ y: Any,
72
+ teams: Any,
73
+ *,
74
+ league: str,
75
+ season: Any = None,
76
+ kind: str = "logo",
77
+ variant: str = "default",
78
+ id_system: str = "auto",
79
+ strict: bool = False,
80
+ _warn: bool = True,
81
+ ) -> list[Placement]:
82
+ """The marks to draw for each (x, y, team), in input order, skipping (with one warning per reason) the points
83
+ whose team is unknown, whose x or y is missing, or that have no mark.
84
+
85
+ ``x``, ``y`` and ``teams`` are read positionally (a pandas index is ignored). For ``kind="headshot"``, ``teams``
86
+ holds player ids and ``id_system`` must be ``"espn"`` or ``"gsis"`` (as in ``headshot_url``); otherwise
87
+ ``strict=True`` raises UnresolvedTeamError for a team that does not resolve, as ``resolve`` does. ``_warn=False``
88
+ skips the same points without warning, for an adapter that already warned for them (no process-wide warning
89
+ filter is touched, so it is thread-safe).
90
+ """
91
+ skipped = _warn_skipped if _warn else lambda reason, values: None
92
+ if kind not in KINDS:
93
+ raise InputError(f"kind must be one of {KINDS}, got {kind!r}")
94
+ xs, _ = _unpack(x)
95
+ ys, _ = _unpack(y)
96
+ ts, _ = _unpack(teams)
97
+ if not len(xs) == len(ys) == len(ts):
98
+ raise ValueError(f"x, y and teams must have the same length, got {len(xs)}, {len(ys)} and {len(ts)}")
99
+ out: list[Placement] = []
100
+ missing_xy: list[Any] = []
101
+ if kind == "headshot":
102
+ no_image: list[Any] = []
103
+ for xi, yi, pid in zip(xs, ys, ts, strict=True):
104
+ if _missing(pid):
105
+ continue
106
+ if _missing(xi) or _missing(yi):
107
+ missing_xy.append(pid)
108
+ continue
109
+ # id_system is a team or a headshot id system by kind; the callee validates it
110
+ url = headshot_url(pid, league, id_system=cast("HeadshotIdSystem", id_system))
111
+ if url is None:
112
+ no_image.append(pid)
113
+ continue
114
+ # the id the URL was built from: headshot_url reads an espn id through norm_value, so an id that went
115
+ # through a float ("3139477.0") is 3139477; gsis ids are looked up as given
116
+ key = norm_value(pid) if id_system == "espn" else None
117
+ out.append(Placement(key or str(pid).strip(), xi, yi, url, None, None))
118
+ skipped("with no headshot", no_image)
119
+ else:
120
+ seasons = _seasons(season, len(ts))
121
+ if _warn or strict: # one warning for unknown values, or the strict error
122
+ ids = resolve(ts, league, season=seasons, id_system=cast("IdSystem", id_system), strict=strict)
123
+ else:
124
+ ids, _ = _resolve_ids(ts, league, seasons, id_system)
125
+ rows: dict[tuple[str, int | None], dict[str, Any] | None] = {}
126
+ no_mark: list[Any] = []
127
+ for xi, yi, raw, team_id, s in zip(xs, ys, ts, ids, seasons, strict=True):
128
+ if team_id is None:
129
+ continue # unknown or null: the resolver already warned
130
+ if _missing(xi) or _missing(yi):
131
+ missing_xy.append(raw)
132
+ continue
133
+ if (team_id, s) not in rows:
134
+ rows[(team_id, s)] = select_mark(team_id, league, s, variant, kind)
135
+ row = rows[(team_id, s)]
136
+ if row is None:
137
+ no_mark.append(raw)
138
+ continue
139
+ w, h = row.get("width"), row.get("height")
140
+ aspect = float(w) / float(h) if w and h else None
141
+ out.append(Placement(team_id, xi, yi, str(row["archive_url"]), aspect, row))
142
+ skipped(f"with no {kind} archived", no_mark)
143
+ skipped("with a missing x or y", missing_xy)
144
+ return out
145
+
146
+
147
+ def place_images(x: Any, y: Any, paths: Any, *, _warn: bool = True) -> list[Placement]:
148
+ """``place`` for arbitrary images: one Placement per (x, y, path), keyed by the path (a local file or URL).
149
+
150
+ A null path is skipped silently (as a null team is); a missing x or y is skipped with one warning (none with
151
+ ``_warn=False``, as ``place``).
152
+ """
153
+ paths = os.fspath(paths) if isinstance(paths, os.PathLike) else paths # one pathlib.Path is one point
154
+ xs, ys, ps = _unpack(x)[0], _unpack(y)[0], _unpack(paths)[0]
155
+ if not len(xs) == len(ys) == len(ps):
156
+ raise ValueError(f"x, y and paths must have the same length, got {len(xs)}, {len(ys)} and {len(ps)}")
157
+ out: list[Placement] = []
158
+ missing_xy: list[Any] = []
159
+ for xi, yi, path in zip(xs, ys, ps, strict=True):
160
+ if _missing(path):
161
+ continue
162
+ if _missing(xi) or _missing(yi):
163
+ missing_xy.append(path)
164
+ continue
165
+ out.append(Placement(str(path), xi, yi, str(path), None, None))
166
+ if _warn:
167
+ _warn_skipped("with a missing x or y", missing_xy)
168
+ return out