Skip to content

Commit

Permalink
pythongh-39615: Add warnings.warn() skip_file_prefixes support (pytho…
Browse files Browse the repository at this point in the history
…n#100840)

`warnings.warn()` gains the ability to skip stack frames based on code
filename prefix rather than only a numeric `stacklevel=` via a new
`skip_file_prefixes=` keyword argument.
  • Loading branch information
gpshead authored and mdboom committed Jan 31, 2023
1 parent cada1de commit 1939cb0
Show file tree
Hide file tree
Showing 12 changed files with 263 additions and 48 deletions.
40 changes: 35 additions & 5 deletions Doc/library/warnings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ Available Functions
-------------------


.. function:: warn(message, category=None, stacklevel=1, source=None)
.. function:: warn(message, category=None, stacklevel=1, source=None, \*, skip_file_prefixes=None)

Issue a warning, or maybe ignore it or raise an exception. The *category*
argument, if given, must be a :ref:`warning category class <warning-categories>`; it
Expand All @@ -407,19 +407,49 @@ Available Functions
:ref:`warnings filter <warning-filter>`. The *stacklevel* argument can be used by wrapper
functions written in Python, like this::

def deprecation(message):
def deprecated_api(message):
warnings.warn(message, DeprecationWarning, stacklevel=2)

This makes the warning refer to :func:`deprecation`'s caller, rather than to the
source of :func:`deprecation` itself (since the latter would defeat the purpose
of the warning message).
This makes the warning refer to ``deprecated_api``'s caller, rather than to
the source of ``deprecated_api`` itself (since the latter would defeat the
purpose of the warning message).

The *skip_file_prefixes* keyword argument can be used to indicate which
stack frames are ignored when counting stack levels. This can be useful when
you want the warning to always appear at call sites outside of a package
when a constant *stacklevel* does not fit all call paths or is otherwise
challenging to maintain. If supplied, it must be a tuple of strings. When
prefixes are supplied, stacklevel is implicitly overridden to be ``max(2,
stacklevel)``. To cause a warning to be attributed to the caller from
outside of the current package you might write::

# example/lower.py
_warn_skips = (os.path.dirname(__file__),)

def one_way(r_luxury_yacht=None, t_wobbler_mangrove=None):
if r_luxury_yacht:
warnings.warn("Please migrate to t_wobbler_mangrove=.",
skip_file_prefixes=_warn_skips)

# example/higher.py
from . import lower

def another_way(**kw):
lower.one_way(**kw)

This makes the warning refer to both the ``example.lower.one_way()`` and
``package.higher.another_way()`` call sites only from calling code living
outside of ``example`` package.

*source*, if supplied, is the destroyed object which emitted a
:exc:`ResourceWarning`.

.. versionchanged:: 3.6
Added *source* parameter.

.. versionchanged:: 3.12
Added *skip_file_prefixes*.


.. function:: warn_explicit(message, category, filename, lineno, module=None, registry=None, module_globals=None, source=None)

Expand Down
1 change: 1 addition & 0 deletions Include/internal/pycore_global_objects_fini_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Include/internal/pycore_global_strings.h
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@ struct _Py_global_strings {
STRUCT_FOR_ID(signed)
STRUCT_FOR_ID(size)
STRUCT_FOR_ID(sizehint)
STRUCT_FOR_ID(skip_file_prefixes)
STRUCT_FOR_ID(sleep)
STRUCT_FOR_ID(sock)
STRUCT_FOR_ID(sort)
Expand Down
1 change: 1 addition & 0 deletions Include/internal/pycore_runtime_init_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Include/internal/pycore_unicodeobject_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 38 additions & 1 deletion Lib/test/test_warnings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from test.support import warnings_helper
from test.support.script_helper import assert_python_ok, assert_python_failure

from test.test_warnings.data import package_helper
from test.test_warnings.data import stacklevel as warning_tests

import warnings as original_warnings
Expand Down Expand Up @@ -472,6 +473,42 @@ def test_stacklevel_import(self):
self.assertEqual(len(w), 1)
self.assertEqual(w[0].filename, __file__)

def test_skip_file_prefixes(self):
with warnings_state(self.module):
with original_warnings.catch_warnings(record=True,
module=self.module) as w:
self.module.simplefilter('always')

# Warning never attributed to the data/ package.
package_helper.inner_api(
"inner_api", stacklevel=2,
warnings_module=warning_tests.warnings)
self.assertEqual(w[-1].filename, __file__)
warning_tests.package("package api", stacklevel=2)
self.assertEqual(w[-1].filename, __file__)
self.assertEqual(w[-2].filename, w[-1].filename)
# Low stacklevels are overridden to 2 behavior.
warning_tests.package("package api 1", stacklevel=1)
self.assertEqual(w[-1].filename, __file__)
warning_tests.package("package api 0", stacklevel=0)
self.assertEqual(w[-1].filename, __file__)
warning_tests.package("package api -99", stacklevel=-99)
self.assertEqual(w[-1].filename, __file__)

# The stacklevel still goes up out of the package.
warning_tests.package("prefix02", stacklevel=3)
self.assertIn("unittest", w[-1].filename)

def test_skip_file_prefixes_type_errors(self):
with warnings_state(self.module):
warn = warning_tests.warnings.warn
with self.assertRaises(TypeError):
warn("msg", skip_file_prefixes=[])
with self.assertRaises(TypeError):
warn("msg", skip_file_prefixes=(b"bytes",))
with self.assertRaises(TypeError):
warn("msg", skip_file_prefixes="a sequence of strs")

def test_exec_filename(self):
filename = "<warnings-test>"
codeobj = compile(("import warnings\n"
Expand Down Expand Up @@ -895,7 +932,7 @@ def test_formatwarning(self):
message = "msg"
category = Warning
file_name = os.path.splitext(warning_tests.__file__)[0] + '.py'
line_num = 3
line_num = 5
file_line = linecache.getline(file_name, line_num).strip()
format = "%s:%s: %s: %s\n %s\n"
expect = format % (file_name, line_num, category.__name__, message,
Expand Down
10 changes: 10 additions & 0 deletions Lib/test/test_warnings/data/package_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# helper to the helper for testing skip_file_prefixes.

import os

package_path = os.path.dirname(__file__)

def inner_api(message, *, stacklevel, warnings_module):
warnings_module.warn(
message, stacklevel=stacklevel,
skip_file_prefixes=(package_path,))
8 changes: 7 additions & 1 deletion Lib/test/test_warnings/data/stacklevel.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
# Helper module for testing the skipmodules argument of warnings.warn()
# Helper module for testing stacklevel and skip_file_prefixes arguments
# of warnings.warn()

import warnings
from test.test_warnings.data import package_helper

def outer(message, stacklevel=1):
inner(message, stacklevel)

def inner(message, stacklevel=1):
warnings.warn(message, stacklevel=stacklevel)

def package(message, *, stacklevel):
package_helper.inner_api(message, stacklevel=stacklevel,
warnings_module=warnings)
29 changes: 22 additions & 7 deletions Lib/warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,22 +269,32 @@ def _getcategory(category):
return cat


def _is_internal_filename(filename):
return 'importlib' in filename and '_bootstrap' in filename


def _is_filename_to_skip(filename, skip_file_prefixes):
return any(filename.startswith(prefix) for prefix in skip_file_prefixes)


def _is_internal_frame(frame):
"""Signal whether the frame is an internal CPython implementation detail."""
filename = frame.f_code.co_filename
return 'importlib' in filename and '_bootstrap' in filename
return _is_internal_filename(frame.f_code.co_filename)


def _next_external_frame(frame):
"""Find the next frame that doesn't involve CPython internals."""
def _next_external_frame(frame, skip_file_prefixes):
"""Find the next frame that doesn't involve Python or user internals."""
frame = frame.f_back
while frame is not None and _is_internal_frame(frame):
while frame is not None and (
_is_internal_filename(filename := frame.f_code.co_filename) or
_is_filename_to_skip(filename, skip_file_prefixes)):
frame = frame.f_back
return frame


# Code typically replaced by _warnings
def warn(message, category=None, stacklevel=1, source=None):
def warn(message, category=None, stacklevel=1, source=None,
*, skip_file_prefixes=()):
"""Issue a warning, or maybe ignore it or raise an exception."""
# Check if message is already a Warning object
if isinstance(message, Warning):
Expand All @@ -295,6 +305,11 @@ def warn(message, category=None, stacklevel=1, source=None):
if not (isinstance(category, type) and issubclass(category, Warning)):
raise TypeError("category must be a Warning subclass, "
"not '{:s}'".format(type(category).__name__))
if not isinstance(skip_file_prefixes, tuple):
# The C version demands a tuple for implementation performance.
raise TypeError('skip_file_prefixes must be a tuple of strs.')
if skip_file_prefixes:
stacklevel = max(2, stacklevel)
# Get context information
try:
if stacklevel <= 1 or _is_internal_frame(sys._getframe(1)):
Expand All @@ -305,7 +320,7 @@ def warn(message, category=None, stacklevel=1, source=None):
frame = sys._getframe(1)
# Look for one frame less since the above line starts us off.
for x in range(stacklevel-1):
frame = _next_external_frame(frame)
frame = _next_external_frame(frame, skip_file_prefixes)
if frame is None:
raise ValueError
except ValueError:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:func:`warnings.warn` now has the ability to skip stack frames based on code
filename prefix rather than only a numeric ``stacklevel`` via the new
``skip_file_prefixes`` keyword argument.
Loading

0 comments on commit 1939cb0

Please sign in to comment.