Repository: TRoboto/Maha (language: python)

Issue:
Add deprecation system
### What problem are you trying to solve?

The change or removal must not be immediate to the name of any variable, function, or class, the default parameter values of any function or class, or any other change that the community is used to work with. The old behavior should remain for at least two to five upcoming versions. This feature request asks for a depreciation system that easily informs users of any upcoming change.

### Examples (if relevant)

A decorator like this would be sufficient.

```py
@deprecated(
from="v0.1.0", 
to="v0.1.2", 
message="This function will be deprecated in version 0.1.2. Please use test_function2 instead"
)
def test_function(a, b):
    pass
```

### Definition of Done

- The deprecation system should handle at least deprecated functions, parameters and default parameters. It is recommended have it handle deprecated classes and variables.
- It should be very easy to use and scale.


Discussion / hints:

Fix patch:
diff --git a/maha/deprecation.py b/maha/deprecation.py
new file mode 100644
index 00000000..e5d3b7d3
--- /dev/null
+++ b/maha/deprecation.py
@@ -0,0 +1,203 @@
+__all__ = ["deprecated_fn", "deprecated_param", "deprecated_default"]
+
+from functools import wraps
+from typing import Any
+
+
+def deprecated_fn(from_v: str, to_v: str, alt_fn: str, message: str = ""):
+    """Decorator to mark a function as deprecated.
+
+    Parameters
+    ----------
+    from_v : str
+        Version from since the function is deprecated
+    to_v : str
+        Version from which the function is removed
+    alt_fn: str
+        New function to replace the deprecated function
+    message : str
+        Message to display when the function is called
+
+    Returns
+    -------
+    function
+        The decorated function
+    """
+
+    if from_v is None or to_v is None or alt_fn is None:
+        raise ValueError("from_v, to_v, and alt_fn cannot be None")
+
+    def decorator(func):
+        msg = f"{func.__name__} is deprecated since version {from_v} and will be removed in version {to_v}."
+        if alt_fn is not None:
+            msg += f" Use {alt_fn} instead."
+
+        if message:
+            msg = f"{msg} {message}"
+
+        @wraps(func)
+        def wrapper(*args, **kwargs):
+            deprecation_warning(msg)
+            return func(*args, **kwargs)
+
+        return wrapper
+
+    return decorator
+
+
+def deprecated_param(
+    from_v: str,
+    to_v: str,
+    depr_param: str,
+    alt_param: str = None,
+    message: str = "",
+):
+    """Decorator to mark a parameter as deprecated.
+
+    Parameters
+    ----------
+    from_v : str
+        Version since which the parameter is deprecated
+    to_v : str
+        Version from which the parameter is removed
+    depr_param: str
+        Parameter to deprecate
+    alt_param: str
+        Parameter to use instead
+    message : str
+        Message to display when the function is called
+
+    Returns
+    -------
+    function
+        The decorated function
+    """
+
+    if from_v is None or to_v is None or depr_param is None:
+        raise ValueError("from_v, to_v and depr_param cannot be None")
+
+    def decorator(func):
+        from inspect import Parameter, signature
+
+        sig = signature(func)
+        params = sig.parameters
+
+        if depr_param not in params:
+            raise ValueError(f"{depr_param} is not a parameter of {func.__name__}")
+
+        if alt_param is not None and alt_param not in params:
+            raise ValueError(f"{alt_param} is not a parameter of {func.__name__}")
+
+        msg = f"{depr_param} is deprecated since version {from_v} and will be removed in version {to_v}."
+        if alt_param is not None:
+            msg += f" Use {alt_param} instead."
+
+        if message:
+            msg = f"{msg} {message}"
+
+        @wraps(func)
+        def wrapper(*args, **kwargs):
+            # the first condition checks if it's passed as a keyword argument
+            # the second condition checks if it's passed as a positional argument
+            # the third condition handles the case where the optional parameter is
+            # passed as a positional argument
+            positionales = [
+                k for k, v in params.items() if v.default is Parameter.empty
+            ]
+            if (
+                depr_param in kwargs
+                or depr_param in positionales
+                or list(params.keys()).index(depr_param) <= len(args) - 1
+            ):
+                deprecation_warning(msg)
+            return func(*args, **kwargs)
+
+        return wrapper
+
+    return decorator
+
+
+def deprecated_default(
+    from_v: str,
+    to_v: str,
+    depr_param: str,
+    alt_value: Any = None,
+    message: str = "",
+):
+    """Decorator to mark a parameter default value as deprecated.
+
+    Parameters
+    ----------
+    from_v : str
+        Version since which the parameter is deprecated
+    to_v : str
+        Version from which the parameter is removed
+    depr_param: str
+        Parameter to deprecate its default value
+    alt_value: str
+        New default value that will used instead
+    message : str
+        Message to display when the function is called
+
+    Returns
+    -------
+    function
+        The decorated function
+    """
+
+    if from_v is None or to_v is None or depr_param is None:
+        raise ValueError("from_v, to_v and depr_param cannot be None")
+
+    def decorator(func):
+        from inspect import Parameter, signature
+
+        sig = signature(func)
+        params = sig.parameters
+
+        if depr_param not in params:
+            raise ValueError(f"{depr_param} is not a parameter of {func.__name__}")
+
+        if params[depr_param].default is Parameter.empty:
+            raise ValueError(f"{depr_param} has no default value")
+
+        depr_param_default = params[depr_param].default
+
+        msg = f"The default value of {depr_param} is deprecated since version {from_v}"
+        if alt_value is None:
+            msg += f" and will be removed in version {to_v}."
+        else:
+            msg += f" and will become {depr_param}={alt_value} in version {to_v}."
+
+        if message:
+            msg = f"{msg} {message}"
+
+        @wraps(func)
+        def wrapper(*args, **kwargs):
+            # if the parameter isn't passed as a keyword argument
+            if depr_param not in kwargs:
+                nonlocal msg
+                msg += f" Use {depr_param}={depr_param_default} to preserve the current behavior."
+
+            deprecation_warning(msg)
+            return func(*args, **kwargs)
+
+        return wrapper
+
+    return decorator
+
+
+def deprecation_warning(msg: str):
+    """Prints a deprecation warning.
+
+    Parameters
+    ----------
+    msg : str
+        Message to print
+    """
+    import warnings
+
+    warnings.warn(
+        msg,
+        DeprecationWarning,
+        stacklevel=2,
+    )


Test patch:
diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py
new file mode 100644
index 00000000..1d6c7521
--- /dev/null
+++ b/tests/test_deprecation.py
@@ -0,0 +1,209 @@
+import pytest
+
+from maha.deprecation import deprecated_default, deprecated_fn, deprecated_param
+
+
+def _get_warning_msg(recwarn):
+    w = recwarn.pop(DeprecationWarning)
+    return w.message.args[0]
+
+
+def test_deprecated_default(recwarn):
+    @deprecated_default(from_v="0.2.0", to_v="0.5.0", depr_param="arg11")
+    def f(arg11=1):
+        pass
+
+    f()
+    msg = _get_warning_msg(recwarn)
+    assert "0.2.0" in msg
+    assert "0.5.0" in msg
+    assert "arg11" in msg
+    assert "arg11=1" in msg
+
+
+def test_deprecated_default_with_alt(recwarn):
+    @deprecated_default(from_v="0.2.0", to_v="0.5.0", depr_param="arg11", alt_value=999)
+    def f(arg11=1):
+        pass
+
+    f()
+    msg = _get_warning_msg(recwarn)
+    assert "0.2.0" in msg
+    assert "0.5.0" in msg
+    assert "arg11" in msg
+    assert "999" in msg
+    assert "arg11=1" in msg
+
+
+def test_deprecated_default_with_passed_argument(recwarn):
+    @deprecated_default(
+        from_v="0.2.0", to_v="0.5.0", depr_param="arg11", alt_value="None"
+    )
+    def f(arg11=1):
+        pass
+
+    f(arg11=3)
+    msg = _get_warning_msg(recwarn)
+    assert "0.2.0" in msg
+    assert "0.5.0" in msg
+    assert "arg11" in msg
+    assert "None" in msg
+    assert "arg11=1" not in msg
+
+
+def test_deprecated_default_invalid_parameter():
+
+    with pytest.raises(ValueError):
+
+        @deprecated_default(
+            from_v="0.2.0", to_v="0.5.0", depr_param="x1", alt_value=999
+        )
+        def f(x=1):
+            return x
+
+
+def test_deprecated_default_with_message(recwarn):
+    @deprecated_default(
+        from_v="0.2.0",
+        to_v="0.5.0",
+        depr_param="arg11",
+        alt_value="None",
+        message="This is a message",
+    )
+    def f(arg11=1):
+        pass
+
+    f(arg11=3)
+    msg = _get_warning_msg(recwarn)
+    assert "This is a message" in msg
+
+
+def test_deprecated_fn(recwarn):
+    @deprecated_fn(
+        from_v="0.2.0",
+        to_v="0.5.0",
+        alt_fn="my_alt_fn",
+    )
+    def f():
+        pass
+
+    f()
+    msg = _get_warning_msg(recwarn)
+    assert "0.2.0" in msg
+    assert "0.5.0" in msg
+    assert "my_alt_fn" in msg
+
+
+def test_deprecated_fn_with_message(recwarn):
+    @deprecated_fn(
+        from_v="0.2.0",
+        to_v="0.5.0",
+        alt_fn="my_alt_fn",
+        message="This is a message",
+    )
+    def f():
+        pass
+
+    f()
+    msg = _get_warning_msg(recwarn)
+    assert "0.2.0" in msg
+    assert "0.5.0" in msg
+    assert "my_alt_fn" in msg
+    assert "This is a message" in msg
+
+
+def test_deprecated_param_with_arg_input(recwarn):
+    @deprecated_param(from_v="0.2.0", to_v="0.5.0", depr_param="arg11")
+    def f(a, b, arg11=3, arg12=1):
+        pass
+
+    f(1, 2, 3)
+    msg = _get_warning_msg(recwarn)
+    assert "0.2.0" in msg
+    assert "0.5.0" in msg
+    assert "arg11" in msg
+
+
+def test_deprecated_param_with_arg_input2(recwarn):
+    @deprecated_param(from_v="0.2.0", to_v="0.5.0", depr_param="arg12")
+    def f(a, b, arg11=3, arg12=1):
+        pass
+
+    f(1, 2, 3)
+    with pytest.raises(AssertionError):
+        _get_warning_msg(recwarn)
+
+
+def test_deprecated_param_with_arg_input3(recwarn):
+    @deprecated_param(from_v="0.2.0", to_v="0.5.0", depr_param="arg13")
+    def f(a, b, arg11=3, arg12=1, arg13=1):
+        pass
+
+    f(1, 2, 3, arg13=10)
+    msg = _get_warning_msg(recwarn)
+    assert "0.2.0" in msg
+    assert "0.5.0" in msg
+    assert "arg13" in msg
+
+
+def test_deprecated_param_with_kwarg_input(recwarn):
+    @deprecated_param(from_v="0.2.0", to_v="0.5.0", depr_param="arg11")
+    def f(arg11=1):
+        pass
+
+    f(arg11=10)
+    msg = _get_warning_msg(recwarn)
+    assert "0.2.0" in msg
+    assert "0.5.0" in msg
+    assert "arg11" in msg
+
+
+def test_deprecated_param_without_input(recwarn):
+    @deprecated_param(from_v="0.2.0", to_v="0.5.0", depr_param="arg11")
+    def f(arg11=1):
+        pass
+
+    f()
+    with pytest.raises(AssertionError):
+        _get_warning_msg(recwarn)
+
+
+def test_deprecated_param_with_alt_argument(recwarn):
+    @deprecated_param(
+        from_v="0.2.0", to_v="0.5.0", depr_param="arg11", alt_param="arg12"
+    )
+    def f(arg11=1, arg12=1):
+        pass
+
+    f(arg11=3)
+    msg = _get_warning_msg(recwarn)
+    assert "0.2.0" in msg
+    assert "0.5.0" in msg
+    assert "arg11" in msg
+    assert "arg12" in msg
+
+
+def test_deprecated_param_with_alt_argument_not_found():
+
+    with pytest.raises(ValueError):
+
+        @deprecated_param(
+            from_v="0.2.0", to_v="0.5.0", depr_param="arg11", alt_param="arg12"
+        )
+        def f(arg11=1):
+            pass
+
+
+def test_deprecated_param_with_message(recwarn):
+    @deprecated_param(
+        from_v="0.2.0",
+        to_v="0.5.0",
+        depr_param="arg11",
+        message="This is a message",
+    )
+    def f(arg11=1):
+        pass
+
+    f(arg11=3)
+    msg = _get_warning_msg(recwarn)
+    assert "This is a message" in msg