Repository: tantale/deprecated

Issue:
[ENH] Stacklevel offset
Over at https://github.com/coroa/pandas-indexing/pull/27, I am about to deprecate a [pandas accessor](https://pandas.pydata.org/docs/development/extending.html#registering-custom-accessors), but since these accessors are classes, which are instantiated by pandas upon access the warning is not emitted since the `stacklevel` is too low.

# MWE

```python
import pandas as pd
from deprecated import deprecated

@pd.api.extensions.register_dataframe_accessor("idx")
@deprecated
class IdxAccessor:
    def __init__(self, pandas_obj):
        self._obj = pandas_obj

df = pd.DataFrame()
df.idx
```

will only emit with a ~`warnings.simplefilter("always")`~ `warnings.simplefilter("default")` (edit: changed to include "default" which was pointed out below), since the callstack looks like:
```python
  Cell In[4], line 11
    df.idx
  File ~/.local/conda/envs/aneris2/lib/python3.10/site-packages/pandas/core/accessor.py:182 in __get__
    accessor_obj = self._accessor(obj)
  File ~/.local/conda/envs/aneris2/lib/python3.10/site-packages/deprecated/classic.py:169 in wrapped_cls
    warnings.warn(msg, category=self.category, stacklevel=_class_stacklevel)
```
so that the last `stacklevel=2` setting points to the `pandas.core.accessor` module instead of the user code.

# Proposal

Would you accept a PR to add a `stacklevel_offset` or `stacklevel` argument to deprecated which would either be added like:
```python
    warnings.warn(msg, category=self.category, stacklevel=_class_stacklevel + stacklevel_offset)
```
or could be used to replace the default `stacklevel`, like:
```python
    warnings.warn(msg, category=self.category, stacklevel=_class_stacklevel if stacklevel is None else stacklevel)
```



Discussion:
tantale: Suggested unit test:

```python
import inspect
import warnings

import deprecated.classic


def test_default_stacklevel():
    """
    The objective of this unit test is to ensure that the triggered warning message,
    when invoking the 'use_foo' function, correctly indicates the line where the
    deprecated 'foo' function is called.
    """

    @deprecated.classic.deprecated
    def foo():
        pass

    def use_foo():
        foo()

    with warnings.catch_warnings(record=True) as warns:
        warnings.simplefilter("always")
        use_foo()

    # Check that the warning path matches the module path
    warn = warns[0]
    assert warn.filename == __file__

    # Check that the line number points to the first line inside 'use_foo'
    use_foo_lineno = inspect.getsourcelines(use_foo)[1]
    assert warn.lineno == use_foo_lineno + 1


def test_extra_stacklevel():
    """
    The unit test utilizes an 'extra_stacklevel' of 1 to ensure that the warning message
    accurately identifies the caller of the deprecated function. It verifies that when
    the 'use_foo' function is called, the warning message correctly indicates the line
    where the call to 'use_foo' is made.
    """

    @deprecated.classic.deprecated(extra_stacklevel=1)
    def foo():
        pass

    def use_foo():
        foo()

    def demo():
        use_foo()

    with warnings.catch_warnings(record=True) as warns:
        warnings.simplefilter("always")
        demo()

    # Check that the warning path matches the module path
    warn = warns[0]
    assert warn.filename == __file__

    # Check that the line number points to the first line inside 'demo'
    demo_lineno = inspect.getsourcelines(demo)[1]
    assert warn.lineno == demo_lineno + 1
```

tantale: Suggested documentation

In `docs/source/tutorial.rst`:

```rst
Modifying the deprecated code reference
---------------------------------------

By default, when a deprecated function or class is called, the warning message indicates the location of the caller.

The ``extra_stacklevel`` parameter allows customizing the stack level reference in the deprecation warning message.

This parameter is particularly useful in scenarios where you have a factory or utility function that creates deprecated
objects or performs deprecated operations. By specifying an ``extra_stacklevel`` value, you can control the stack level
at which the deprecation warning is emitted, making it appear as if the calling function is the deprecated one,
rather than the actual deprecated entity.

For example, if you have a factory function ``create_object()`` that creates deprecated objects, you can use
the ``extra_stacklevel`` parameter to emit the deprecation warning at the calling location. This provides clearer and
more actionable deprecation messages, allowing developers to identify and update the code that invokes the deprecated
functionality.

For instance:

.. literalinclude:: tutorial/warning_ctrl/extra_stacklevel_demo.py


Please note that the ``extra_stacklevel`` value should be an integer indicating the number of stack levels to skip
when emitting the deprecation warning.
```

And a new demo in `docs/source/tutorial/warning_ctrl/extra_stacklevel_demo.py`:

```python
import warnings

from deprecated import deprecated


@deprecated(version='1.0', extra_stacklevel=1)
class MyObject(object):
    def __init__(self, name):
        self.name = name

    def __str__(self):
        return "object: {name}".format(name=self.name)


def create_object(name):
    return MyObject(name)


if __name__ == '__main__':
    warnings.filterwarnings("default", category=DeprecationWarning)
    # warn here:
    print(create_object("orange"))
    # and also here:
    print(create_object("banane"))
```
coroa: Oh, we worked in parallel on that. Sorry, should have clarified i'll take care of it. Let me incorporate your suggestions. To understand my unit test there is a lot of prior knowledge involved.

Also I added an optional refactoring commit, which i think disentangles code responsibilities. I am happy to move that into a second PR, if that is preferred.
coroa: I also think you also would have been allowed to directly push to this branch.
tantale: > Oh, we worked in parallel on that. Sorry, should have clarified i'll take care of it. Let me incorporate your suggestions. To understand my unit test there is a lot of prior knowledge involved.
> 
> Also I added an optional refactoring commit, which i think disentangles code responsibilities. I am happy to move that into a second PR, if that is preferred.

Creating a new PR is not necessary.
tantale: Ok, I've just seen a regression regarding the Python 2.7 support. I fixed it on the `master` branch.

So, your code is correct. I have done some minor changes. I will rebase your branch on the master branch and then I will _force_ push my changes to your branch so that you can see the changes.

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ec2f685..1e43f51 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -28,6 +28,8 @@ Fix
 
 - Resolve Python 2.7 support issue introduced in v1.2.14 in ``sphinx.py``.
 
+- Fix #69: Add ``extra_stacklevel`` argument for interoperating with other wrapper functions (refer to #68 for a concrete use case).
+
 Other
 -----
 
diff --git a/deprecated/classic.py b/deprecated/classic.py
index 6ca3f27..84f683c 100644
--- a/deprecated/classic.py
+++ b/deprecated/classic.py
@@ -17,7 +17,7 @@ import wrapt
 try:
     # If the C extension for wrapt was compiled and wrapt/_wrappers.pyd exists, then the
     # stack level that should be passed to warnings.warn should be 2. However, if using
-    # a pure python wrapt, a extra stacklevel is required.
+    # a pure python wrapt, an extra stacklevel is required.
     import wrapt._wrappers
 
     _routine_stacklevel = 2
@@ -83,7 +83,7 @@ class ClassicAdapter(wrapt.AdapterFactory):
            return x + y
     """
 
-    def __init__(self, reason="", version="", action=None, category=DeprecationWarning):
+    def __init__(self, reason="", version="", action=None, category=DeprecationWarning, extra_stacklevel=0):
         """
         Construct a wrapper adapter.
 
@@ -97,23 +97,33 @@ class ClassicAdapter(wrapt.AdapterFactory):
             If you follow the `Semantic Versioning <https://semver.org/>`_,
             the version number has the format "MAJOR.MINOR.PATCH".
 
-        :type  action: str
+        :type  action: Literal["default", "error", "ignore", "always", "module", "once"]
         :param action:
             A warning filter used to activate or not the deprecation warning.
             Can be one of "error", "ignore", "always", "default", "module", or "once".
-            If ``None`` or empty, the the global filtering mechanism is used.
+            If ``None`` or empty, the global filtering mechanism is used.
             See: `The Warnings Filter`_ in the Python documentation.
 
-        :type  category: type
+        :type  category: Type[Warning]
         :param category:
             The warning category to use for the deprecation warning.
             By default, the category class is :class:`~DeprecationWarning`,
             you can inherit this class to define your own deprecation warning category.
+
+        :type  extra_stacklevel: int
+        :param extra_stacklevel:
+            Number of additional stack levels to consider instrumentation rather than user code.
+            With the default value of 0, the warning refers to where the class was instantiated
+            or the function was called.
+
+        .. versionchanged:: 1.2.15
+            Add the *extra_stacklevel* parameter.
         """
         self.reason = reason or ""
         self.version = version or ""
         self.action = action
         self.category = category
+        self.extra_stacklevel = extra_stacklevel
         super(ClassicAdapter, self).__init__()
 
     def get_deprecated_msg(self, wrapped, instance):
@@ -161,12 +171,13 @@ class ClassicAdapter(wrapt.AdapterFactory):
 
             def wrapped_cls(cls, *args, **kwargs):
                 msg = self.get_deprecated_msg(wrapped, None)
+                stacklevel = _class_stacklevel + self.extra_stacklevel
                 if self.action:
                     with warnings.catch_warnings():
                         warnings.simplefilter(self.action, self.category)
-                        warnings.warn(msg, category=self.category, stacklevel=_class_stacklevel)
+                        warnings.warn(msg, category=self.category, stacklevel=stacklevel)
                 else:
-                    warnings.warn(msg, category=self.category, stacklevel=_class_stacklevel)
+                    warnings.warn(msg, category=self.category, stacklevel=stacklevel)
                 if old_new1 is object.__new__:
                     return old_new1(cls)
                 # actually, we don't know the real signature of *old_new1*
@@ -174,6 +185,24 @@ class ClassicAdapter(wrapt.AdapterFactory):
 
             wrapped.__new__ = staticmethod(wrapped_cls)
 
+        elif inspect.isroutine(wrapped):
+            @wrapt.decorator
+            def wrapper_function(wrapped_, instance_, args_, kwargs_):
+                msg = self.get_deprecated_msg(wrapped_, instance_)
+                stacklevel = _routine_stacklevel + self.extra_stacklevel
+                if self.action:
+                    with warnings.catch_warnings():
+                        warnings.simplefilter(self.action, self.category)
+                        warnings.warn(msg, category=self.category, stacklevel=stacklevel)
+                else:
+                    warnings.warn(msg, category=self.category, stacklevel=stacklevel)
+                return wrapped_(*args_, **kwargs_)
+
+            return wrapper_function(wrapped)
+
+        else:
+            raise TypeError(repr(type(wrapped)))
+
         return wrapped
 
 
@@ -226,7 +255,7 @@ def deprecated(*args, **kwargs):
            return x + y
 
     The *category* keyword argument allow you to specify the deprecation warning class of your choice.
-    By default, :exc:`DeprecationWarning` is used but you can choose :exc:`FutureWarning`,
+    By default, :exc:`DeprecationWarning` is used, but you can choose :exc:`FutureWarning`,
     :exc:`PendingDeprecationWarning` or a custom subclass.
 
     .. code-block:: python
@@ -240,7 +269,7 @@ def deprecated(*args, **kwargs):
 
     The *action* keyword argument allow you to locally change the warning filtering.
     *action* can be one of "error", "ignore", "always", "default", "module", or "once".
-    If ``None``, empty or missing, the the global filtering mechanism is used.
+    If ``None``, empty or missing, the global filtering mechanism is used.
     See: `The Warnings Filter`_ in the Python documentation.
 
     .. code-block:: python
@@ -252,6 +281,9 @@ def deprecated(*args, **kwargs):
        def some_old_function(x, y):
            return x + y
 
+    The *extra_stacklevel* keyword argument allows you to specify additional stack levels
+    to consider instrumentation rather than user code. With the default value of 0, the
+    warning refers to where the class was instantiated or the function was called.
     """
     if args and isinstance(args[0], string_types):
         kwargs['reason'] = args[0]
@@ -261,32 +293,9 @@ def deprecated(*args, **kwargs):
         raise TypeError(repr(type(args[0])))
 
     if args:
-        action = kwargs.get('action')
-        category = kwargs.get('category', DeprecationWarning)
         adapter_cls = kwargs.pop('adapter_cls', ClassicAdapter)
         adapter = adapter_cls(**kwargs)
-
         wrapped = args[0]
-        if inspect.isclass(wrapped):
-            wrapped = adapter(wrapped)
-            return wrapped
-
-        elif inspect.isroutine(wrapped):
-
-            @wrapt.decorator(adapter=adapter)
-            def wrapper_function(wrapped_, instance_, args_, kwargs_):
-                msg = adapter.get_deprecated_msg(wrapped_, instance_)
-                if action:
-                    with warnings.catch_warnings():
-                        warnings.simplefilter(action, category)
-                        warnings.warn(msg, category=category, stacklevel=_routine_stacklevel)
-                else:
-                    warnings.warn(msg, category=category, stacklevel=_routine_stacklevel)
-                return wrapped_(*args_, **kwargs_)
-
-            return wrapper_function(wrapped)
-
-        else:
-            raise TypeError(repr(type(wrapped)))
+        return adapter(wrapped)
 
     return functools.partial(deprecated, **kwargs)
diff --git a/deprecated/sphinx.py b/deprecated/sphinx.py
index be6dce9..70ef050 100644
--- a/deprecated/sphinx.py
+++ b/deprecated/sphinx.py
@@ -22,8 +22,6 @@ when the function/method is called or the class is constructed.
 import re
 import textwrap
 
-import wrapt
-
 from deprecated.classic import ClassicAdapter
 from deprecated.classic import deprecated as _classic_deprecated
 
@@ -48,6 +46,7 @@ class SphinxAdapter(ClassicAdapter):
         version="",
         action=None,
         category=DeprecationWarning,
+        extra_stacklevel=0,
         line_length=70,
     ):
         """
@@ -67,29 +66,40 @@ class SphinxAdapter(ClassicAdapter):
             If you follow the `Semantic Versioning <https://semver.org/>`_,
             the version number has the format "MAJOR.MINOR.PATCH".
 
-        :type  action: str
+        :type  action: Literal["default", "error", "ignore", "always", "module", "once"]
         :param action:
             A warning filter used to activate or not the deprecation warning.
             Can be one of "error", "ignore", "always", "default", "module", or "once".
-            If ``None`` or empty, the the global filtering mechanism is used.
+            If ``None`` or empty, the global filtering mechanism is used.
             See: `The Warnings Filter`_ in the Python documentation.
 
-        :type  category: type
+        :type  category: Type[Warning]
         :param category:
             The warning category to use for the deprecation warning.
             By default, the category class is :class:`~DeprecationWarning`,
             you can inherit this class to define your own deprecation warning category.
 
+        :type  extra_stacklevel: int
+        :param extra_stacklevel:
+            Number of additional stack levels to consider instrumentation rather than user code.
+            With the default value of 0, the warning refers to where the class was instantiated
+            or the function was called.
+
         :type  line_length: int
         :param line_length:
             Max line length of the directive text. If non nul, a long text is wrapped in several lines.
+
+        .. versionchanged:: 1.2.15
+            Add the *extra_stacklevel* parameter.
         """
         if not version:
             # https://github.com/tantale/deprecated/issues/40
             raise ValueError("'version' argument is required in Sphinx directives")
         self.directive = directive
         self.line_length = line_length
-        super(SphinxAdapter, self).__init__(reason=reason, version=version, action=action, category=category)
+        super(SphinxAdapter, self).__init__(
+            reason=reason, version=version, action=action, category=category, extra_stacklevel=extra_stacklevel
+        )
 
     def __call__(self, wrapped):
         """
@@ -102,7 +112,7 @@ class SphinxAdapter(ClassicAdapter):
         # -- build the directive division
         fmt = ".. {directive}:: {version}" if self.version else ".. {directive}::"
         div_lines = [fmt.format(directive=self.directive, version=self.version)]
-        width = self.line_length - 3 if self.line_length > 3 else 2 ** 16
+        width = self.line_length - 3 if self.line_length > 3 else 2**16
         reason = textwrap.dedent(self.reason).strip()
         for paragraph in reason.splitlines():
             if paragraph:
@@ -153,7 +163,7 @@ class SphinxAdapter(ClassicAdapter):
 
         """
         msg = super(SphinxAdapter, self).get_deprecated_msg(wrapped, instance)
-        # Strip Sphinx cross reference syntax (like ":function:", ":py:func:" and ":py:meth:")
+        # Strip Sphinx cross-reference syntax (like ":function:", ":py:func:" and ":py:meth:")
         # Possible values are ":role:`foo`", ":domain:role:`foo`"
         # where ``role`` and ``domain`` should match "[a-zA-Z]+"
         msg = re.sub(r"(?: : [a-zA-Z]+ )? : [a-zA-Z]+ : (`[^`]*`)", r"\1", msg, flags=re.X)
@@ -163,7 +173,7 @@ class SphinxAdapter(ClassicAdapter):
 def versionadded(reason="", version="", line_length=70):
     """
     This decorator can be used to insert a "versionadded" directive
-    in your function/class docstring in order to documents the
+    in your function/class docstring in order to document the
     version of the project which adds this new functionality in your library.
 
     :param str reason:
@@ -193,7 +203,7 @@ def versionadded(reason="", version="", line_length=70):
 def versionchanged(reason="", version="", line_length=70):
     """
     This decorator can be used to insert a "versionchanged" directive
-    in your function/class docstring in order to documents the
+    in your function/class docstring in order to document the
     version of the project which modifies this functionality in your library.
 
     :param str reason:
@@ -222,7 +232,7 @@ def versionchanged(reason="", version="", line_length=70):
 def deprecated(reason="", version="", line_length=70, **kwargs):
     """
     This decorator can be used to insert a "deprecated" directive
-    in your function/class docstring in order to documents the
+    in your function/class docstring in order to document the
     version of the project which deprecates this functionality in your library.
 
     :param str reason:
@@ -242,17 +252,26 @@ def deprecated(reason="", version="", line_length=70, **kwargs):
     -   "action":
         A warning filter used to activate or not the deprecation warning.
         Can be one of "error", "ignore", "always", "default", "module", or "once".
-        If ``None``, empty or missing, the the global filtering mechanism is used.
+        If ``None``, empty or missing, the global filtering mechanism is used.
 
     -   "category":
         The warning category to use for the deprecation warning.
         By default, the category class is :class:`~DeprecationWarning`,
         you can inherit this class to define your own deprecation warning category.
 
+    -   "extra_stacklevel":
+        Number of additional stack levels to consider instrumentation rather than user code.
+        With the default value of 0, the warning refers to where the class was instantiated
+        or the function was called.
+
+
     :return: a decorator used to deprecate a function.
 
     .. versionchanged:: 1.2.13
        Change the signature of the decorator to reflect the valid use cases.
+
+    .. versionchanged:: 1.2.15
+        Add the *extra_stacklevel* parameter.
     """
     directive = kwargs.pop('directive', 'deprecated')
     adapter_cls = kwargs.pop('adapter_cls', SphinxAdapter)
diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst
index 86cf056..00fb237 100644
--- a/docs/source/tutorial.rst
+++ b/docs/source/tutorial.rst
@@ -242,3 +242,28 @@ function will raise an exception because the *action* is set to "error".
      File "path/to/deprecated/classic.py", line 274, in wrapper_function
        warnings.warn(msg, category=category, stacklevel=_stacklevel)
    DeprecationWarning: Call to deprecated function (or staticmethod) foo. (do not call it)
+
+
+Modifying the deprecated code reference
+---------------------------------------
+
+By default, when a deprecated function or class is called, the warning message indicates the location of the caller.
+
+The ``extra_stacklevel`` parameter allows customizing the stack level reference in the deprecation warning message.
+
+This parameter is particularly useful in scenarios where you have a factory or utility function that creates deprecated
+objects or performs deprecated operations. By specifying an ``extra_stacklevel`` value, you can control the stack level
+at which the deprecation warning is emitted, making it appear as if the calling function is the deprecated one,
+rather than the actual deprecated entity.
+
+For example, if you have a factory function ``create_object()`` that creates deprecated objects, you can use
+the ``extra_stacklevel`` parameter to emit the deprecation warning at the calling location. This provides clearer and
+more actionable deprecation messages, allowing developers to identify and update the code that invokes the deprecated
+functionality.
+
+For instance:
+
+.. literalinclude:: tutorial/warning_ctrl/extra_stacklevel_demo.py
+
+Please note that the ``extra_stacklevel`` value should be an integer indicating the number of stack levels to skip
+when emitting the deprecation warning.
diff --git a/docs/source/tutorial/warning_ctrl/extra_stacklevel_demo.py b/docs/source/tutorial/warning_ctrl/extra_stacklevel_demo.py
new file mode 100644
index 0000000..3c0516c
--- /dev/null
+++ b/docs/source/tutorial/warning_ctrl/extra_stacklevel_demo.py
@@ -0,0 +1,24 @@
+import warnings
+
+from deprecated import deprecated
+
+
+@deprecated(version='1.0', extra_stacklevel=1)
+class MyObject(object):
+    def __init__(self, name):
+        self.name = name
+
+    def __str__(self):
+        return "object: {name}".format(name=self.name)
+
+
+def create_object(name):
+    return MyObject(name)
+
+
+if __name__ == '__main__':
+    warnings.filterwarnings("default", category=DeprecationWarning)
+    # warn here:
+    print(create_object("orange"))
+    # and also here:
+    print(create_object("banane"))


Test patch:
diff --git a/tests/test_deprecated.py b/tests/test_deprecated.py
index e4c00ef..0e467ae 100644
--- a/tests/test_deprecated.py
+++ b/tests/test_deprecated.py
@@ -1,4 +1,5 @@
 # -*- coding: utf-8 -*-
+import inspect
 import sys
 import warnings
 
@@ -11,6 +12,10 @@ class MyDeprecationWarning(DeprecationWarning):
     pass
 
 
+class WrongStackLevelWarning(DeprecationWarning):
+    pass
+
+
 _PARAMS = [
     None,
     ((), {}),
@@ -19,6 +24,7 @@ _PARAMS = [
     ((), {'version': '1.2.3'}),
     ((), {'action': 'once'}),
     ((), {'category': MyDeprecationWarning}),
+    ((), {'extra_stacklevel': 1, 'category': WrongStackLevelWarning}),
 ]
 
 
@@ -136,7 +142,7 @@ def test_classic_deprecated_function__warns(classic_deprecated_function):
     warn = warns[0]
     assert issubclass(warn.category, DeprecationWarning)
     assert "deprecated function (or staticmethod)" in str(warn.message)
-    assert warn.filename == __file__, 'Incorrect warning stackLevel'
+    assert warn.filename == __file__ or warn.category is WrongStackLevelWarning, 'Incorrect warning stackLevel'
 
 
 # noinspection PyShadowingNames
@@ -148,7 +154,7 @@ def test_classic_deprecated_class__warns(classic_deprecated_class):
     warn = warns[0]
     assert issubclass(warn.category, DeprecationWarning)
     assert "deprecated class" in str(warn.message)
-    assert warn.filename == __file__, 'Incorrect warning stackLevel'
+    assert warn.filename == __file__ or warn.category is WrongStackLevelWarning, 'Incorrect warning stackLevel'
 
 
 # noinspection PyShadowingNames
@@ -161,7 +167,7 @@ def test_classic_deprecated_method__warns(classic_deprecated_method):
     warn = warns[0]
     assert issubclass(warn.category, DeprecationWarning)
     assert "deprecated method" in str(warn.message)
-    assert warn.filename == __file__, 'Incorrect warning stackLevel'
+    assert warn.filename == __file__ or warn.category is WrongStackLevelWarning, 'Incorrect warning stackLevel'
 
 
 # noinspection PyShadowingNames
@@ -173,7 +179,7 @@ def test_classic_deprecated_static_method__warns(classic_deprecated_static_metho
     warn = warns[0]
     assert issubclass(warn.category, DeprecationWarning)
     assert "deprecated function (or staticmethod)" in str(warn.message)
-    assert warn.filename == __file__, 'Incorrect warning stackLevel'
+    assert warn.filename == __file__ or warn.category is WrongStackLevelWarning, 'Incorrect warning stackLevel'
 
 
 # noinspection PyShadowingNames
@@ -189,7 +195,7 @@ def test_classic_deprecated_class_method__warns(classic_deprecated_class_method)
         assert "deprecated class method" in str(warn.message)
     else:
         assert "deprecated function (or staticmethod)" in str(warn.message)
-    assert warn.filename == __file__, 'Incorrect warning stackLevel'
+    assert warn.filename == __file__ or warn.category is WrongStackLevelWarning, 'Incorrect warning stackLevel'
 
 
 def test_should_raise_type_error():
@@ -258,3 +264,61 @@ def test_respect_global_filter():
         fun()
         fun()
     assert len(warns) == 1
+
+
+def test_default_stacklevel():
+    """
+    The objective of this unit test is to ensure that the triggered warning message,
+    when invoking the 'use_foo' function, correctly indicates the line where the
+    deprecated 'foo' function is called.
+    """
+
+    @deprecated.classic.deprecated
+    def foo():
+        pass
+
+    def use_foo():
+        foo()
+
+    with warnings.catch_warnings(record=True) as warns:
+        warnings.simplefilter("always")
+        use_foo()
+
+    # Check that the warning path matches the module path
+    warn = warns[0]
+    assert warn.filename == __file__
+
+    # Check that the line number points to the first line inside 'use_foo'
+    use_foo_lineno = inspect.getsourcelines(use_foo)[1]
+    assert warn.lineno == use_foo_lineno + 1
+
+
+def test_extra_stacklevel():
+    """
+    The unit test utilizes an 'extra_stacklevel' of 1 to ensure that the warning message
+    accurately identifies the caller of the deprecated function. It verifies that when
+    the 'use_foo' function is called, the warning message correctly indicates the line
+    where the call to 'use_foo' is made.
+    """
+
+    @deprecated.classic.deprecated(extra_stacklevel=1)
+    def foo():
+        pass
+
+    def use_foo():
+        foo()
+
+    def demo():
+        use_foo()
+
+    with warnings.catch_warnings(record=True) as warns:
+        warnings.simplefilter("always")
+        demo()
+
+    # Check that the warning path matches the module path
+    warn = warns[0]
+    assert warn.filename == __file__
+
+    # Check that the line number points to the first line inside 'demo'
+    demo_lineno = inspect.getsourcelines(demo)[1]
+    assert warn.lineno == demo_lineno + 1