diff --git a/altair/__init__.py b/altair/__init__.py index bd81f0209..ad77b5633 100644 --- a/altair/__init__.py +++ b/altair/__init__.py @@ -617,11 +617,12 @@ def __dir__(): return __all__ -from .vegalite import * -from .jupyter import JupyterChart +from altair.vegalite import * +from altair.jupyter import JupyterChart +from altair.utils import AltairDeprecationWarning def load_ipython_extension(ipython): - from ._magics import vegalite + from altair._magics import vegalite ipython.register_magic_function(vegalite, "cell") diff --git a/altair/utils/__init__.py b/altair/utils/__init__.py index 36d35bca4..b9b7269a6 100644 --- a/altair/utils/__init__.py +++ b/altair/utils/__init__.py @@ -11,7 +11,7 @@ ) from .html import spec_to_html from .plugin_registry import PluginRegistry -from .deprecation import AltairDeprecationWarning +from .deprecation import AltairDeprecationWarning, deprecated, deprecated_warn from .schemapi import Undefined, Optional @@ -21,6 +21,8 @@ "PluginRegistry", "SchemaBase", "Undefined", + "deprecated", + "deprecated_warn", "display_traceback", "infer_encoding_types", "infer_vegalite_type_for_pandas", diff --git a/altair/utils/deprecation.py b/altair/utils/deprecation.py index b247e3a4b..88c12516c 100644 --- a/altair/utils/deprecation.py +++ b/altair/utils/deprecation.py @@ -1,94 +1,113 @@ from __future__ import annotations import sys -from typing import Callable, TypeVar, TYPE_CHECKING import warnings -import functools +from typing import TYPE_CHECKING -if sys.version_info >= (3, 10): - from typing import ParamSpec +if sys.version_info >= (3, 13): + from warnings import deprecated as _deprecated else: - from typing_extensions import ParamSpec + from typing_extensions import deprecated as _deprecated + if TYPE_CHECKING: - from functools import _Wrapped + if sys.version_info >= (3, 11): + from typing import LiteralString + else: + from typing_extensions import LiteralString -T = TypeVar("T") -P = ParamSpec("P") -R = TypeVar("R") +class AltairDeprecationWarning(DeprecationWarning): ... -class AltairDeprecationWarning(UserWarning): - pass +def _format_message( + version: LiteralString, + alternative: LiteralString | None, + message: LiteralString | None, + /, +) -> LiteralString: + output = f"Deprecated in `altair={version}`." + if alternative: + output = f"{output} Use {alternative} instead." + return f"{output}\n{message}" if message else output + +# NOTE: Annotating the return type breaks `pyright` detecting [reportDeprecated] +# NOTE: `LiteralString` requirement is introduced by stubs def deprecated( - message: str | None = None, -) -> Callable[..., type[T] | _Wrapped[P, R, P, R]]: - """Decorator to deprecate a function or class. + *, + version: LiteralString, + alternative: LiteralString | None = None, + message: LiteralString | None = None, + category: type[AltairDeprecationWarning] | None = AltairDeprecationWarning, + stacklevel: int = 1, +): # te.deprecated + """Indicate that a class, function or overload is deprecated. + + When this decorator is applied to an object, the type checker + will generate a diagnostic on usage of the deprecated object. Parameters ---------- - message : string (optional) - The deprecation message + version + ``altair`` version the deprecation first appeared. + alternative + Suggested replacement class/method/function. + message + Additional message appended to ``version``, ``alternative``. + category + If the *category* is ``None``, no warning is emitted at runtime. + stacklevel + The *stacklevel* determines where the + warning is emitted. If it is ``1`` (the default), the warning + is emitted at the direct caller of the deprecated object; if it + is higher, it is emitted further up the stack. + Static type checker behavior is not affected by the *category* + and *stacklevel* arguments. + + References + ---------- + [PEP 702](https://peps.python.org/pep-0702/) """ + msg = _format_message(version, alternative, message) + return _deprecated(msg, category=category, stacklevel=stacklevel) - def wrapper(obj: type[T] | Callable[P, R]) -> type[T] | _Wrapped[P, R, P, R]: - return _deprecate(obj, message=message) - return wrapper +def deprecated_warn( + message: LiteralString, + *, + version: LiteralString, + alternative: LiteralString | None = None, + category: type[AltairDeprecationWarning] = AltairDeprecationWarning, + stacklevel: int = 2, +) -> None: + """Indicate that the current code path is deprecated. - -def _deprecate( - obj: type[T] | Callable[P, R], name: str | None = None, message: str | None = None -) -> type[T] | _Wrapped[P, R, P, R]: - """Return a version of a class or function that raises a deprecation warning. + This should be used for non-trivial cases *only*. ``@deprecated`` should + always be preferred as it is recognized by static type checkers. Parameters ---------- - obj : class or function - The object to create a deprecated version of. - name : string (optional) - The name of the deprecated object - message : string (optional) - The deprecation message - - Returns - ------- - deprecated_obj : - The deprecated version of obj - - Examples - -------- - >>> class Foo: pass - >>> OldFoo = _deprecate(Foo, "OldFoo") - >>> f = OldFoo() # doctest: +SKIP - AltairDeprecationWarning: alt.OldFoo is deprecated. Use alt.Foo instead. + message + Explanation of the deprecated behaviour. + + .. note:: + Unlike ``@deprecated``, this is *not* optional. + + version + ``altair`` version the deprecation first appeared. + alternative + Suggested replacement argument/method/function. + category + The runtime warning type emitted. + stacklevel + How far up the call stack to make this warning appear. + A value of ``2`` attributes the warning to the caller + of the code calling ``deprecated_warn()``. + + References + ---------- + [warnings.warn](https://docs.python.org/3/library/warnings.html#warnings.warn) """ - if message is None: - message = f"alt.{name} is deprecated. Use alt.{obj.__name__} instead." "" - if isinstance(obj, type): - if name is None: - msg = f"Requires name, but got: {name=}" - raise TypeError(msg) - else: - return type( - name, - (obj,), - { - "__doc__": obj.__doc__, - "__init__": _deprecate(obj.__init__, "__init__", message), - }, - ) - elif callable(obj): - - @functools.wraps(obj) - def new_obj(*args: P.args, **kwargs: P.kwargs) -> R: - warnings.warn(message, AltairDeprecationWarning, stacklevel=1) - return obj(*args, **kwargs) - - new_obj._deprecated = True # type: ignore[attr-defined] - return new_obj - else: - msg = f"Cannot deprecate object of type {type(obj)}" - raise ValueError(msg) + msg = _format_message(version, alternative, message) + warnings.warn(msg, category=category, stacklevel=stacklevel) diff --git a/altair/utils/save.py b/altair/utils/save.py index 3a8816e17..61d13d8d1 100644 --- a/altair/utils/save.py +++ b/altair/utils/save.py @@ -7,7 +7,7 @@ from .mimebundle import spec_to_mimebundle from ..vegalite.v5.data import data_transformers from altair.utils._vegafusion_data import using_vegafusion -from altair.utils.deprecation import AltairDeprecationWarning +from altair.utils.deprecation import deprecated_warn if TYPE_CHECKING: from pathlib import Path @@ -135,12 +135,10 @@ def save( additional kwargs passed to spec_to_mimebundle. """ if webdriver is not None: - warnings.warn( - "The webdriver argument is deprecated as it's not relevant for" - + " the new vl-convert engine which replaced altair_saver." - + " The argument will be removed in a future release.", - AltairDeprecationWarning, - stacklevel=1, + deprecated_warn( + "The webdriver argument is not relevant for the new vl-convert engine which replaced altair_saver. " + "The argument will be removed in a future release.", + version="5.0.0", ) if json_kwds is None: diff --git a/altair/vegalite/v5/api.py b/altair/vegalite/v5/api.py index 635577db8..e9d420828 100644 --- a/altair/vegalite/v5/api.py +++ b/altair/vegalite/v5/api.py @@ -23,9 +23,10 @@ using_vegafusion as _using_vegafusion, compile_with_vegafusion as _compile_with_vegafusion, ) -from ...utils.data import DataType, is_data_type as _is_data_type -from ...utils.deprecation import AltairDeprecationWarning -from ...utils.core import to_eager_narwhals_dataframe as _to_eager_narwhals_dataframe +from altair.utils.data import DataType, is_data_type as _is_data_type +from altair.utils.core import ( + to_eager_narwhals_dataframe as _to_eager_narwhals_dataframe, +) if TYPE_CHECKING: from ...utils.core import DataFrameLike @@ -261,9 +262,7 @@ def __init__( self.param = param self.param_type = param_type - @utils.deprecation.deprecated( - message="'ref' is deprecated. No need to call '.ref()' anymore." - ) + @utils.deprecated(version="5.0.0", alternative="to_dict") def ref(self) -> dict: "'ref' is deprecated. No need to call '.ref()' anymore." return self.to_dict() @@ -444,41 +443,24 @@ def param( parameter: Parameter The parameter object that can be used in chart creation. """ + warn_msg = "The value of `empty` should be True or False." + empty_remap = {"none": False, "all": True} parameter = Parameter(name) if empty is not Undefined: - parameter.empty = empty - if parameter.empty == "none": - warnings.warn( - """The value of 'empty' should be True or False.""", - utils.AltairDeprecationWarning, - stacklevel=1, - ) - parameter.empty = False - elif parameter.empty == "all": - warnings.warn( - """The value of 'empty' should be True or False.""", - utils.AltairDeprecationWarning, - stacklevel=1, - ) - parameter.empty = True - elif (parameter.empty is False) or (parameter.empty is True): - pass + if isinstance(empty, bool) and not isinstance(empty, str): + parameter.empty = empty + elif empty in empty_remap: + utils.deprecated_warn(warn_msg, version="5.0.0") + parameter.empty = empty_remap[empty] # type: ignore[index] else: - msg = "The value of 'empty' should be True or False." - raise ValueError(msg) + raise ValueError(warn_msg) - if "init" in kwds: - warnings.warn( - """Use 'value' instead of 'init'.""", - utils.AltairDeprecationWarning, - stacklevel=1, - ) + if _init := kwds.pop("init", None): + utils.deprecated_warn("Use `value` instead of `init`.", version="5.0.0") + # If both 'value' and 'init' are set, we ignore 'init'. if value is Undefined: - kwds["value"] = kwds.pop("init") - else: - # If both 'value' and 'init' are set, we ignore 'init'. - kwds.pop("init") + kwds["value"] = _init # ignore[arg-type] comment is needed because we can also pass _expr_core.Expression if "select" not in kwds: @@ -517,11 +499,10 @@ def _selection(type: Optional[SelectionType_T] = Undefined, **kwds) -> Parameter select = core.PointSelectionConfig(type=type, **kwds) elif type in {"single", "multi"}: select = core.PointSelectionConfig(type="point", **kwds) - warnings.warn( - """The types 'single' and 'multi' are now - combined and should be specified using "selection_point()".""", - utils.AltairDeprecationWarning, - stacklevel=1, + utils.deprecated_warn( + "The types `single` and `multi` are now combined.", + version="5.0.0", + alternative="selection_point()", ) else: msg = """'type' must be 'point' or 'interval'""" @@ -530,9 +511,10 @@ def _selection(type: Optional[SelectionType_T] = Undefined, **kwds) -> Parameter return param(select=select, **param_kwds) -@utils.deprecation.deprecated( - message="""'selection' is deprecated. - Use 'selection_point()' or 'selection_interval()' instead; these functions also include more helpful docstrings.""" +@utils.deprecated( + version="5.0.0", + alternative="'selection_point()' or 'selection_interval()'", + message="These functions also include more helpful docstrings.", ) def selection(type: Optional[SelectionType_T] = Undefined, **kwds) -> Parameter: """ @@ -785,19 +767,13 @@ def selection_point( ) -@utils.deprecation.deprecated( - message="'selection_multi' is deprecated. Use 'selection_point'" -) -@utils.use_signature(core.PointSelectionConfig) +@utils.deprecated(version="5.0.0", alternative="selection_point") def selection_multi(**kwargs): """'selection_multi' is deprecated. Use 'selection_point'""" return _selection(type="point", **kwargs) -@utils.deprecation.deprecated( - message="'selection_single' is deprecated. Use 'selection_point'" -) -@utils.use_signature(core.PointSelectionConfig) +@utils.deprecated(version="5.0.0", alternative="selection_point") def selection_single(**kwargs): """'selection_single' is deprecated. Use 'selection_point'""" return _selection(type="point", **kwargs) @@ -1267,12 +1243,10 @@ def save( additional kwargs passed to spec_to_mimebundle. """ if webdriver is not None: - warnings.warn( - "The webdriver argument is deprecated as it's not relevant for" - + " the new vl-convert engine which replaced altair_saver." - + " The argument will be removed in a future release.", - AltairDeprecationWarning, - stacklevel=1, + utils.deprecated_warn( + "The webdriver argument is not relevant for the new vl-convert engine which replaced altair_saver. " + "The argument will be removed in a future release.", + version="5.0.0", ) from ...utils.save import save @@ -2657,7 +2631,7 @@ def display( else: display(self) - @utils.deprecation.deprecated(message="'serve' is deprecated. Use 'show' instead.") + @utils.deprecated(version="4.1.0", alternative="show") def serve( self, ip="127.0.0.1", @@ -3031,9 +3005,7 @@ def add_params(self, *params: Parameter) -> Self: copy.params.append(s.param) return copy - @utils.deprecation.deprecated( - message="'add_selection' is deprecated. Use 'add_params' instead." - ) + @utils.deprecated(version="5.0.0", alternative="add_params") def add_selection(self, *params) -> Self: """'add_selection' is deprecated. Use 'add_params' instead.""" return self.add_params(*params) @@ -3247,9 +3219,7 @@ def add_params(self, *params: Parameter) -> Self: copy.spec = copy.spec.add_params(*params) return copy.copy() - @utils.deprecation.deprecated( - message="'add_selection' is deprecated. Use 'add_params' instead." - ) + @utils.deprecated(version="5.0.0", alternative="add_params") def add_selection(self, *selections) -> Self: """'add_selection' is deprecated. Use 'add_params' instead.""" return self.add_params(*selections) @@ -3364,9 +3334,7 @@ def add_params(self, *params: Parameter) -> Self: copy.concat = [chart.add_params(*params) for chart in copy.concat] return copy - @utils.deprecation.deprecated( - message="'add_selection' is deprecated. Use 'add_params' instead." - ) + @utils.deprecated(version="5.0.0", alternative="add_params") def add_selection(self, *selections) -> Self: """'add_selection' is deprecated. Use 'add_params' instead.""" return self.add_params(*selections) @@ -3461,9 +3429,7 @@ def add_params(self, *params: Parameter) -> Self: copy.hconcat = [chart.add_params(*params) for chart in copy.hconcat] return copy - @utils.deprecation.deprecated( - message="'add_selection' is deprecated. Use 'add_params' instead." - ) + @utils.deprecated(version="5.0.0", alternative="add_params") def add_selection(self, *selections) -> Self: """'add_selection' is deprecated. Use 'add_params' instead.""" return self.add_params(*selections) @@ -3560,9 +3526,7 @@ def add_params(self, *params: Parameter) -> Self: copy.vconcat = [chart.add_params(*params) for chart in copy.vconcat] return copy - @utils.deprecation.deprecated( - message="'add_selection' is deprecated. Use 'add_params' instead." - ) + @utils.deprecated(version="5.0.0", alternative="add_params") def add_selection(self, *selections) -> Self: """'add_selection' is deprecated. Use 'add_params' instead.""" return self.add_params(*selections) @@ -3679,9 +3643,7 @@ def add_params(self, *params: Parameter) -> Self: copy.layer[0] = copy.layer[0].add_params(*params) return copy.copy() - @utils.deprecation.deprecated( - message="'add_selection' is deprecated. Use 'add_params' instead." - ) + @utils.deprecated(version="5.0.0", alternative="add_params") def add_selection(self, *selections) -> Self: """'add_selection' is deprecated. Use 'add_params' instead.""" return self.add_params(*selections) @@ -3767,9 +3729,7 @@ def add_params(self, *params: Parameter) -> Self: copy.spec = copy.spec.add_params(*params) return copy.copy() - @utils.deprecation.deprecated( - message="'add_selection' is deprecated. Use 'add_params' instead." - ) + @utils.deprecated(version="5.0.0", alternative="add_params") def add_selection(self, *selections) -> Self: """'add_selection' is deprecated. Use 'add_params' instead.""" return self.add_params(*selections) diff --git a/tests/utils/test_deprecation.py b/tests/utils/test_deprecation.py index 4754fb187..205652292 100644 --- a/tests/utils/test_deprecation.py +++ b/tests/utils/test_deprecation.py @@ -1,24 +1,38 @@ import pytest - -import altair as alt -from altair.utils import AltairDeprecationWarning -from altair.utils.deprecation import _deprecate, deprecated +import re +from altair.utils.deprecation import ( + AltairDeprecationWarning, + deprecated, + deprecated_warn, +) def test_deprecated_class(): - OldChart = _deprecate(alt.Chart, "OldChart") - with pytest.warns(AltairDeprecationWarning) as record: + class Dummy: + def __init__(self, *args) -> None: + self.args = args + + OldChart = deprecated(version="2.0.0", alternative="LayerChart")(Dummy) + + with pytest.warns(AltairDeprecationWarning, match=r"altair=2\.0\.0.+LayerChart"): OldChart() - assert "alt.OldChart" in record[0].message.args[0] - assert "alt.Chart" in record[0].message.args[0] def test_deprecation_decorator(): - @deprecated(message="func is deprecated") + @deprecated(version="999", alternative="func_12345") def func(x): return x + 1 - with pytest.warns(AltairDeprecationWarning) as record: + with pytest.warns( + AltairDeprecationWarning, match=r"altair=999.+func_12345 instead" + ): y = func(1) assert y == 2 - assert record[0].message.args[0] == "func is deprecated" + + +def test_deprecation_warn(): + with pytest.warns( + AltairDeprecationWarning, + match=re.compile(r"altair=3321.+this code path is a noop", flags=re.DOTALL), + ): + deprecated_warn("this code path is a noop", version="3321", stacklevel=1) diff --git a/tools/update_init_file.py b/tools/update_init_file.py index 5e8bbc6ab..28ba165f0 100644 --- a/tools/update_init_file.py +++ b/tools/update_init_file.py @@ -38,6 +38,7 @@ Sequence, IO, annotations, + te.deprecated, } @@ -112,6 +113,7 @@ def _is_relevant(attr: Any, name: str, /) -> bool: or attr is TYPE_CHECKING or (_is_hashable(attr) and attr in _TYPING_CONSTRUCTS) or name in {"pd", "jsonschema"} + or getattr_static(attr, "__deprecated__", False) ): return False elif ismodule(attr):