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/_tiers.py ADDED
@@ -0,0 +1,135 @@
1
+ """team_tiers' data preparation and look, ported from sdvplotR ``sdv_team_tiers()``: the matplotlib and plotnine
2
+ adapters only draw what ``prepare()`` returns, so ranking, tier lines, labels and limits have one implementation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import textwrap
7
+ from collections import Counter
8
+ from dataclasses import dataclass
9
+ from typing import Any, Literal
10
+
11
+ import narwhals as nw
12
+
13
+ from sdvplot._index import teams as teams_index
14
+ from sdvplot._placement import _missing, _real, _warn_skipped, check_alpha, check_height
15
+ from sdvplot._resolve import _unpack, resolve
16
+
17
+ BG, LINES, MUTED = "#1e1e1e", "#e0e0e0", "#8e8e93" # sdvplotR's dark theme: background, tier lines, subtitle/caption
18
+ # theme -> (background, tier lines, title and labels, subtitle and caption). "light" is sdvplot's: dark logos (Ohio
19
+ # State, Texas A&M, Penn State) vanish on sdvplotR's dark background
20
+ THEMES: dict[str, tuple[str, str, str, str]] = {
21
+ "dark": (BG, LINES, "#ffffff", MUTED),
22
+ "light": ("#ffffff", "#3a3a3c", "#1e1e1e", "#636366"),
23
+ }
24
+ SUBTITLE = "created with the #sdvplot Tiermaker"
25
+ TIER_DESC = {1: "Elite", 2: "Very Good", 3: "Medium", 4: "Bad", 5: "What are they doing?", 6: "", 7: ""}
26
+ # About the largest logo height (a fraction of the panel height) at which 32 square logos in 5 tiers (7, 7, 6, 6,
27
+ # 6) neither overlap nor leave the panel at matplotlib's and plotnine's default 6.4 x 4.8 in figure; it stands in for
28
+ # sdvplotR's width = 0.075 npc. tests/test_team_tiers.py measures it.
29
+ DEFAULT_HEIGHT = 0.1
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Tiers:
34
+ """What a tier plot draws: one entry per resolved team in x/y/team_ids/labels, then the axes and titles."""
35
+
36
+ x: list[Any] # tier_rank
37
+ y: list[Any] # tier_no
38
+ team_ids: list[str]
39
+ labels: list[str] # the team's abbreviation (else the value given), which devel=True draws
40
+ lines: list[float] # the tier separators, at tier +- 0.5
41
+ breaks: list[Any] # the tiers, top (smallest tier_no) first
42
+ break_labels: list[str]
43
+ xlim: tuple[float, float]
44
+ ylim: tuple[float, float] # (top, bottom) in tier_no units
45
+ title: str
46
+ subtitle: str | None
47
+ caption: str | None
48
+ height: float
49
+ alpha: float
50
+ bg: str # the theme's colors (THEMES)
51
+ line_color: str
52
+ text: str
53
+ muted: str
54
+
55
+
56
+ def _wrap(text: str) -> str:
57
+ """R's ``strwrap(text, 15)``: lines under 15 characters, a longer word kept whole."""
58
+ return "\n".join(textwrap.wrap(text, 14, break_long_words=False, break_on_hyphens=False))
59
+
60
+
61
+ def prepare(
62
+ data: Any,
63
+ league: str,
64
+ *,
65
+ title: str | None = None,
66
+ subtitle: str | None = SUBTITLE,
67
+ caption: str | None = None,
68
+ tier_desc: dict[Any, str] | None = None,
69
+ presort: bool = False,
70
+ alpha: float = 0.8,
71
+ height: float | None = None,
72
+ no_line_below_tier: Any = None,
73
+ theme: Literal["dark", "light"] = "dark",
74
+ ) -> Tiers:
75
+ """Validate ``data`` and compute everything a tier plot draws (see the adapters' ``team_tiers``)."""
76
+ if theme not in THEMES:
77
+ raise ValueError(f"theme must be one of {sorted(THEMES)}, got {theme!r}")
78
+ frame = nw.from_native(data, eager_only=True) # TypeError unless a pandas/polars (narwhals-supported) frame
79
+ missing = [c for c in ("tier_no", "team") if c not in frame.columns]
80
+ if missing:
81
+ raise ValueError(f"data must have the columns tier_no and team; missing {', '.join(missing)}")
82
+ h = DEFAULT_HEIGHT if height is None else check_height(height)
83
+ a = check_alpha(alpha)
84
+ tiers, teams = frame["tier_no"].to_list(), frame["team"].to_list()
85
+ # a pandas column with a NaN is float: 1.0 back to 1, so tier_desc's R-style "1" keys (and the ticks) match
86
+ tiers = [int(t) if isinstance(t, float) and t.is_integer() else t for t in tiers]
87
+ given = frame["tier_rank"].to_list() if "tier_rank" in frame.columns and not presort else None
88
+
89
+ absent = {i for i, t in enumerate(tiers) if _missing(t) or (given is not None and _missing(given[i]))}
90
+ _warn_skipped("with a missing tier_no or tier_rank", [teams[i] for i in sorted(absent)])
91
+ rows = [i for i in range(len(tiers)) if i not in absent]
92
+ if not rows:
93
+ raise ValueError("data has no rows with a tier_no")
94
+ if not all(_real(tiers[i]) and (given is None or _real(given[i])) for i in rows):
95
+ raise TypeError("tier_no and tier_rank must hold numbers (tier 1 is the top tier)")
96
+ if presort: # sdvplotR: arrange(tier_no, team), then rank within the tier
97
+ rows.sort(key=lambda i: (tiers[i], _missing(teams[i]), str(teams[i]))) # a missing team last, as R's NA
98
+ if given is None:
99
+ seen: Counter[Any] = Counter()
100
+ ranks = {}
101
+ for i in rows:
102
+ seen[tiers[i]] += 1
103
+ ranks[i] = seen[tiers[i]]
104
+ else:
105
+ ranks = {i: given[i] for i in rows}
106
+
107
+ # Rank first, then resolve, as sdvplotR does: an unknown team keeps its slot (and the x range) but draws nothing.
108
+ ids = resolve([teams[i] for i in rows], league)
109
+ kept = [(i, team_id) for i, team_id in zip(rows, ids, strict=True) if team_id is not None]
110
+ abbr = dict(teams_index(league).select("team_id", "abbr").iter_rows()) # devel draws it, as sdvplotR does
111
+ levels = sorted({tiers[i] for i in rows})
112
+ skip = set(_unpack(no_line_below_tier)[0]) if no_line_below_tier is not None else set()
113
+ desc: dict[Any, str] = TIER_DESC if tier_desc is None else tier_desc
114
+ lo, hi = min(ranks.values()), max(ranks.values())
115
+ pad = 0.5 if lo == hi else 0.05 * (hi - lo) # a ggplot continuous scale's default expansion
116
+ return Tiers(
117
+ x=[ranks[i] for i, _ in kept],
118
+ y=[tiers[i] for i, _ in kept],
119
+ team_ids=[team_id for _, team_id in kept],
120
+ labels=[abbr.get(team_id) or str(teams[i]) for i, team_id in kept],
121
+ lines=[levels[0] - 0.5] + [t + 0.5 for t in levels if t not in skip],
122
+ breaks=levels,
123
+ break_labels=[_wrap(desc.get(t, desc.get(str(t), ""))) for t in levels],
124
+ xlim=(lo - pad, hi + pad),
125
+ ylim=(levels[0] - 0.6, levels[-1] + 0.6), # +- 0.5 around the tiers, plus sdvplotR's expansion(add = 0.1)
126
+ title=f"{league.upper()} Team Tiers" if title is None else title,
127
+ subtitle=subtitle,
128
+ caption=caption,
129
+ height=h,
130
+ alpha=a,
131
+ bg=THEMES[theme][0],
132
+ line_color=THEMES[theme][1],
133
+ text=THEMES[theme][2],
134
+ muted=THEMES[theme][3],
135
+ )
sdvplot/_types.py ADDED
@@ -0,0 +1,38 @@
1
+ """The closed vocabularies of sdvplot's arguments, as ``Literal`` types a type checker can check.
2
+
3
+ An alias exists only where the runtime accepts exactly that set of strings; ``tests/test_types.py`` keeps each one
4
+ equal to the set the code validates against. Two vocabularies stay ``str`` because they are open: ``league`` (the
5
+ leagues come from the bundled team index, which grows with each release) and ``variant`` (the names come from the logo
6
+ archive's manifest; "default" and "dark" always work).
7
+ """
8
+
9
+ from typing import Literal
10
+
11
+ # resolve()'s id_system: "auto", then _resolve.PRIORITY in order, then _resolve.EXPLICIT_ONLY
12
+ IdSystem = Literal[
13
+ "auto",
14
+ "team_id",
15
+ "espn",
16
+ "espn_abbr",
17
+ "nhl",
18
+ "nflverse",
19
+ "mlbstats",
20
+ "nba_api",
21
+ "hockeytech",
22
+ "ncaa",
23
+ "pff",
24
+ "cricinfo",
25
+ "cfbd",
26
+ "bref",
27
+ "sportsipy",
28
+ "fangraphs",
29
+ "sdvplotr",
30
+ "name",
31
+ "nhl_id",
32
+ ]
33
+ # headshot_url()'s id_system: ESPN athlete ids (any ESPN league) or nflverse gsis ids (nfl)
34
+ HeadshotIdSystem = Literal["espn", "gsis"]
35
+ # palette()'s color slot: _colors._COLUMNS
36
+ Which = Literal["primary", "secondary"]
37
+ # logo_url() and logo_image()'s mark: _marks.MARK_TYPES
38
+ MarkType = Literal["logo", "wordmark"]
sdvplot/_versions.py ADDED
@@ -0,0 +1,30 @@
1
+ """What a bug report needs: package version, bundled-index version, and the cached manifest's date."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sdvplot._cache import read_meta
6
+ from sdvplot._index import index_version
7
+
8
+
9
+ def versions() -> dict[str, str | None]:
10
+ """What a bug report needs: the package version, the bundled-index version, and the cached manifest's date.
11
+
12
+ Returns:
13
+ dict[str, str | None]: Keys ``sdvplot``, ``index`` and ``manifest_last_modified`` (None until the manifest has
14
+ been downloaded).
15
+
16
+ Example:
17
+ ::
18
+
19
+ import sdvplot
20
+
21
+ sdvplot.versions()["sdvplot"] # '0.1.0'
22
+
23
+ See Also:
24
+ sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
25
+ sdv-py: https://py.sportsdataverse.org/
26
+ """
27
+ from sdvplot import __version__
28
+
29
+ meta = read_meta("manifest/marks.csv") or {}
30
+ return {"sdvplot": __version__, "index": index_version(), "manifest_last_modified": meta.get("last_modified")}
sdvplot/_web.py ADDED
@@ -0,0 +1,61 @@
1
+ """What the web adapters (Plotly, Altair, Bokeh, HoloViews, Folium, pygal) share: image sources and aspect ratios.
2
+
3
+ Browsers load images themselves, so a web adapter hands over the mark's ``archive_url`` (immutable, content-addressed),
4
+ or with ``embed=True`` a ``data:`` URI of the cached bytes, for HTML that renders offline and for static export.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ from urllib.parse import quote
11
+
12
+ from PIL import Image
13
+
14
+ from sdvplot._images import mark_file, url_file
15
+ from sdvplot._placement import Placement
16
+
17
+ # ESPN's combiner serves full headshots at 600x436 (measured 2026-10-04 for NFL, NBA and MLB ids); checked by
18
+ # tests/test_web_live.py.
19
+ HEADSHOT_ASPECT = 600 / 436
20
+ _URL_PUNCTUATION = "-._~:/?#[]@!$&()*+,;=%" # SAFE_URL's characters besides letters and digits (quote keeps those)
21
+
22
+
23
+ def aspect(p: Placement) -> float:
24
+ """Width / height of the placement's image: the manifest's, or HEADSHOT_ASPECT for a headshot."""
25
+ return p.aspect if p.aspect is not None else HEADSHOT_ASPECT
26
+
27
+
28
+ def axis_letter(axis: str) -> str:
29
+ """``axis`` if it is "x" or "y", else a ValueError (the check every web adapter's axis verb starts with)."""
30
+ if axis not in ("x", "y"):
31
+ raise ValueError(f"axis must be 'x' or 'y', got {axis!r}")
32
+ return axis
33
+
34
+
35
+ def image_src(p: Placement, *, embed: bool = False) -> str:
36
+ """The image source for a browser: ``p.url``, or with ``embed=True`` a data URI of the cached image bytes.
37
+
38
+ Embedding reads through the cache (downloading on a miss), so offline with no cached copy it raises OfflineError.
39
+ The URL is percent-encoded past ``SAFE_URL``'s characters, a no-op on the URLs the manifest and player table let
40
+ through, so an unvalidated one still cannot close the <script> block or attribute an adapter writes it into.
41
+ """
42
+ if not embed:
43
+ return quote(p.url, safe=_URL_PUNCTUATION)
44
+ path = mark_file(p.mark) if p.mark is not None else url_file(p.url)
45
+ if p.mark is not None and str(p.mark["ext"]).lower() == "svg":
46
+ mime = "image/svg+xml"
47
+ else:
48
+ with Image.open(path) as img:
49
+ mime = Image.MIME.get(img.format or "", "application/octet-stream")
50
+ return f"data:{mime};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"
51
+
52
+
53
+ def image_sources(placements: list[Placement], *, embed: bool = False) -> list[str]:
54
+ """``image_src`` for each placement, reading each distinct image once."""
55
+ seen: dict[str, str] = {}
56
+ out = []
57
+ for p in placements:
58
+ if p.url not in seen:
59
+ seen[p.url] = image_src(p, embed=embed)
60
+ out.append(seen[p.url])
61
+ return out