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/_colors.py ADDED
@@ -0,0 +1,206 @@
1
+ """Team colors as plain {team: "#hex"} dicts, which seaborn, Plotly, Altair, Bokeh and PyPalettes all accept."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from typing import TYPE_CHECKING, Any, overload
7
+
8
+ from sdvplot import _index
9
+ from sdvplot._errors import InputError, warn
10
+ from sdvplot._resolve import _seasons, _unpack, resolve
11
+ from sdvplot._types import IdSystem, Which
12
+
13
+ if TYPE_CHECKING:
14
+ import numpy as np
15
+ import pandas as pd
16
+ import polars as pl
17
+ else:
18
+ from sdvplot._lazy import pl
19
+
20
+ _COLUMNS = {"primary": "color_primary", "secondary": "color_secondary"}
21
+
22
+
23
+ def _column(which: str) -> str:
24
+ if which not in _COLUMNS:
25
+ raise InputError(f"which must be one of {sorted(_COLUMNS)}, got {which!r}")
26
+ return _COLUMNS[which]
27
+
28
+
29
+ def _colors(league: str, column: str) -> dict[str, str]:
30
+ t = _index.team_table().filter(pl.col("league") == league)
31
+ return {tid: c for tid, c in t.select("team_id", column).iter_rows() if c}
32
+
33
+
34
+ def palette(
35
+ league: str,
36
+ teams: Any = None,
37
+ *,
38
+ which: Which = "primary",
39
+ season: Any = None,
40
+ id_system: IdSystem = "auto",
41
+ strict: bool = False,
42
+ ) -> dict[Any, str]:
43
+ """A ``{team: "#hex"}`` dict for a league, ready for seaborn, Plotly, Altair, Bokeh or PyPalettes.
44
+
45
+ Without ``teams`` the keys are canonical abbreviations, or the team_id where a team has no abbreviation or shares
46
+ it with another team of the league (never guessing which team an abbreviation means). With ``teams`` the keys are
47
+ the caller's own values, so they match a seaborn ``hue`` column or a Plotly color column exactly.
48
+
49
+ Args:
50
+ league: The SDV league key, e.g. "nfl".
51
+ teams: Team values to key the dict by; None returns the whole league.
52
+ which: "primary" or "secondary".
53
+ season: One season, or one per team, for values reused across eras.
54
+ id_system: The id system of ``teams``, as in ``resolve``: "auto" tries each in order; NHL stats ids need
55
+ "nhl_id".
56
+ strict: Raise UnresolvedTeamError instead of warning when a team does not resolve.
57
+
58
+ Returns:
59
+ dict: ``{team: "#hex"}``. Teams that do not resolve, or have no color, are left out. Without ``teams``, a team
60
+ whose abbreviation another team of the league shares is keyed by its team_id, with one SdvplotWarning naming
61
+ the shared abbreviations.
62
+
63
+ Raises:
64
+ TypeError: If ``teams`` is not a scalar, list, tuple, numpy array, or pandas/polars Series.
65
+ InputError: (a ValueError) If ``league`` is not a known league key or ``which`` is not "primary"/"secondary";
66
+ and, when ``teams`` is given, if it holds "primary" or "secondary" (the slot goes in ``which=``),
67
+ ``id_system`` is unknown, or ``season`` is not a year, is outside the seasons sdvplot knows for the league,
68
+ or is a list whose length does not match the teams. Without ``teams`` they are not checked.
69
+ UnresolvedTeamError: (a ValueError) If ``strict=True`` and a team does not resolve.
70
+
71
+ Example:
72
+ ::
73
+
74
+ import sdvplot
75
+
76
+ sdvplot.palette("nfl", teams=["KC", "SF"]) # {'KC': '#e31837', 'SF': '#aa0000'}
77
+
78
+ See Also:
79
+ sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
80
+ sdv-py: https://py.sportsdataverse.org/
81
+ """
82
+ column = _column(which)
83
+ _index.check_league(league)
84
+ colors = _colors(league, column)
85
+ if teams is None:
86
+ rows = _index.team_table().filter(pl.col("league") == league).select("team_id", "abbr").rows()
87
+ shared = {a for a, n in Counter(a for _, a in rows if a).items() if n > 1}
88
+ if shared:
89
+ warn(f"abbreviations shared by several {league} teams are keyed by team_id: {', '.join(sorted(shared))}")
90
+ return {(tid if not abbr or abbr in shared else abbr): colors[tid] for tid, abbr in rows if tid in colors}
91
+ values, _ = _unpack(teams)
92
+ if slots := [v for v in values if isinstance(v, str) and v in _COLUMNS]: # pre-0.1: palette(league, which)
93
+ raise InputError(
94
+ f"{slots[0]!r} is a color slot, not a team; pass it by keyword: "
95
+ f'palette(league, teams=..., which="{slots[0]}")'
96
+ )
97
+ pairs = [p for p in dict.fromkeys(zip(values, _seasons(season, len(values)), strict=True)) if p[0] is not None]
98
+ ids = resolve([v for v, _ in pairs], league, season=[s for _, s in pairs], id_system=id_system, strict=strict)
99
+ out: dict[Any, str] = {}
100
+ for (v, _), i in zip(pairs, ids, strict=True):
101
+ if i is not None and i in colors:
102
+ out.setdefault(v, colors[i]) # a value two teams wore across the seasons keeps its first team's color
103
+ return out
104
+
105
+
106
+ # resolve()'s container rule, and so its overloads (the last one's ignore included).
107
+ @overload
108
+ def team_colors(
109
+ league: str,
110
+ teams: str | bytes | int | float | None,
111
+ *,
112
+ which: Which = "primary",
113
+ season: Any = None,
114
+ id_system: IdSystem = "auto",
115
+ strict: bool = False,
116
+ ) -> str | None: ...
117
+ @overload
118
+ def team_colors(
119
+ league: str,
120
+ teams: pl.Series,
121
+ *,
122
+ which: Which = "primary",
123
+ season: Any = None,
124
+ id_system: IdSystem = "auto",
125
+ strict: bool = False,
126
+ ) -> pl.Series: ...
127
+ @overload
128
+ def team_colors(
129
+ league: str,
130
+ teams: list[Any] | tuple[Any, ...] | np.ndarray[Any, Any],
131
+ *,
132
+ which: Which = "primary",
133
+ season: Any = None,
134
+ id_system: IdSystem = "auto",
135
+ strict: bool = False,
136
+ ) -> list[str | None]: ...
137
+ @overload
138
+ def team_colors(
139
+ league: str,
140
+ teams: pd.Series,
141
+ *,
142
+ which: Which = "primary",
143
+ season: Any = None,
144
+ id_system: IdSystem = "auto",
145
+ strict: bool = False,
146
+ ) -> pd.Series: ...
147
+ @overload
148
+ def team_colors( # type: ignore[overload-cannot-match]
149
+ league: str,
150
+ teams: Any,
151
+ *,
152
+ which: Which = "primary",
153
+ season: Any = None,
154
+ id_system: IdSystem = "auto",
155
+ strict: bool = False,
156
+ ) -> Any: ...
157
+ def team_colors(
158
+ league: str,
159
+ teams: Any,
160
+ *,
161
+ which: Which = "primary",
162
+ season: Any = None,
163
+ id_system: IdSystem = "auto",
164
+ strict: bool = False,
165
+ ) -> Any:
166
+ """One "#hex" (or None) per team value, in the same container the values came in.
167
+
168
+ Args:
169
+ league: The SDV league key, e.g. "nfl".
170
+ teams: A scalar, list/tuple, numpy array, or pandas/polars Series of team identifiers.
171
+ which: "primary" or "secondary".
172
+ season: One season, or one per team, for values reused across eras.
173
+ id_system: The id system of ``teams``, as in ``resolve``: "auto" tries each in order; NHL stats ids need
174
+ "nhl_id".
175
+ strict: Raise UnresolvedTeamError instead of warning when a team does not resolve.
176
+
177
+ Returns:
178
+ str | list | Series | None: The hex color for each team, None where a team does not resolve or has no color.
179
+
180
+ Raises:
181
+ TypeError: If ``teams`` is not a scalar, list, tuple, numpy array, or pandas/polars Series.
182
+ InputError: (a ValueError) If ``league`` is not a known league key (a list or Series there is the pre-0.1
183
+ ``team_colors(teams, league)`` order), ``which`` is not "primary"/"secondary", ``id_system`` is unknown,
184
+ or ``season`` is not a year, is outside the seasons sdvplot knows for the league, or is a list whose
185
+ length does not match the teams.
186
+ UnresolvedTeamError: (a ValueError) If ``strict=True`` and a team does not resolve.
187
+
188
+ Example:
189
+ ::
190
+
191
+ import sdvplot
192
+
193
+ sdvplot.team_colors("nfl", ["KC", "SF"]) # ['#e31837', '#aa0000']
194
+ sdvplot.team_colors("nfl", "KC", which="secondary") # '#ffb612'
195
+ sdvplot.team_colors("nhl", [1, 6, 10], id_system="nhl_id") # NHL stats ids: Devils, Bruins, Leafs
196
+
197
+ See Also:
198
+ sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
199
+ sdv-py: https://py.sportsdataverse.org/
200
+ """
201
+ column = _column(which)
202
+ _index.check_league(league)
203
+ colors = _colors(league, column)
204
+ values, wrap = _unpack(teams)
205
+ ids = resolve(values, league, season=season, id_system=id_system, strict=strict)
206
+ return wrap([colors.get(i) if i is not None else None for i in ids])
sdvplot/_contrast.py ADDED
@@ -0,0 +1,60 @@
1
+ """Color readability, ported from sdvplotR (R/utils-theme.R): WCAG luminance and contrast, a readable ink, mixing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ _HEX = set("0123456789abcdefABCDEF")
6
+
7
+
8
+ def hex6(color: str, *, drop_alpha: bool = False) -> str:
9
+ """A color as lowercase ``#rrggbb``: accepts ``#rgb``, ``#rrggbb``, ``#rgba`` and ``#rrggbbaa`` (``#`` optional).
10
+
11
+ Callers draw and measure the result as an opaque color (contrast, ramps, theme accents), so a color with
12
+ transparency is a ValueError rather than silently drawn solid; an opaque alpha (``ff``) is dropped. A caller that
13
+ sets its own alpha passes ``drop_alpha=True`` (as R's ``scales::alpha()`` replaces a color's alpha). Named colors
14
+ and tuples are not hex colors.
15
+ """
16
+ c = str(color).strip().lstrip("#")
17
+ if len(c) in (3, 4):
18
+ c = "".join(ch * 2 for ch in c)
19
+ if len(c) not in (6, 8) or not set(c) <= _HEX:
20
+ raise ValueError(f"not a hex color: {color!r}")
21
+ if len(c) == 8 and c[6:].lower() != "ff" and not drop_alpha:
22
+ raise ValueError(f"{color!r} has transparency; give an opaque color such as '#{c[:6].lower()}'")
23
+ return "#" + c[:6].lower()
24
+
25
+
26
+ def luminance(color: str) -> float:
27
+ """WCAG relative luminance, 0 (black) to 1 (white)."""
28
+ c = hex6(color)
29
+ rgb = [int(c[i : i + 2], 16) / 255 for i in (1, 3, 5)]
30
+ lin = [v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4 for v in rgb]
31
+ return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
32
+
33
+
34
+ def contrast(a: str, b: str) -> float:
35
+ """WCAG contrast ratio between two colors, 1 (same) to 21 (black on white)."""
36
+ la, lb = luminance(a), luminance(b)
37
+ return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
38
+
39
+
40
+ def on_color(background: str) -> str:
41
+ """Black or white, whichever reads better on ``background`` (sdvplotR's ``.theme_on_color``)."""
42
+ return "#000000" if contrast("#000000", background) >= contrast("#ffffff", background) else "#ffffff"
43
+
44
+
45
+ def mix(a: str, b: str, t: float) -> str:
46
+ """The color ``t`` of the way from ``a`` to ``b`` in sRGB (``t`` in [0, 1])."""
47
+ if not 0 <= t <= 1:
48
+ raise ValueError(f"t must be in [0, 1], got {t!r}")
49
+ ca, cb = hex6(a), hex6(b)
50
+ out = [round(int(ca[i : i + 2], 16) * (1 - t) + int(cb[i : i + 2], 16) * t) for i in (1, 3, 5)]
51
+ return "#" + "".join(f"{v:02x}" for v in out)
52
+
53
+
54
+ def solid(color: str, background: str = "#ffffff") -> str:
55
+ """The opaque ``#rrggbb`` a hex color shows as over ``background``: a translucent ``#rgba``/``#rrggbbaa`` is
56
+ alpha-composited onto it, so its contrast can be measured; an opaque color is itself."""
57
+ opaque = hex6(color, drop_alpha=True) # ValueError for anything but a hex color
58
+ c = str(color).strip().lstrip("#")
59
+ alpha = int(c[3] * 2, 16) / 255 if len(c) == 4 else int(c[6:], 16) / 255 if len(c) == 8 else 1.0
60
+ return mix(background, opaque, alpha)
sdvplot/_court.py ADDED
@@ -0,0 +1,110 @@
1
+ """Shot locations from the stats.nba.com legacy frame to sportypy's court: the port of sdvplotR's sdv_court_coords()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any
7
+
8
+ import narwhals as nw
9
+
10
+ from sdvplot._placement import _missing
11
+
12
+ BASKET_X = -47 + 5.25 # sportypy's NBA/WNBA/NCAA courts: center-court origin, baseline at -47, basket 5.25 ft in
13
+
14
+
15
+ def _tenths(frame: Any, name: str) -> Any:
16
+ """Column ``name`` (tenths of a foot) as a Float64 Series; strings of numbers are coerced."""
17
+ s = frame[name]
18
+ if s.dtype.is_numeric(): # rebuilt like the coerced columns, so both outputs share one dtype (pandas nullable too)
19
+ floats = [None if _missing(v) else v for v in s.cast(nw.Float64).to_list()]
20
+ return nw.new_series(name, floats, nw.Float64(), backend=nw.get_native_namespace(frame))
21
+ all_null_bool = s.dtype == nw.Boolean and s.null_count() == len(s) # R/arrow write an all-NA column as logical
22
+ if s.dtype not in (nw.String, nw.Object, nw.Unknown) and not all_null_bool: # Unknown: a polars Null column
23
+ raise TypeError(f"column {name!r} must be numeric or strings of numbers, not {s.dtype}")
24
+ values: list[float | None] = []
25
+ bad: list[Any] = []
26
+ for v in s.to_list():
27
+ if _missing(v):
28
+ values.append(None)
29
+ continue
30
+ if type(v).__name__ in ("bool", "bool_"): # Python and numpy booleans, which float() reads as 1.0 / 0.0
31
+ raise TypeError(f"column {name!r} must be numeric or strings of numbers, not booleans")
32
+ try: # float() reads strings and numbers; R's as.numeric() reads no "_" digit separators, so neither do we
33
+ f = math.nan if isinstance(v, str) and "_" in v else float(v)
34
+ except (TypeError, ValueError):
35
+ f = math.nan
36
+ if math.isnan(f):
37
+ bad.append(v)
38
+ values.append(f)
39
+ if bad:
40
+ raise ValueError(
41
+ f"column {name!r} has values that are not numbers: {sorted(set(map(str, bad)))}; name the coordinate "
42
+ "columns with x=/y=, or fix the input data"
43
+ )
44
+ return nw.new_series(name, values, nw.Float64(), backend=nw.get_native_namespace(frame))
45
+
46
+
47
+ def court_coords(data: Any, *, x: str = "x_legacy", y: str = "y_legacy") -> Any:
48
+ """Convert stats.nba.com / stats.wnba.com shot locations to the court frame sportypy draws.
49
+
50
+ The stats API's legacy shot frame (``LOC_X``/``LOC_Y``; hoopR, wehoop and sdv-py ``x_legacy``/``y_legacy``) is in
51
+ tenths of a foot with the hoop at the origin, relative to the shooter's basket. sportypy's ``NBACourt``,
52
+ ``WNBACourt`` and ``NCAACourt`` (what ``surface("nba")`` draws) put the origin at center court, the baseline at
53
+ ``x = -47`` and the basket at ``x = -41.75``. So ``court_x = -47 + 5.25 + y / 10`` and ``court_y = x / 10``, with no
54
+ sign flip: a left-corner three lands at negative ``court_y``. Every shot falls on the TV-left half; draw a half
55
+ court with ``surface("nba", display_range="defense")``. Don't pass ESPN ``coordinate_x``/``coordinate_y``: they
56
+ are already in feet on a center-court frame.
57
+
58
+ Args:
59
+ data: A pandas or polars DataFrame of shots.
60
+ x: The column holding ``LOC_X`` / ``x_legacy`` (tenths of a foot, across the court).
61
+ y: The column holding ``LOC_Y`` / ``y_legacy`` (tenths of a foot, toward half court).
62
+
63
+ Returns:
64
+ DataFrame: ``data``'s type, with ``court_x`` and ``court_y`` (feet, Float64) added; existing columns of those
65
+ names are replaced in place, every other column is kept. Numeric columns and strings of numbers (stats.nba.com
66
+ payloads arrive as strings) convert; nulls stay null, and an all-null column gives null coordinates.
67
+
68
+ Raises:
69
+ TypeError: If ``data`` is not a pandas/polars DataFrame, ``x``/``y`` is not a string, or a coordinate column
70
+ is boolean (or holds booleans), categorical or another non-numeric type.
71
+ ValueError: If ``x`` and ``y`` name the same column, a column is missing, or a string is not a number (such
72
+ as ``""``, ``"NA"`` or ``"1_0"``).
73
+
74
+ Example:
75
+ ::
76
+
77
+ import polars as pl
78
+ import sdvplot
79
+
80
+ shots = pl.DataFrame({"LOC_X": [-224, 240], "LOC_Y": [39, 29]})
81
+ out = sdvplot.court_coords(shots, x="LOC_X", y="LOC_Y")
82
+ out["court_y"].to_list() # [-22.4, 24.0]: a left and a right corner three
83
+
84
+ ax = sdvplot.surface("nba", display_range="defense")
85
+ ax.scatter(out["court_x"], out["court_y"], zorder=20)
86
+
87
+ See Also:
88
+ sdvplotR sdv_court_coords(): https://sdvplotR.sportsdataverse.org/ ;
89
+ sportypy: https://sportypy.sportsdataverse.org/ ;
90
+ nba_api shotchartdetail: https://github.com/swar/nba_api
91
+ """
92
+ try:
93
+ frame = nw.from_native(data, eager_only=True)
94
+ except TypeError:
95
+ raise TypeError(f"data must be a pandas or polars DataFrame, got {type(data).__name__}") from None
96
+ for arg, column in (("x", x), ("y", y)):
97
+ if not isinstance(column, str):
98
+ raise TypeError(f"{arg} must be a single column name (a string), got {column!r}")
99
+ if x == y:
100
+ raise ValueError(f"x and y must name different columns, not both {x!r}")
101
+ missing = [c for c in (x, y) if c not in frame.columns]
102
+ if missing:
103
+ raise ValueError(
104
+ f"data is missing column(s) {missing}; name the stats-API columns with x=/y=, e.g. x='LOC_X', y='LOC_Y'"
105
+ )
106
+ # Divide by a Series of tens, not the scalar: polars divides by a scalar through its reciprocal, so -224 / 10 would
107
+ # be -22.400000000000002 instead of R's (and IEEE division's) -22.4.
108
+ ten = nw.new_series("ten", [10.0] * len(frame), nw.Float64(), backend=nw.get_native_namespace(frame))
109
+ xs, ys = _tenths(frame, x) / ten, _tenths(frame, y) / ten
110
+ return frame.with_columns(court_x=ys + BASKET_X, court_y=xs).to_native()
sdvplot/_deprecate.py ADDED
@@ -0,0 +1,39 @@
1
+ """Deprecations: one warning category and one way to emit it, so every rename warns the same way (CONTRIBUTING:
2
+ Deprecation policy). Nothing in sdvplot is deprecated yet."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import functools
7
+ from collections.abc import Callable
8
+ from typing import Any, TypeVar
9
+
10
+ from sdvplot._errors import SdvplotDeprecationWarning, warn
11
+
12
+ F = TypeVar("F", bound=Callable[..., Any])
13
+
14
+
15
+ def deprecate(old: str, *, replacement: str, removal: str) -> None:
16
+ """Warn, at the caller's line, that ``old`` is deprecated: it names ``replacement`` and ``removal``, the release
17
+ that removes it. Call it from the deprecated function or branch, which keeps working."""
18
+ warn(f"{old} is deprecated and will be removed in sdvplot {removal}; use {replacement} instead",
19
+ SdvplotDeprecationWarning) # fmt: skip
20
+
21
+
22
+ def deprecated_alias(*, removal: str, **renames: str) -> Callable[[F], F]:
23
+ """A decorator for renamed keyword arguments: ``@deprecated_alias(removal="0.3.0", which="kind")`` keeps
24
+ ``fn(which=...)`` working as ``fn(kind=...)``, with one warning per call. Passing both names is a TypeError."""
25
+
26
+ def decorate(fn: F) -> F:
27
+ @functools.wraps(fn)
28
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
29
+ for old, new in renames.items():
30
+ if old in kwargs:
31
+ if new in kwargs:
32
+ raise TypeError(f"{fn.__name__}() got both {old}= (deprecated) and {new}=; pass only {new}=")
33
+ deprecate(f"{fn.__name__}({old}=...)", replacement=f"{new}=", removal=removal)
34
+ kwargs[new] = kwargs.pop(old)
35
+ return fn(*args, **kwargs)
36
+
37
+ return wrapper # type: ignore[return-value]
38
+
39
+ return decorate