Skip to content

Commit e3d8fe5

Browse files
authored
Merge pull request #3569 from jasongrout/stacklevel
Calculate correct stack level for deprecation warnings to show them to the user
2 parents 3148ca3 + ca423b2 commit e3d8fe5

File tree

10 files changed

+239
-26
lines changed

10 files changed

+239
-26
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Copyright (c) Jupyter Development Team.
2+
# Distributed under the terms of the Modified BSD License.
3+
4+
import inspect
5+
import pytest
6+
7+
from ..utils import deprecation
8+
from .utils import call_method
9+
10+
CALL_PATH = inspect.getfile(call_method)
11+
12+
def test_deprecation():
13+
caller_path = inspect.stack(context=0)[1].filename
14+
with pytest.deprecated_call() as record:
15+
deprecation('Deprecated call')
16+
# Make sure the deprecation pointed to the external function calling this test function
17+
assert len(record) == 1
18+
assert record[0].filename == caller_path
19+
20+
with pytest.deprecated_call() as record:
21+
deprecation('Deprecated call', ['ipywidgets/widgets/tests'])
22+
# Make sure the deprecation pointed to the external function calling this test function
23+
assert len(record) == 1
24+
assert record[0].filename == caller_path
25+
26+
with pytest.deprecated_call() as record:
27+
deprecation('Deprecated call', 'ipywidgets/widgets/tests')
28+
# Make sure the deprecation pointed to the external function calling this test function
29+
assert len(record) == 1
30+
assert record[0].filename == caller_path
31+
32+
with pytest.deprecated_call() as record:
33+
deprecation('Deprecated call', [])
34+
# Make sure the deprecation pointed to *this* file
35+
assert len(record) == 1
36+
assert record[0].filename == __file__
37+
38+
def test_deprecation_indirect():
39+
# If the line that calls "deprecation" is not internal, it is considered the source:
40+
with pytest.warns(DeprecationWarning) as record:
41+
call_method(deprecation, "test message", [])
42+
assert len(record) == 1
43+
assert record[0].filename == CALL_PATH
44+
45+
def test_deprecation_indirect_internal():
46+
# If the line that calls "deprecation" is internal, it is not considered the source:
47+
with pytest.warns(DeprecationWarning) as record:
48+
call_method(deprecation, "test message", [CALL_PATH])
49+
assert len(record) == 1
50+
assert record[0].filename == __file__
51+
52+
def test_deprecation_nested1():
53+
def level1():
54+
deprecation("test message", [])
55+
56+
with pytest.warns(DeprecationWarning) as record:
57+
call_method(level1)
58+
59+
assert len(record) == 1
60+
assert record[0].filename == __file__
61+
62+
def test_deprecation_nested2():
63+
def level2():
64+
deprecation("test message", [])
65+
def level1():
66+
level2()
67+
68+
with pytest.warns(DeprecationWarning) as record:
69+
call_method(level1)
70+
71+
assert len(record) == 1
72+
assert record[0].filename == __file__

python/ipywidgets/ipywidgets/widgets/tests/test_widget.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
from IPython.core.interactiveshell import InteractiveShell
77
from IPython.display import display
88
from IPython.utils.capture import capture_output
9+
import inspect
10+
import pytest
911

1012
from .. import widget
1113
from ..widget import Widget
1214
from ..widget_button import Button
1315

14-
1516
def test_no_widget_view():
1617
# ensure IPython shell is instantiated
1718
# otherwise display() just calls print
@@ -51,7 +52,7 @@ def test_close_all():
5152
widgets = [Button() for i in range(10)]
5253

5354
assert len(widget._instances) > 0, "expect active widgets"
54-
55+
assert widget._instances[widgets[0].model_id] is widgets[0]
5556
# close all the widgets
5657
Widget.close_all()
5758

@@ -60,12 +61,16 @@ def test_close_all():
6061

6162
def test_compatibility():
6263
button = Button()
63-
assert button in widget.Widget.widgets.values()
64-
assert widget._instances is widget.Widget.widgets
65-
assert widget._instances is widget.Widget._active_widgets
66-
Widget.close_all()
67-
assert not widget.Widget.widgets
68-
assert not widget.Widget._active_widgets
69-
70-
assert widget.Widget.widget_types is widget._registry
71-
assert widget.Widget._widget_types is widget._registry
64+
assert widget._instances[button.model_id] is button
65+
with pytest.deprecated_call() as record:
66+
assert widget._instances is widget.Widget.widgets
67+
assert widget._instances is widget.Widget._active_widgets
68+
assert widget._registry is widget.Widget.widget_types
69+
assert widget._registry is widget.Widget._widget_types
70+
71+
Widget.close_all()
72+
assert not widget.Widget.widgets
73+
assert not widget.Widget._active_widgets
74+
caller_path = inspect.stack(context=0)[1].filename
75+
assert all(x.filename == caller_path for x in record)
76+
assert len(record) == 6
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Copyright (c) Jupyter Development Team.
2+
# Distributed under the terms of the Modified BSD License.
3+
4+
import inspect
5+
import pytest
6+
from ipywidgets import Button
7+
8+
def test_deprecation_fa_icons():
9+
with pytest.deprecated_call() as record:
10+
Button(icon='fa-home')
11+
assert len(record) == 1
12+
assert record[0].filename == inspect.stack(context=0)[1].filename

python/ipywidgets/ipywidgets/widgets/tests/test_widget_string.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# Copyright (c) Jupyter Development Team.
22
# Distributed under the terms of the Modified BSD License.
33

4-
from ..widget_string import Combobox
4+
import inspect
5+
import pytest
56

7+
from ..widget_string import Combobox, Text
68

79
def test_combobox_creation_blank():
810
w = Combobox()
@@ -32,3 +34,32 @@ def test_combobox_creation_kwargs():
3234
"Vanilla",
3335
)
3436
assert w.ensure_option == True
37+
38+
def test_tooltip_deprecation():
39+
caller_path = inspect.stack(context=0)[1].filename
40+
with pytest.deprecated_call() as record:
41+
w = Text(description_tooltip="testing")
42+
assert len(record) == 1
43+
assert record[0].filename == caller_path
44+
45+
with pytest.deprecated_call() as record:
46+
w.description_tooltip
47+
assert len(record) == 1
48+
assert record[0].filename == caller_path
49+
50+
with pytest.deprecated_call() as record:
51+
w.description_tooltip == "testing"
52+
assert len(record) == 1
53+
assert record[0].filename == caller_path
54+
55+
with pytest.deprecated_call() as record:
56+
w.description_tooltip = "second value"
57+
assert len(record) == 1
58+
assert record[0].filename == caller_path
59+
assert w.tooltip == "second value"
60+
61+
def test_on_submit_deprecation():
62+
with pytest.deprecated_call() as record:
63+
Text().on_submit(lambda *args: ...)
64+
assert len(record) == 1
65+
assert record[0].filename == inspect.stack(context=0)[1].filename

python/ipywidgets/ipywidgets/widgets/tests/utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,6 @@ def setup():
4848

4949
def teardown():
5050
teardown_test_comm()
51+
52+
def call_method(method, *args, **kwargs):
53+
method(*args, **kwargs)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Copyright (c) Jupyter Development Team.
2+
# Distributed under the terms of the Modified BSD License.
3+
4+
from pathlib import Path
5+
import sys
6+
import inspect
7+
import warnings
8+
9+
def _get_frame(level):
10+
"""Get the frame at the given stack level."""
11+
# sys._getframe is much faster than inspect.stack, but isn't guaranteed to
12+
# exist in all python implementations, so we fall back to inspect.stack()
13+
14+
# We need to add one to level to account for this get_frame call.
15+
if hasattr(sys, '_getframe'):
16+
frame = sys._getframe(level+1)
17+
else:
18+
frame = inspect.stack(context=0)[level+1].frame
19+
return frame
20+
21+
22+
# This function is from https://github.com/python/cpython/issues/67998
23+
# (https://bugs.python.org/file39550/deprecated_module_stacklevel.diff) and
24+
# calculates the appropriate stacklevel for deprecations to target the
25+
# deprecation for the caller, no matter how many internal stack frames we have
26+
# added in the process. For example, with the deprecation warning in the
27+
# __init__ below, the appropriate stacklevel will change depending on how deep
28+
# the inheritance hierarchy is.
29+
def _external_stacklevel(internal):
30+
"""Find the stacklevel of the first frame that doesn't contain any of the given internal strings
31+
32+
The depth will be 1 at minimum in order to start checking at the caller of
33+
the function that called this utility method.
34+
"""
35+
# Get the level of my caller's caller
36+
level = 2
37+
frame = _get_frame(level)
38+
39+
# Normalize the path separators:
40+
normalized_internal = [str(Path(s)) for s in internal]
41+
42+
# climb the stack frames while we see internal frames
43+
while frame and any(s in str(Path(frame.f_code.co_filename)) for s in normalized_internal):
44+
level +=1
45+
frame = frame.f_back
46+
47+
# Return the stack level from the perspective of whoever called us (i.e., one level up)
48+
return level-1
49+
50+
def deprecation(message, internal='ipywidgets/widgets/'):
51+
"""Generate a deprecation warning targeting the first frame that is not 'internal'
52+
53+
internal is a string or list of strings, which if they appear in filenames in the
54+
frames, the frames will be considered internal. Changing this can be useful if, for examnple,
55+
we know that ipywidgets is calling out to traitlets internally.
56+
"""
57+
if isinstance(internal, str):
58+
internal = [internal]
59+
60+
# stack level of the first external frame from here
61+
stacklevel = _external_stacklevel(internal)
62+
63+
# The call to .warn adds one frame, so bump the stacklevel up by one
64+
warnings.warn(message, DeprecationWarning, stacklevel=stacklevel+1)

python/ipywidgets/ipywidgets/widgets/widget.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import typing
1010
from contextlib import contextmanager
1111
from collections.abc import Iterable
12-
import warnings
1312
from IPython import get_ipython
1413
from ipykernel.comm import Comm
1514
from traitlets import (
@@ -19,8 +18,13 @@
1918

2019
from base64 import standard_b64encode
2120

21+
from .utils import deprecation, _get_frame
22+
2223
from .._version import __protocol_version__, __control_protocol_version__, __jupyter_widgets_base_version__
2324

25+
import inspect
26+
TRAITLETS_FILE = inspect.getfile(HasTraits)
27+
2428
# Based on jupyter_core.paths.envset
2529
def envset(name, default):
2630
"""Return True if the given environment variable is turned on, otherwise False
@@ -302,22 +306,42 @@ class Widget(LoggingHasTraits):
302306

303307
@_staticproperty
304308
def widgets():
305-
warnings.warn("Widget.widgets is deprecated.", DeprecationWarning)
309+
# Because this is a static attribute, it will be accessed when initializing this class. In that case, since a user
310+
# did not explicitly try to use this attribute, we do not want to throw a deprecation warning.
311+
# So we check if the thing calling this static property is one of the known initialization functions in traitlets.
312+
frame = _get_frame(2)
313+
if not (frame.f_code.co_filename == TRAITLETS_FILE and (frame.f_code.co_name in ('getmembers', 'setup_instance'))):
314+
deprecation("Widget.widgets is deprecated.")
306315
return _instances
307316

308317
@_staticproperty
309318
def _active_widgets():
310-
warnings.warn("Widget._active_widgets is deprecated.", DeprecationWarning)
319+
# Because this is a static attribute, it will be accessed when initializing this class. In that case, since a user
320+
# did not explicitly try to use this attribute, we do not want to throw a deprecation warning.
321+
# So we check if the thing calling this static property is one of the known initialization functions in traitlets.
322+
frame = _get_frame(2)
323+
if not (frame.f_code.co_filename == TRAITLETS_FILE and (frame.f_code.co_name in ('getmembers', 'setup_instance'))):
324+
deprecation("Widget._active_widgets is deprecated.")
311325
return _instances
312326

313327
@_staticproperty
314328
def _widget_types():
315-
warnings.warn("Widget._widget_types is deprecated.", DeprecationWarning)
329+
# Because this is a static attribute, it will be accessed when initializing this class. In that case, since a user
330+
# did not explicitly try to use this attribute, we do not want to throw a deprecation warning.
331+
# So we check if the thing calling this static property is one of the known initialization functions in traitlets.
332+
frame = _get_frame(2)
333+
if not (frame.f_code.co_filename == TRAITLETS_FILE and (frame.f_code.co_name in ('getmembers', 'setup_instance'))):
334+
deprecation("Widget._widget_types is deprecated.")
316335
return _registry
317336

318337
@_staticproperty
319338
def widget_types():
320-
warnings.warn("Widget.widget_types is deprecated.", DeprecationWarning)
339+
# Because this is a static attribute, it will be accessed when initializing this class. In that case, since a user
340+
# did not explicitly try to use this attribute, we do not want to throw a deprecation warning.
341+
# So we check if the thing calling this static property is one of the known initialization functions in traitlets.
342+
frame = _get_frame(2)
343+
if not (frame.f_code.co_filename == TRAITLETS_FILE and (frame.f_code.co_name in ('getmembers', 'setup_instance'))):
344+
deprecation("Widget.widget_types is deprecated.")
321345
return _registry
322346

323347
@classmethod

python/ipywidgets/ipywidgets/widgets/widget_button.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
click events on the button and trigger backend code when the clicks are fired.
88
"""
99

10+
from .utils import deprecation
1011
from .domwidget import DOMWidget
1112
from .widget import CallbackDispatcher, register, widget_serialization
1213
from .widget_core import CoreWidget
1314
from .widget_style import Style
1415
from .trait_types import Color, InstanceDict
1516

1617
from traitlets import Unicode, Bool, CaselessStrEnum, Instance, validate, default
17-
import warnings
1818

1919

2020
@register
@@ -70,8 +70,9 @@ def _validate_icon(self, proposal):
7070
"""Strip 'fa-' if necessary'"""
7171
value = proposal['value']
7272
if 'fa-' in value:
73-
warnings.warn("icons names no longer need 'fa-', "
74-
"just use the class names themselves (for example, 'gear spin' instead of 'fa-gear fa-spin')", DeprecationWarning)
73+
deprecation("icons names no longer need 'fa-', "
74+
"just use the class names themselves (for example, 'gear spin' instead of 'fa-gear fa-spin')",
75+
internal=['ipywidgets/widgets/', 'traitlets/traitlets.py', '/contextlib.py'])
7576
value = value.replace('fa-', '')
7677
return value
7778

python/ipywidgets/ipywidgets/widgets/widget_description.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from .widget_style import Style
1010
from .widget_core import CoreWidget
1111
from .domwidget import DOMWidget
12+
from .utils import deprecation
13+
1214
import warnings
1315

1416
@register
@@ -27,7 +29,7 @@ class DescriptionWidget(DOMWidget, CoreWidget):
2729

2830
def __init__(self, *args, **kwargs):
2931
if 'description_tooltip' in kwargs:
30-
warnings.warn("the description_tooltip argument is deprecated, use tooltip instead", DeprecationWarning)
32+
deprecation("the description_tooltip argument is deprecated, use tooltip instead")
3133
kwargs.setdefault('tooltip', kwargs['description_tooltip'])
3234
del kwargs['description_tooltip']
3335
super().__init__(*args, **kwargs)
@@ -47,10 +49,10 @@ def description_tooltip(self):
4749
.. deprecated :: 8.0.0
4850
Use tooltip attribute instead.
4951
"""
50-
warnings.warn(".description_tooltip is deprecated, use .tooltip instead", DeprecationWarning)
52+
deprecation(".description_tooltip is deprecated, use .tooltip instead")
5153
return self.tooltip
5254

5355
@description_tooltip.setter
5456
def description_tooltip(self, tooltip):
55-
warnings.warn(".description_tooltip is deprecated, use .tooltip instead", DeprecationWarning)
57+
deprecation(".description_tooltip is deprecated, use .tooltip instead")
5658
self.tooltip = tooltip

0 commit comments

Comments
 (0)