Repository: pyjanitor-devs/pyjanitor

Issue:
[EHN] `min_max_scale` drop `old_min` and `old_max` to fit sklearn's method API
<!-- 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:

- Drop `old_min` and `old_max` parameters.
- Merge `new_min` and `new_max` two parameters to one `feature_range`.

<!-- 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 #1068.**

<!-- 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



Discussion:
# [Codecov](https://codecov.io/gh/pyjanitor-devs/pyjanitor/pull/1097?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyjanitor-devs) Report
> Merging [#1097](https://codecov.io/gh/pyjanitor-devs/pyjanitor/pull/1097?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyjanitor-devs) (a655391) into [dev](https://codecov.io/gh/pyjanitor-devs/pyjanitor/commit/5dfc402efbd5436f4522fff4d1f8667b508b5892?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyjanitor-devs) (5dfc402) will **increase** coverage by `0.18%`.
> The diff coverage is `100.00%`.


```diff
@@            Coverage Diff             @@
##              dev    #1097      +/-   ##
==========================================
+ Coverage   97.75%   97.94%   +0.18%     
==========================================
  Files          77       77              
  Lines        3121     3117       -4     
==========================================
+ Hits         3051     3053       +2     
+ Misses         70       64       -6     
```

Since this PR would drop `old_min`, `old_max`, `new_min`, and `new_max` four parameters.
Is there a warning message to notify users the API has changed?
> Since this PR would drop `old_min`, `old_max`, `new_min`, and `new_max` four parameters.
> Is there a warning message to notify users the API has changed?

Good point, @Zeroto521!

If my memory serves me right, there is a function that's available for deprecating arguments/keyword arguments. Could you see if that would do the trick?

If not, something we can do here might be to capture the old kwargs in `**kwargs` and raise an error if any of the old kwargs are used. In the error, I can foresee that explaining the rationale is going to be quite helpful as well (something more diplomatic than "we would like to just follow sklearn", but conveying the same meaning). 
According to this condition, we build a new decorator called `deprecated_kwargs` like `deprecated_alias` in a new PR or directly raise the error in the source code?
For now, I think directly raising the error makes sense, unless you think you've got a good generalizable pattern. In the latter case, we can make a `deprecated_kwargs` decorator directly. I'll leave the decision in your hands, both ways sound reasonable to me!

Resolving patch:
diff --git a/janitor/utils.py b/janitor/utils.py
--- 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 simple_sum(alpha, beta):
     """  # 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 simple_sum(alpha, beta):
 
     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
--- /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