diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 08a3369a..4e30f25c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,7 +10,8 @@ chainladder-python/ │ │ │ ├── _config/ # Package-wide configuration │ │ ├── options.py # Datetime constants, package options -│ │ └── deprecation.py # Deprecation utilities +│ │ ├── deprecation.py # Deprecation utilities +│ │ └── tests/ │ │ │ ├── core/ # Triangle data structure │ │ ├── triangle.py # Triangle (the public-facing class) diff --git a/chainladder/__init__.py b/chainladder/__init__.py index 28fbc9a2..8963ee1d 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -32,6 +32,9 @@ _deprecated_backend_message, _dask_parallel_state, _warn_dask_parallel_deprecated, + _deprecated_rename, + _deprecated_rename_argument, + _deprecated_drop_argument, ) from chainladder.utils import ( # noqa (API import) WeightedRegression, diff --git a/chainladder/_config/__init__.py b/chainladder/_config/__init__.py index c62b7af4..c7ee5609 100644 --- a/chainladder/_config/__init__.py +++ b/chainladder/_config/__init__.py @@ -6,7 +6,10 @@ _DEPRECATED_BACKENDS, _deprecated_backend_message, _dask_parallel_state, - _warn_dask_parallel_deprecated, # noqa (API import) + _warn_dask_parallel_deprecated, + _deprecated_rename, + _deprecated_rename_argument, + _deprecated_drop_argument, # noqa (API import) ) from chainladder._config.options import ( __dt64_dtype__, @@ -24,4 +27,7 @@ "_deprecated_backend_message", "_dask_parallel_state", "_warn_dask_parallel_deprecated", + "_deprecated_rename", + "_deprecated_rename_argument", + "_deprecated_drop_argument", ] diff --git a/chainladder/_config/deprecation.py b/chainladder/_config/deprecation.py index 44843ef3..6710751a 100644 --- a/chainladder/_config/deprecation.py +++ b/chainladder/_config/deprecation.py @@ -7,14 +7,15 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. from __future__ import annotations +import functools import inspect import warnings -from typing import overload, TYPE_CHECKING +from typing import overload, TYPE_CHECKING, TypeVar if TYPE_CHECKING: from types import FrameType - from typing import Literal + from typing import Callable, Literal del TYPE_CHECKING del annotations @@ -147,3 +148,225 @@ def _resolve_pat( caller: str = f_back.f_code.co_name raise TypeError(f"{caller}() missing required argument: 'pat'.") return pat + + +# Type variable ensures that decorated functions maintain their signatures. +_F = TypeVar("_F", bound="Callable[..., object]") + + +def _deprecated_rename( + new_name: str, + *, + version: str | None = None, + category: type[Warning] = FutureWarning, +) -> Callable[[_F], _F]: + """ + Decorator factory that marks a function as scheduled to be renamed. + + Calling the decorated function will emit a warning that the function will be renamed in a future release. + + Deprecation steps: + + 1. Define a function with the new name. + 2. Move the body of the deprecated function to the new function. + 3. Have the old function serve as a wrapper to the new function. + 4. Apply the decorator to the old function. + 5. Once you are ready to deprecate, delete the old function and its + decorator. + + Parameters + ---------- + new_name: str + The name this function will be renamed to. + version: str | None + The release the rename is expected to land in, e.g. "0.11.0". + Included in the warning message when given. Optional. + category: type[Warning] + The warning category to emit. Defaults to FutureWarning. + + Returns + ------- + Callable + A decorator that wraps a function, preserving its name, docstring, + and signature. + + Examples + -------- + + .. testcode:: + :options: +SKIP + + from chainladder._config.deprecation import _deprecated_rename + + def new_func(x): + return x + 1 + + @_deprecated_rename("new_func", version="0.11.0") + def old_func(x): + return new_func(x) + + print(old_func(1)) + + .. testoutput:: + + example.py:11: FutureWarning: 'old_func' is deprecated and will be renamed to 'new_func' in 0.11.0. Update your code to use 'new_func' instead. + print(old_func(1)) + + """ + + def decorator(func: _F) -> _F: + old_name = func.__name__ + message = f"'{old_name}' is deprecated and will be renamed to '{new_name}'" + if version: + message += f" in {version}" + message += f". Update your code to use '{new_name}' instead." + + @functools.wraps(func) + def wrapper(*args, **kwargs): + warnings.warn(message, category, stacklevel=2) # noqa + return func(*args, **kwargs) + + return wrapper # type: ignore[return-value] + + return decorator + + +def _deprecated_rename_argument( + old_name: str, + new_name: str, + *, + version: str | None = None, + category: type[Warning] = FutureWarning, +) -> Callable[[_F], _F]: + """ + Decorator factory that marks a keyword argument as scheduled to be + renamed. + + Apply this to a function while it still accepts the argument under its + *current* name, to warn callers ahead of the actual rename. + + This decorator allows you to replace the old argument with the new argument + in the function signature. Once you are ready to deprecate, simply remove the + decorator. + + Parameters + ---------- + old_name: str + The keyword argument name the function currently accepts. + new_name: str + The keyword argument name it will be renamed to. + version: str | None + The release the rename is expected to land in, e.g. "0.11.0". + Included in the warning message when given. Optional. + category: type[Warning] + The warning category to emit. Defaults to FutureWarning. + + Returns + ------- + Callable + A decorator that wraps a function, preserving its name, docstring, + and signature via functools.wraps. + + Examples + -------- + + .. testcode:: + :options: +SKIP + + from chainladder._config.deprecation import _deprecated_rename_argument + + @_deprecated_rename_argument("old_arg", "new_arg", version="0.11.0") + def func(new_arg): + return new_arg + 1 + + print(func(old_arg=1)) + + .. testoutput:: + + example.py:8: FutureWarning: 'old_arg' is deprecated and will be renamed to 'new_arg' in 0.11.0. Use 'new_arg' instead. + func(old_arg=1) + + """ + + def decorator(func: _F) -> _F: + message = f"'{old_name}' is deprecated and will be renamed to '{new_name}'" + if version: + message += f" in {version}" + message += f". Use '{new_name}' instead." + + @functools.wraps(func) + def wrapper(*args, **kwargs): + if old_name in kwargs: + if new_name in kwargs: + raise TypeError( + f"Cannot specify both '{old_name}' and '{new_name}'." + ) + warnings.warn(message, category, stacklevel=2) # noqa + kwargs[new_name] = kwargs.pop(old_name) + return func(*args, **kwargs) + + return wrapper # type: ignore[return-value] + + return decorator + + +def _deprecated_drop_argument( + name: str, + *, + version: str | None = None, + category: type[Warning] = FutureWarning, +) -> Callable[[_F], _F]: + """ + Decorator factory that marks a keyword argument as scheduled for removal, + with no replacement. + + Parameters + ---------- + name: str + The keyword argument scheduled for removal. + version: str | None + The release the removal is expected to land in, e.g. "0.11.0". + Included in the warning message when given. Optional. + category: type[Warning] + The warning category to emit. Defaults to FutureWarning. + + Returns + ------- + Callable + A decorator that wraps a function, preserving its name, docstring, + and signature via functools.wraps. + + Examples + -------- + + .. testcode:: + :options: +SKIP + + from chainladder._config.deprecation import _deprecated_drop_argument + + @_deprecated_drop_argument("verbose", version="0.11.0") + def func(x, verbose=False): + return x + 1 + + print(func(1, verbose=True)) + + .. testoutput:: + + example.py:8: FutureWarning: 'verbose' is deprecated and will be removed in 0.11.0. + func(1, verbose=True) + + """ + + def decorator(func: _F) -> _F: + message = f"'{name}' is deprecated and will be removed" + message += f" in {version}." if version else " in a future release." + + @functools.wraps(func) + def wrapper(*args, **kwargs): + if name in kwargs: + warnings.warn(message, category, stacklevel=2) # noqa + return func(*args, **kwargs) + + return wrapper # type: ignore[return-value] + + return decorator diff --git a/chainladder/_config/tests/__init__.py b/chainladder/_config/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/chainladder/_config/tests/test_deprecation.py b/chainladder/_config/tests/test_deprecation.py new file mode 100644 index 00000000..b6d3b09c --- /dev/null +++ b/chainladder/_config/tests/test_deprecation.py @@ -0,0 +1,360 @@ +""" +Test the deprecation tools. +""" + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +from __future__ import annotations + +import warnings + +import pytest + +from chainladder._config.deprecation import ( + _deprecated_drop_argument, + _deprecated_rename, + _deprecated_rename_argument, +) + + +def _warn_once(func, *args, **kwargs) -> tuple[object, warnings.WarningMessage]: + """Calls func and returns (result, the single warning recorded).""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = func(*args, **kwargs) + assert caught is not None + # Check that the warning was triggered. + assert len(caught) == 1 + return result, caught[0] + + +def _warn_never(func, *args, **kwargs) -> object: + """Calls func and returns its result, checking that nothing was warned.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = func(*args, **kwargs) + assert caught is not None + # Check that no warning was triggered. + assert len(caught) == 0 + return result + + +class TestDeprecatedRename: + """Test the _deprecated_rename decorator.""" + + def test_warns_default_category(self) -> None: + """Check that the default warning category is FutureWarning.""" + + def new_func(x): + return x + 1 + + @_deprecated_rename("new_func") + def old_func(x): + return new_func(x) + + result, warning = _warn_once(old_func, 1) + assert result == 2 + assert warning.category is FutureWarning + + def test_message_with_version(self) -> None: + """Check the warning message when a version is given.""" + + def new_func(): + return 0 + + @_deprecated_rename("new_func", version="0.11.0") + def old_func(): + return new_func() + + _, warning = _warn_once(old_func) + assert str(warning.message) == ( + "'old_func' is deprecated and will be renamed to 'new_func' in " + "0.11.0. Update your code to use 'new_func' instead." + ) + + def test_message_without_version(self) -> None: + """Check the warning message when no version is given.""" + + def new_func(): + return 0 + + @_deprecated_rename("new_func") + def old_func(): + return new_func() + + _, warning = _warn_once(old_func) + assert str(warning.message) == ( + "'old_func' is deprecated and will be renamed to 'new_func'. " + "Update your code to use 'new_func' instead." + ) + + def test_custom_category(self) -> None: + """Check that a custom warning category is honored.""" + + def new_func(): + return 0 + + @_deprecated_rename("new_func", category=DeprecationWarning) + def old_func(): + return new_func() + + with pytest.warns(DeprecationWarning): + old_func() + + def test_forwards_args_and_kwargs(self) -> None: + """Check that positional and keyword arguments reach the new function unchanged.""" + + def new_func(a, b, *, c): + return a * 100 + b * 10 + c + + @_deprecated_rename("new_func") + def old_func(*args, **kwargs): + return new_func(*args, **kwargs) + + # Weighting by position makes the result spell out the argument order, + # so 1, 2, 3 can only arrive intact as 123. + result, _ = _warn_once(old_func, 1, 2, c=3) + assert result == 123 + + def test_preserves_metadata(self) -> None: + """Check that functools.wraps preserves the function's name and docstring.""" + + def new_func(): + return 0 + + @_deprecated_rename("new_func") + def old_func(): + """Original docstring.""" + return new_func() + + assert old_func.__name__ == "old_func" + assert old_func.__doc__ == "Original docstring." + + def test_warns_every_call(self) -> None: + """Check that the warning fires on every call, not just the first.""" + + def new_func(): + return 0 + + @_deprecated_rename("new_func") + def old_func(): + return new_func() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + old_func() + old_func() + assert caught is not None + assert len(caught) == 2 + + +class TestDeprecatedRenameArgument: + """Tests for the _deprecated_rename_argument decorator.""" + + def test_old_name_translates_and_warns(self) -> None: + """Check that the old argument name is translated to the new one and warns.""" + + @_deprecated_rename_argument("old_arg", "new_arg") + def func(new_arg): + return new_arg + + result, warning = _warn_once(func, old_arg=1) + assert result == 1 + assert warning.category is FutureWarning + + def test_new_name_no_warning(self) -> None: + """Check that calling with the new argument name alone doesn't warn.""" + + @_deprecated_rename_argument("old_arg", "new_arg") + def func(new_arg): + return new_arg + + assert _warn_never(func, new_arg=1) == 1 + + def test_neither_name_uses_default_no_warning(self) -> None: + """Check that omitting both names falls back to the default without warning.""" + + @_deprecated_rename_argument("old_arg", "new_arg") + def func(new_arg="default"): + return new_arg + + assert _warn_never(func) == "default" + + def test_both_names_raises_type_error(self) -> None: + """Check that passing both the old and new names raises a TypeError.""" + + @_deprecated_rename_argument("old_arg", "new_arg") + def func(new_arg=None): + return new_arg + + with pytest.raises( + TypeError, match="Cannot specify both 'old_arg' and 'new_arg'" + ): + # noinspection PyArgumentList + func(old_arg=1, new_arg=2) # pyright: ignore[reportCallIssue] + + def test_message_with_version(self) -> None: + """Check the warning message when a version is given.""" + + @_deprecated_rename_argument("old_arg", "new_arg", version="0.11.0") + def func(new_arg=None): + return new_arg + + _, warning = _warn_once(func, old_arg=1) + assert str(warning.message) == ( + "'old_arg' is deprecated and will be renamed to 'new_arg' in " + "0.11.0. Use 'new_arg' instead." + ) + + def test_message_without_version(self) -> None: + """Check the warning message when no version is given.""" + + @_deprecated_rename_argument("old_arg", "new_arg") + def func(new_arg=None): + return new_arg + + _, warning = _warn_once(func, old_arg=1) + assert str(warning.message) == ( + "'old_arg' is deprecated and will be renamed to 'new_arg'. " + "Use 'new_arg' instead." + ) + + def test_custom_category(self) -> None: + """Check that a custom warning category is honored.""" + + @_deprecated_rename_argument("old_arg", "new_arg", category=DeprecationWarning) + def func(new_arg=None): + return new_arg + + with pytest.warns(DeprecationWarning): + # noinspection PyArgumentList + func(old_arg=1) # pyright: ignore[reportCallIssue] + + def test_positional_args_unaffected(self) -> None: + """Check that positional arguments pass through unaffected.""" + + @_deprecated_rename_argument("old_arg", "new_arg") + def func(a, b, new_arg=None): + return a, b, new_arg + + result, _ = _warn_once(func, 1, 2, old_arg=3) + assert result == (1, 2, 3) + + def test_preserves_metadata(self) -> None: + """Check that functools.wraps preserves the function's name and docstring.""" + + @_deprecated_rename_argument("old_arg", "new_arg") + def func(new_arg=None): # noqa + """Original docstring.""" + + assert func.__name__ == "func" + assert func.__doc__ == "Original docstring." + + def test_warns_every_call(self) -> None: + """Check that the warning fires on every call, not just the first.""" + + @_deprecated_rename_argument("old_arg", "new_arg") + def func(new_arg=None): + return new_arg + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + # noinspection PyArgumentList + func(old_arg=1) # pyright: ignore[reportCallIssue] + # noinspection PyArgumentList + func(old_arg=2) # pyright: ignore[reportCallIssue] + assert caught is not None + assert len(caught) == 2 + + +class TestDeprecatedDropArgument: + """Tests for the _deprecated_drop_argument decorator.""" + + def test_warns_and_forwards_value_unchanged(self) -> None: + """Check that the deprecated argument still reaches the function unchanged.""" + + @_deprecated_drop_argument("verbose") + def func(x, verbose: bool = False): + return x, verbose + + result, warning = _warn_once(func, 1, verbose=True) + assert result == (1, True) + assert warning.category is FutureWarning + + def test_not_passed_no_warning(self) -> None: + """Check that omitting the deprecated argument doesn't warn.""" + + @_deprecated_drop_argument("verbose") + def func(x, verbose: bool = False): + return x, verbose + + assert _warn_never(func, 1) == (1, False) + + def test_message_with_version(self) -> None: + """Check the warning message when a version is given.""" + + @_deprecated_drop_argument("verbose", version="0.11.0") + def func(verbose: bool = False): # noqa + pass + + _, warning = _warn_once(func, verbose=True) + assert str(warning.message) == ( + "'verbose' is deprecated and will be removed in 0.11.0." + ) + + def test_message_without_version(self) -> None: + """Check the warning message when no version is given.""" + + @_deprecated_drop_argument("verbose") + def func(verbose: bool = False): # noqa + pass + + _, warning = _warn_once(func, verbose=True) + assert str(warning.message) == ( + "'verbose' is deprecated and will be removed in a future release." + ) + + def test_custom_category(self) -> None: + """Check that a custom warning category is honored.""" + + @_deprecated_drop_argument("verbose", category=DeprecationWarning) + def func(verbose: bool = False): # noqa + pass + + with pytest.warns(DeprecationWarning): + func(verbose=True) + + def test_positional_args_unaffected(self) -> None: + """Check that positional arguments pass through unaffected.""" + + @_deprecated_drop_argument("verbose") + def func(a, b, verbose: bool = False): + return a, b, verbose + + result, _ = _warn_once(func, 1, 2, verbose=True) + assert result == (1, 2, True) + + def test_preserves_metadata(self) -> None: + """Check that functools.wraps preserves the function's name and docstring.""" + + @_deprecated_drop_argument("verbose") + def func(verbose: bool = False): # noqa + """Original docstring.""" + + assert func.__name__ == "func" + assert func.__doc__ == "Original docstring." + + def test_warns_every_call(self) -> None: + """Check that the warning fires on every call, not just the first.""" + + @_deprecated_drop_argument("verbose") + def func(verbose: bool = False): # noqa + pass + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + func(verbose=True) + func(verbose=True) + assert caught is not None + assert len(caught) == 2