Repository: pyjanitor-devs/pyjanitor
Pull request #1103 (2022-05-12 06:33:58)

[EHN] New decorator `deprecated_kwargs`
<!-- Thank you for your PR!

BEFORE YOU CONTINUE! Please add the appropriate three-letter abbreviation to your title.

The abbreviations can be:
- [DOC]: Documentation fixes.
- [ENH]: Code contributions and new features.
- [TST]: Test-related contributions.
- [INF]: Infrastructure-related contributions.

Also, do not forget to tag the relevant issue here as well.

Finally, as commits come in, don't forget to regularly rebase!
-->

# PR Description

Please describe the changes proposed in the pull request:

Similar to `janitor.utils.deprecated_alias`. But This decorator is for these breaking APIs.

<!-- Doing so provides maintainers with context on what the PR is, and can help us more effectively review your PR. -->

<!-- Please also identify below which issue that has been raised that you are going to close. -->

**This PR resolves #1097.**

<!-- As you go down the PR template, please feel free to delete sections that are irrelevant. -->

# PR Checklist

<!-- This checklist exists for newcomers who are not yet familiar with our requirements. If you are experienced with
the project, please feel free to delete this section. -->

Please ensure that you have done the following:

1. [x] PR in from a fork off your branch. Do not PR from `<your_username>`:`dev`, but rather from `<your_username>`:`<feature-branch_name>`.
<!-- Doing this helps us keep the commit history much cleaner than it would otherwise be. -->
2. [x] If you're not on the contributors list, add yourself to `AUTHORS.md`.
<!-- We'd like to acknowledge your contributions! -->
3. [x] Add a line to `CHANGELOG.md` under the latest version header (i.e. the one that is "on deck") describing the contribution.
    - Do use some discretion here; if there are multiple PRs that are related, keep them in a single line.

# Automatic checks

There will be automatic checks run on the PR. These include:

- Building a preview of the docs on Netlify
- Automatically linting the code
- Making sure the code is documented
- Making sure that all tests are passed
- Making sure that code coverage doesn't go down.

# Relevant Reviewers

<!-- Finally, please tag relevant maintainers to review. -->

Please tag maintainers to review.

- @ericmjl


codecov[bot]: # [Codecov](https://codecov.io/gh/pyjanitor-devs/pyjanitor/pull/1103?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyjanitor-devs) Report
> Merging [#1103](https://codecov.io/gh/pyjanitor-devs/pyjanitor/pull/1103?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyjanitor-devs) (3d38eef) into [dev](https://codecov.io/gh/pyjanitor-devs/pyjanitor/commit/4bcfd6c2826f83b6c10e50ee6eea77816e92ff86?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyjanitor-devs) (4bcfd6c) will **decrease** coverage by `6.31%`.
> The diff coverage is `92.85%`.

> :exclamation: Current head 3d38eef differs from pull request most recent head 7fbeb5c. Consider uploading reports for the commit 7fbeb5c to get more accurate results


```diff
@@            Coverage Diff             @@
##              dev    #1103      +/-   ##
==========================================
- Coverage   97.75%   91.44%   -6.32%     
==========================================
  Files          77       77              
  Lines        3123     3133      +10     
==========================================
- Hits         3053     2865     -188     
- Misses         70      268     +198     
```

Zeroto521: Read to be merged.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e81ca51..50f99ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
 
 -   [DOC] Updated developer guide docs.
 -   [ENH] Allow column selection/renaming within conditional_join. #1102 @samukweku.
+-   [EHN] New decorator `deprecated_kwargs` for breaking API. #1103 @Zeroto521
 
 ## [v0.23.1] - 2022-05-03
 
diff --git a/janitor/utils.py b/janitor/utils.py
index 2f93a4f..b01b979 100644
--- a/janitor/utils.py
+++ b/janitor/utils.py
@@ -1,16 +1,11 @@
 """Miscellaneous internal PyJanitor helper functions."""
 
-import functools
 import os
 import socket
 import sys
-import warnings
-from typing import (
-    Callable,
-    Dict,
-    Iterable,
-    Union,
-)
+from warnings import warn
+from functools import singledispatch, wraps
+from typing import Callable, Dict, Iterable, Union
 
 import numpy as np
 import pandas as pd
@@ -46,7 +41,7 @@ def check(varname: str, value, expected_types: list):
         raise TypeError(f"{varname} should be one of {expected_types}.")
 
 
-@functools.singledispatch
+@singledispatch
 def _expand_grid(value, grid_index, key):
     """
     Base function for dispatch of `_expand_grid`.
@@ -231,6 +226,59 @@ def idempotent(func: Callable, df: pd.DataFrame, *args, **kwargs):
         )
 
 
+def deprecated_kwargs(
+    *arguments: list[str],
+    message: str = (
+        "The keyword argument '{argument}' of '{func_name}' is deprecated."
+    ),
+    error: bool = True,
+) -> Callable:
+    """
+    Used as a decorator when deprecating function's keyword arguments.
+
+    Example:
+
+    ```python
+    from janitor.utils import deprecated_kwargs
+
+    @deprecated_kwargs('x', 'y')
+    def plus(a, b, x=0, y=0):
+        return a + b
+    ```
+
+    :param arguments: The list of deprecated keyword arguments.
+    :param message: The message of `ValueError` or `DeprecationWarning`.
+        It should be a string or a string template. If a string template
+        defaults input `func_name` and `argument`.
+    :param error: If True raises `ValueError` else returns
+        `DeprecationWarning`.
+    :return: The original function wrapped with the deprecated `kwargs`
+        checking function.
+    :raises ValueError: If one of `arguments` is in the decorated function's
+        keyword arguments.  # noqa: DAR402
+    """
+
+    def decorator(func):
+        @wraps(func)
+        def wrapper(*args, **kwargs):
+            for argument in arguments:
+                if argument in kwargs:
+                    msg = message.format(
+                        func_name=func.__name__,
+                        argument=argument,
+                    )
+                    if error:
+                        raise ValueError(msg)
+                    else:
+                        warn(msg, DeprecationWarning)
+
+            return func(*args, **kwargs)
+
+        return wrapper
+
+    return decorator
+
+
 def deprecated_alias(**aliases) -> Callable:
     """
     Used as a decorator when deprecating old function argument names, while
@@ -252,7 +300,7 @@ def deprecated_alias(**aliases) -> Callable:
     """  # noqa: E501
 
     def decorator(func):
-        @functools.wraps(func)
+        @wraps(func)
         def wrapper(*args, **kwargs):
             rename_kwargs(func.__name__, kwargs, aliases)
             return func(*args, **kwargs)
@@ -287,7 +335,7 @@ def refactored_function(message: str) -> Callable:
 
     def decorator(func):
         def emit_warning(*args, **kwargs):
-            warnings.warn(message, FutureWarning)
+            warn(message, FutureWarning)
             return func(*args, **kwargs)
 
         return emit_warning
@@ -315,7 +363,7 @@ def rename_kwargs(func_name: str, kwargs: Dict, aliases: Dict):
                 raise TypeError(
                     f"{func_name} received both {old_alias} and {new_alias}"
                 )
-            warnings.warn(
+            warn(
                 f"{old_alias} is deprecated; use {new_alias}",
                 DeprecationWarning,
             )
@@ -449,7 +497,7 @@ def is_connected(url: str) -> bool:
             return True
     except OSError as e:
 
-        warnings.warn(
+        warn(
             "There was an issue connecting to the internet. "
             "Please see original error below."
         )


Test patch:
diff --git a/tests/utils/test_deprecated_kwargs.py b/tests/utils/test_deprecated_kwargs.py
new file mode 100644
index 0000000..6df527a
--- /dev/null
+++ b/tests/utils/test_deprecated_kwargs.py
@@ -0,0 +1,99 @@
+import pytest
+
+from janitor.utils import deprecated_kwargs
+
+
+@pytest.mark.utils
+@pytest.mark.parametrize(
+    "arguments, message, func_kwargs, msg_expected",
+    [
+        (
+            ["a"],
+            "The keyword argument '{argument}' of '{func_name}' is deprecated",
+            dict(a=1),
+            "The keyword argument 'a' of 'simple_sum' is deprecated",
+        ),
+        (
+            ["b"],
+            "The keyword argument '{argument}' of '{func_name}' is deprecated",
+            dict(b=2),
+            "The keyword argument 'b' of 'simple_sum' is deprecated",
+        ),
+        (
+            ["a", "b"],
+            "The option '{argument}' of '{func_name}' is deprecated.",
+            dict(a=1, b=2),
+            "The option 'a' of 'simple_sum' is deprecated.",
+        ),
+        (
+            ["b", "a"],
+            "The keyword of function is deprecated.",
+            dict(a=1, b=2),
+            "The keyword of function is deprecated.",
+        ),
+    ],
+)
+def test_error(arguments, message, func_kwargs, msg_expected):
+    @deprecated_kwargs(*arguments, message=message)
+    def simple_sum(alpha, beta, a=0, b=0):
+        return alpha + beta
+
+    with pytest.raises(ValueError, match=msg_expected):
+        simple_sum(1, 2, **func_kwargs)
+
+
+@pytest.mark.utils
+@pytest.mark.parametrize(
+    "arguments, message, func_kwargs, msg_expected",
+    [
+        (
+            ["a"],
+            "The keyword argument '{argument}' of '{func_name}' is deprecated",
+            dict(a=1),
+            "The keyword argument 'a' of 'simple_sum' is deprecated",
+        ),
+        (
+            ["b"],
+            "The keyword argument '{argument}' of '{func_name}' is deprecated",
+            dict(b=2),
+            "The keyword argument 'b' of 'simple_sum' is deprecated",
+        ),
+        (
+            ["a", "b"],
+            "The option '{argument}' of '{func_name}' is deprecated.",
+            dict(a=1, b=2),
+            "The option 'a' of 'simple_sum' is deprecated.",
+        ),
+        (
+            ["b", "a"],
+            "The keyword of function is deprecated.",
+            dict(a=1, b=2),
+            "The keyword of function is deprecated.",
+        ),
+    ],
+)
+def test_warning(arguments, message, func_kwargs, msg_expected):
+    @deprecated_kwargs(*arguments, message=message, error=False)
+    def simple_sum(alpha, beta, a=0, b=0):
+        return alpha + beta
+
+    with pytest.warns(DeprecationWarning, match=msg_expected):
+        simple_sum(1, 2, **func_kwargs)
+
+
+@pytest.mark.utils
+@pytest.mark.parametrize(
+    "arguments, func_args, expected",
+    [
+        (["a"], [0, 0], 0),
+        (["b"], [1, 1], 2),
+        (["a", "b"], [0, 1], 1),
+        (["b", "a"], [0, 1], 1),
+    ],
+)
+def test_without_error(arguments, func_args, expected):
+    @deprecated_kwargs(*arguments)
+    def simple_sum(alpha, beta, a=0, b=0):
+        return alpha + beta
+
+    assert simple_sum(*func_args) == expected