Skip to content

Commit 052f53d

Browse files
authored
gh-39615: Add warnings.warn() skip_file_prefixes support (#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.
1 parent 8cef9c0 commit 052f53d

File tree

12 files changed

+263
-48
lines changed

12 files changed

+263
-48
lines changed

Doc/library/warnings.rst

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,7 @@ Available Functions
396396
-------------------
397397

398398

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

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

410-
def deprecation(message):
410+
def deprecated_api(message):
411411
warnings.warn(message, DeprecationWarning, stacklevel=2)
412412

413-
This makes the warning refer to :func:`deprecation`'s caller, rather than to the
414-
source of :func:`deprecation` itself (since the latter would defeat the purpose
415-
of the warning message).
413+
This makes the warning refer to ``deprecated_api``'s caller, rather than to
414+
the source of ``deprecated_api`` itself (since the latter would defeat the
415+
purpose of the warning message).
416+
417+
The *skip_file_prefixes* keyword argument can be used to indicate which
418+
stack frames are ignored when counting stack levels. This can be useful when
419+
you want the warning to always appear at call sites outside of a package
420+
when a constant *stacklevel* does not fit all call paths or is otherwise
421+
challenging to maintain. If supplied, it must be a tuple of strings. When
422+
prefixes are supplied, stacklevel is implicitly overridden to be ``max(2,
423+
stacklevel)``. To cause a warning to be attributed to the caller from
424+
outside of the current package you might write::
425+
426+
# example/lower.py
427+
_warn_skips = (os.path.dirname(__file__),)
428+
429+
def one_way(r_luxury_yacht=None, t_wobbler_mangrove=None):
430+
if r_luxury_yacht:
431+
warnings.warn("Please migrate to t_wobbler_mangrove=.",
432+
skip_file_prefixes=_warn_skips)
433+
434+
# example/higher.py
435+
from . import lower
436+
437+
def another_way(**kw):
438+
lower.one_way(**kw)
439+
440+
This makes the warning refer to both the ``example.lower.one_way()`` and
441+
``package.higher.another_way()`` call sites only from calling code living
442+
outside of ``example`` package.
416443

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

420447
.. versionchanged:: 3.6
421448
Added *source* parameter.
422449

450+
.. versionchanged:: 3.12
451+
Added *skip_file_prefixes*.
452+
423453

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

Include/internal/pycore_global_objects_fini_generated.h

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Include/internal/pycore_global_strings.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,7 @@ struct _Py_global_strings {
637637
STRUCT_FOR_ID(signed)
638638
STRUCT_FOR_ID(size)
639639
STRUCT_FOR_ID(sizehint)
640+
STRUCT_FOR_ID(skip_file_prefixes)
640641
STRUCT_FOR_ID(sleep)
641642
STRUCT_FOR_ID(sock)
642643
STRUCT_FOR_ID(sort)

Include/internal/pycore_runtime_init_generated.h

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Include/internal/pycore_unicodeobject_generated.h

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Lib/test/test_warnings/__init__.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from test.support import warnings_helper
1313
from test.support.script_helper import assert_python_ok, assert_python_failure
1414

15+
from test.test_warnings.data import package_helper
1516
from test.test_warnings.data import stacklevel as warning_tests
1617

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

476+
def test_skip_file_prefixes(self):
477+
with warnings_state(self.module):
478+
with original_warnings.catch_warnings(record=True,
479+
module=self.module) as w:
480+
self.module.simplefilter('always')
481+
482+
# Warning never attributed to the data/ package.
483+
package_helper.inner_api(
484+
"inner_api", stacklevel=2,
485+
warnings_module=warning_tests.warnings)
486+
self.assertEqual(w[-1].filename, __file__)
487+
warning_tests.package("package api", stacklevel=2)
488+
self.assertEqual(w[-1].filename, __file__)
489+
self.assertEqual(w[-2].filename, w[-1].filename)
490+
# Low stacklevels are overridden to 2 behavior.
491+
warning_tests.package("package api 1", stacklevel=1)
492+
self.assertEqual(w[-1].filename, __file__)
493+
warning_tests.package("package api 0", stacklevel=0)
494+
self.assertEqual(w[-1].filename, __file__)
495+
warning_tests.package("package api -99", stacklevel=-99)
496+
self.assertEqual(w[-1].filename, __file__)
497+
498+
# The stacklevel still goes up out of the package.
499+
warning_tests.package("prefix02", stacklevel=3)
500+
self.assertIn("unittest", w[-1].filename)
501+
502+
def test_skip_file_prefixes_type_errors(self):
503+
with warnings_state(self.module):
504+
warn = warning_tests.warnings.warn
505+
with self.assertRaises(TypeError):
506+
warn("msg", skip_file_prefixes=[])
507+
with self.assertRaises(TypeError):
508+
warn("msg", skip_file_prefixes=(b"bytes",))
509+
with self.assertRaises(TypeError):
510+
warn("msg", skip_file_prefixes="a sequence of strs")
511+
475512
def test_exec_filename(self):
476513
filename = "<warnings-test>"
477514
codeobj = compile(("import warnings\n"
@@ -895,7 +932,7 @@ def test_formatwarning(self):
895932
message = "msg"
896933
category = Warning
897934
file_name = os.path.splitext(warning_tests.__file__)[0] + '.py'
898-
line_num = 3
935+
line_num = 5
899936
file_line = linecache.getline(file_name, line_num).strip()
900937
format = "%s:%s: %s: %s\n %s\n"
901938
expect = format % (file_name, line_num, category.__name__, message,
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# helper to the helper for testing skip_file_prefixes.
2+
3+
import os
4+
5+
package_path = os.path.dirname(__file__)
6+
7+
def inner_api(message, *, stacklevel, warnings_module):
8+
warnings_module.warn(
9+
message, stacklevel=stacklevel,
10+
skip_file_prefixes=(package_path,))
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
# Helper module for testing the skipmodules argument of warnings.warn()
1+
# Helper module for testing stacklevel and skip_file_prefixes arguments
2+
# of warnings.warn()
23

34
import warnings
5+
from test.test_warnings.data import package_helper
46

57
def outer(message, stacklevel=1):
68
inner(message, stacklevel)
79

810
def inner(message, stacklevel=1):
911
warnings.warn(message, stacklevel=stacklevel)
12+
13+
def package(message, *, stacklevel):
14+
package_helper.inner_api(message, stacklevel=stacklevel,
15+
warnings_module=warnings)

Lib/warnings.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -269,22 +269,32 @@ def _getcategory(category):
269269
return cat
270270

271271

272+
def _is_internal_filename(filename):
273+
return 'importlib' in filename and '_bootstrap' in filename
274+
275+
276+
def _is_filename_to_skip(filename, skip_file_prefixes):
277+
return any(filename.startswith(prefix) for prefix in skip_file_prefixes)
278+
279+
272280
def _is_internal_frame(frame):
273281
"""Signal whether the frame is an internal CPython implementation detail."""
274-
filename = frame.f_code.co_filename
275-
return 'importlib' in filename and '_bootstrap' in filename
282+
return _is_internal_filename(frame.f_code.co_filename)
276283

277284

278-
def _next_external_frame(frame):
279-
"""Find the next frame that doesn't involve CPython internals."""
285+
def _next_external_frame(frame, skip_file_prefixes):
286+
"""Find the next frame that doesn't involve Python or user internals."""
280287
frame = frame.f_back
281-
while frame is not None and _is_internal_frame(frame):
288+
while frame is not None and (
289+
_is_internal_filename(filename := frame.f_code.co_filename) or
290+
_is_filename_to_skip(filename, skip_file_prefixes)):
282291
frame = frame.f_back
283292
return frame
284293

285294

286295
# Code typically replaced by _warnings
287-
def warn(message, category=None, stacklevel=1, source=None):
296+
def warn(message, category=None, stacklevel=1, source=None,
297+
*, skip_file_prefixes=()):
288298
"""Issue a warning, or maybe ignore it or raise an exception."""
289299
# Check if message is already a Warning object
290300
if isinstance(message, Warning):
@@ -295,6 +305,11 @@ def warn(message, category=None, stacklevel=1, source=None):
295305
if not (isinstance(category, type) and issubclass(category, Warning)):
296306
raise TypeError("category must be a Warning subclass, "
297307
"not '{:s}'".format(type(category).__name__))
308+
if not isinstance(skip_file_prefixes, tuple):
309+
# The C version demands a tuple for implementation performance.
310+
raise TypeError('skip_file_prefixes must be a tuple of strs.')
311+
if skip_file_prefixes:
312+
stacklevel = max(2, stacklevel)
298313
# Get context information
299314
try:
300315
if stacklevel <= 1 or _is_internal_frame(sys._getframe(1)):
@@ -305,7 +320,7 @@ def warn(message, category=None, stacklevel=1, source=None):
305320
frame = sys._getframe(1)
306321
# Look for one frame less since the above line starts us off.
307322
for x in range(stacklevel-1):
308-
frame = _next_external_frame(frame)
323+
frame = _next_external_frame(frame, skip_file_prefixes)
309324
if frame is None:
310325
raise ValueError
311326
except ValueError:
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:func:`warnings.warn` now has the ability to skip stack frames based on code
2+
filename prefix rather than only a numeric ``stacklevel`` via the new
3+
``skip_file_prefixes`` keyword argument.

0 commit comments

Comments
 (0)