Skip to content

gh-39615: Add warnings.warn() skip_file_prefixes support #100840

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jan 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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