gh-135801: Improve filtering by module in warn_explicit() without module argument (GH-140151)

* Try to match the module name pattern with module names constructed
  starting from different parent directories of the filename.
  E.g., for "/path/to/package/module" try to match with
  "path.to.package.module", "to.package.module", "package.module" and
  "module".
* Ignore trailing "/__init__.py".
* Ignore trailing ".pyw" on Windows.
* Keep matching with the full filename (without optional ".py" extension)
  for compatibility.
* Only ignore the case of the ".py" extension on Windows.
This commit is contained in:
Serhiy Storchaka
2025-10-30 15:55:39 +02:00
committed by GitHub
parent efc37ba49e
commit 6826166280
13 changed files with 243 additions and 73 deletions

View File

@@ -487,7 +487,14 @@ Available Functions
ignored.
*module*, if supplied, should be the module name.
If no module is passed, the filename with ``.py`` stripped is used.
If no module is passed, the module regular expression in
:ref:`warnings filter <warning-filter>` will be tested against the module
names constructed from the path components starting from all parent
directories (with ``/__init__.py``, ``.py`` and, on Windows, ``.pyw``
stripped) and against the filename with ``.py`` stripped.
For example, when the filename is ``'/path/to/package/module.py'``, it will
be tested against ``'path.to.package.module'``, ``'to.package.module'``
``'package.module'``, ``'module'``, and ``'/path/to/package/module'``.
*registry*, if supplied, should be the ``__warningregistry__`` dictionary
of the module.
@@ -506,6 +513,10 @@ Available Functions
.. versionchanged:: 3.6
Add the *source* parameter.
.. versionchanged:: next
If no module is passed, test the filter regular expression against
module names created from the path, not only the path itself.
.. function:: showwarning(message, category, filename, lineno, file=None, line=None)

View File

@@ -611,6 +611,18 @@ unittest
(Contributed by Garry Cairns in :gh:`134567`.)
warnings
--------
* Improve filtering by module in :func:`warnings.warn_explicit` if no *module*
argument is passed.
It now tests the module regular expression in the warnings filter not only
against the filename with ``.py`` stripped, but also against module names
constructed starting from different parent directories of the filename
(with ``/__init__.py``, ``.py`` and, on Windows, ``.pyw`` stripped).
(Contributed by Serhiy Storchaka in :gh:`135801`.)
venv
----

View File

@@ -520,20 +520,50 @@ def warn(message, category=None, stacklevel=1, source=None,
)
def _match_filename(pattern, filename, *, MS_WINDOWS=(sys.platform == 'win32')):
if not filename:
return pattern.match('<unknown>') is not None
if filename[0] == '<' and filename[-1] == '>':
return pattern.match(filename) is not None
is_py = (filename[-3:].lower() == '.py'
if MS_WINDOWS else
filename.endswith('.py'))
if is_py:
filename = filename[:-3]
if pattern.match(filename): # for backward compatibility
return True
if MS_WINDOWS:
if not is_py and filename[-4:].lower() == '.pyw':
filename = filename[:-4]
is_py = True
if is_py and filename[-9:].lower() in (r'\__init__', '/__init__'):
filename = filename[:-9]
filename = filename.replace('\\', '/')
else:
if is_py and filename.endswith('/__init__'):
filename = filename[:-9]
filename = filename.replace('/', '.')
i = 0
while True:
if pattern.match(filename, i):
return True
i = filename.find('.', i) + 1
if not i:
return False
def warn_explicit(message, category, filename, lineno,
module=None, registry=None, module_globals=None,
source=None):
lineno = int(lineno)
if module is None:
module = filename or "<unknown>"
if module[-3:].lower() == ".py":
module = module[:-3] # XXX What about leading pathname?
if isinstance(message, Warning):
text = str(message)
category = message.__class__
else:
text = message
message = category(message)
modules = None
key = (text, category, lineno)
with _wm._lock:
if registry is None:
@@ -549,9 +579,11 @@ def warn_explicit(message, category, filename, lineno,
action, msg, cat, mod, ln = item
if ((msg is None or msg.match(text)) and
issubclass(category, cat) and
(mod is None or mod.match(module)) and
(ln == 0 or lineno == ln)):
break
(ln == 0 or lineno == ln) and
(mod is None or (_match_filename(mod, filename)
if module is None else
mod.match(module)))):
break
else:
action = _wm.defaultaction
# Early exit actions

View File

@@ -13,6 +13,7 @@ import tempfile
import textwrap
import types
import unittest
import warnings
import weakref
from io import StringIO
from pathlib import Path
@@ -1069,6 +1070,19 @@ class AST_Tests(unittest.TestCase):
self.assertIsInstance(tree.body[0].value.values[0], ast.Constant)
self.assertIsInstance(tree.body[0].value.values[1], ast.Interpolation)
def test_filter_syntax_warnings_by_module(self):
filename = support.findfile('test_import/data/syntax_warnings.py')
with open(filename, 'rb') as f:
source = f.read()
with warnings.catch_warnings(record=True) as wlog:
warnings.simplefilter('error')
warnings.filterwarnings('always', module=r'<unknown>\z')
ast.parse(source)
self.assertEqual(sorted(wm.lineno for wm in wlog), [4, 7, 10])
for wm in wlog:
self.assertEqual(wm.filename, '<unknown>')
self.assertIs(wm.category, SyntaxWarning)
class CopyTests(unittest.TestCase):
"""Test copying and pickling AST nodes."""

View File

@@ -1088,6 +1088,28 @@ class BuiltinTest(ComplexesAreIdenticalMixin, unittest.TestCase):
three_freevars.__globals__,
closure=my_closure)
def test_exec_filter_syntax_warnings_by_module(self):
filename = support.findfile('test_import/data/syntax_warnings.py')
with open(filename, 'rb') as f:
source = f.read()
with warnings.catch_warnings(record=True) as wlog:
warnings.simplefilter('error')
warnings.filterwarnings('always', module=r'<string>\z')
exec(source, {})
self.assertEqual(sorted(wm.lineno for wm in wlog), [4, 7, 10, 13, 14, 21])
for wm in wlog:
self.assertEqual(wm.filename, '<string>')
self.assertIs(wm.category, SyntaxWarning)
with warnings.catch_warnings(record=True) as wlog:
warnings.simplefilter('error')
warnings.filterwarnings('always', module=r'<string>\z')
exec(source, {'__name__': 'package.module', '__file__': filename})
self.assertEqual(sorted(wm.lineno for wm in wlog), [4, 7, 10, 13, 14, 21])
for wm in wlog:
self.assertEqual(wm.filename, '<string>')
self.assertIs(wm.category, SyntaxWarning)
def test_filter(self):
self.assertEqual(list(filter(lambda c: 'a' <= c <= 'z', 'Hello World')), list('elloorld'))

View File

@@ -810,6 +810,19 @@ class CmdLineTest(unittest.TestCase):
out, err = p.communicate()
self.assertEqual(out, b"12345678912345678912345\n")
def test_filter_syntax_warnings_by_module(self):
filename = support.findfile('test_import/data/syntax_warnings.py')
rc, out, err = assert_python_ok(
'-Werror',
'-Walways:::test.test_import.data.syntax_warnings',
filename)
self.assertEqual(err.count(b': SyntaxWarning: '), 6)
rc, out, err = assert_python_ok(
'-Werror',
'-Walways:::syntax_warnings',
filename)
self.assertEqual(err.count(b': SyntaxWarning: '), 6)
def tearDownModule():

View File

@@ -1745,6 +1745,20 @@ class TestSpecifics(unittest.TestCase):
self.assertEqual(wm.category, SyntaxWarning)
self.assertIn("\"is\" with 'int' literal", str(wm.message))
def test_filter_syntax_warnings_by_module(self):
filename = support.findfile('test_import/data/syntax_warnings.py')
with open(filename, 'rb') as f:
source = f.read()
module_re = r'test\.test_import\.data\.syntax_warnings\z'
with warnings.catch_warnings(record=True) as wlog:
warnings.simplefilter('error')
warnings.filterwarnings('always', module=module_re)
compile(source, filename, 'exec')
self.assertEqual(sorted(wm.lineno for wm in wlog), [4, 7, 10, 13, 14, 21])
for wm in wlog:
self.assertEqual(wm.filename, filename)
self.assertIs(wm.category, SyntaxWarning)
@support.subTests('src', [
textwrap.dedent("""
def f():

View File

@@ -15,6 +15,7 @@ import marshal
import os
import py_compile
import random
import re
import shutil
import stat
import subprocess
@@ -23,6 +24,7 @@ import textwrap
import threading
import time
import types
import warnings
import unittest
from unittest import mock
import _imp
@@ -51,7 +53,7 @@ from test.support.os_helper import (
TESTFN, rmtree, temp_umask, TESTFN_UNENCODABLE)
from test.support import script_helper
from test.support import threading_helper
from test.test_importlib.util import uncache
from test.test_importlib.util import uncache, temporary_pycache_prefix
from types import ModuleType
try:
import _testsinglephase
@@ -412,7 +414,6 @@ class ImportTests(unittest.TestCase):
self.assertIsNotNone(cm.exception)
def test_from_import_star_invalid_type(self):
import re
with ready_to_import() as (name, path):
with open(path, 'w', encoding='utf-8') as f:
f.write("__all__ = [b'invalid_type']")
@@ -1250,6 +1251,35 @@ os.does_not_exist
origin = "a\x00b"
_imp.create_dynamic(Spec2())
def test_filter_syntax_warnings_by_module(self):
module_re = r'test\.test_import\.data\.syntax_warnings\z'
unload('test.test_import.data.syntax_warnings')
with (os_helper.temp_dir() as tmpdir,
temporary_pycache_prefix(tmpdir),
warnings.catch_warnings(record=True) as wlog):
warnings.simplefilter('error')
warnings.filterwarnings('always', module=module_re)
import test.test_import.data.syntax_warnings
self.assertEqual(sorted(wm.lineno for wm in wlog), [4, 7, 10, 13, 14, 21])
filename = test.test_import.data.syntax_warnings.__file__
for wm in wlog:
self.assertEqual(wm.filename, filename)
self.assertIs(wm.category, SyntaxWarning)
module_re = r'syntax_warnings\z'
unload('test.test_import.data.syntax_warnings')
with (os_helper.temp_dir() as tmpdir,
temporary_pycache_prefix(tmpdir),
warnings.catch_warnings(record=True) as wlog):
warnings.simplefilter('error')
warnings.filterwarnings('always', module=module_re)
import test.test_import.data.syntax_warnings
self.assertEqual(sorted(wm.lineno for wm in wlog), [4, 7, 10, 13, 14, 21])
filename = test.test_import.data.syntax_warnings.__file__
for wm in wlog:
self.assertEqual(wm.filename, filename)
self.assertIs(wm.category, SyntaxWarning)
@skip_if_dont_write_bytecode
class FilePermissionTests(unittest.TestCase):

View File

@@ -0,0 +1,21 @@
# Syntax warnings emitted in different parts of the Python compiler.
# Parser/lexer/lexer.c
x = 1or 0 # line 4
# Parser/tokenizer/helpers.c
'\z' # line 7
# Parser/string_parser.c
'\400' # line 10
# _PyCompile_Warn() in Python/codegen.c
assert(x, 'message') # line 13
x is 1 # line 14
# _PyErr_EmitSyntaxWarning() in Python/ast_preprocess.c
def f():
try:
pass
finally:
return 42 # line 21

View File

@@ -5,6 +5,7 @@ Test the API of the symtable module.
import re
import textwrap
import symtable
import warnings
import unittest
from test import support
@@ -586,6 +587,20 @@ class SymtableTest(unittest.TestCase):
# check error path when 'compile_type' AC conversion failed
self.assertRaises(TypeError, symtable.symtable, '', mortal_str, 1)
def test_filter_syntax_warnings_by_module(self):
filename = support.findfile('test_import/data/syntax_warnings.py')
with open(filename, 'rb') as f:
source = f.read()
module_re = r'test\.test_import\.data\.syntax_warnings\z'
with warnings.catch_warnings(record=True) as wlog:
warnings.simplefilter('error')
warnings.filterwarnings('always', module=module_re)
symtable.symtable(source, filename, 'exec')
self.assertEqual(sorted(wm.lineno for wm in wlog), [4, 7, 10])
for wm in wlog:
self.assertEqual(wm.filename, filename)
self.assertIs(wm.category, SyntaxWarning)
class ComprehensionTests(unittest.TestCase):
def get_identifiers_recursive(self, st, res):

View File

@@ -249,10 +249,23 @@ class FilterTests(BaseTest):
self.module.warn_explicit('msg', UserWarning, 'filename', 42,
module='package.module')
self.assertEqual(len(w), 1)
self.module.warn_explicit('msg', UserWarning, '/path/to/package/module', 42)
self.assertEqual(len(w), 2)
self.module.warn_explicit('msg', UserWarning, '/path/to/package/module.py', 42)
self.assertEqual(len(w), 3)
self.module.warn_explicit('msg', UserWarning, '/path/to/package/module/__init__.py', 42)
self.assertEqual(len(w), 4)
with self.assertRaises(UserWarning):
self.module.warn_explicit('msg', UserWarning, '/path/to/package/module', 42)
with self.assertRaises(UserWarning):
self.module.warn_explicit('msg', UserWarning, '/path/to/package/module.py', 42)
self.module.warn_explicit('msg', UserWarning, '/path/to/package/module/__init__', 42)
if MS_WINDOWS:
self.module.warn_explicit('msg', UserWarning, r'C:\path\to\package\module.PY', 42)
self.assertEqual(len(w), 5)
self.module.warn_explicit('msg', UserWarning, r'C:\path\to\package\module\__INIT__.PY', 42)
self.assertEqual(len(w), 6)
self.module.warn_explicit('msg', UserWarning, r'C:\path\to\package\module.PYW', 42)
self.assertEqual(len(w), 7)
self.module.warn_explicit('msg', UserWarning, r'C:\path\to\package\module\__INIT__.PYW', 42)
self.assertEqual(len(w), 8)
with self.module.catch_warnings(record=True) as w:
self.module.simplefilter('error')
@@ -276,9 +289,8 @@ class FilterTests(BaseTest):
with self.assertRaises(UserWarning):
self.module.warn_explicit('msg', UserWarning, '/PATH/TO/PACKAGE/MODULE', 42)
if MS_WINDOWS:
if self.module is py_warnings:
self.module.warn_explicit('msg', UserWarning, r'/path/to/package/module.PY', 42)
self.assertEqual(len(w), 3)
self.module.warn_explicit('msg', UserWarning, r'/path/to/package/module.PY', 42)
self.assertEqual(len(w), 3)
with self.assertRaises(UserWarning):
self.module.warn_explicit('msg', UserWarning, r'/path/to/package/module/__init__.py', 42)
with self.assertRaises(UserWarning):
@@ -302,9 +314,8 @@ class FilterTests(BaseTest):
self.assertEqual(len(w), 1)
self.module.warn_explicit('msg', UserWarning, r'C:\path\to\package\module.py', 42)
self.assertEqual(len(w), 2)
if self.module is py_warnings:
self.module.warn_explicit('msg', UserWarning, r'C:\path\to\package\module.PY', 42)
self.assertEqual(len(w), 3)
self.module.warn_explicit('msg', UserWarning, r'C:\path\to\package\module.PY', 42)
self.assertEqual(len(w), 3)
with self.assertRaises(UserWarning):
self.module.warn_explicit('msg', UserWarning, r'C:\path\to\package\module.pyw', 42)
with self.assertRaises(UserWarning):
@@ -399,7 +410,7 @@ class FilterTests(BaseTest):
def test_mutate_filter_list(self):
class X:
def match(self, a):
def match(self, a, start=0):
L[:] = []
L = [("default",X(),UserWarning,X(),0) for i in range(2)]

View File

@@ -0,0 +1,6 @@
Improve filtering by module in :func:`warnings.warn_explicit` if no *module*
argument is passed. It now tests the module regular expression in the
warnings filter not only against the filename with ``.py`` stripped, but
also against module names constructed starting from different parent
directories of the filename (with ``/__init__.py``, ``.py`` and, on Windows,
``.pyw`` stripped).

View File

@@ -171,7 +171,7 @@ _PyWarnings_InitState(PyInterpreterState *interp)
/*************************************************************************/
static int
check_matched(PyInterpreterState *interp, PyObject *obj, PyObject *arg)
check_matched(PyInterpreterState *interp, PyObject *obj, PyObject *arg, PyObject *arg2)
{
PyObject *result;
int rc;
@@ -182,6 +182,9 @@ check_matched(PyInterpreterState *interp, PyObject *obj, PyObject *arg)
/* An internal plain text default filter must match exactly */
if (PyUnicode_CheckExact(obj)) {
if (arg == NULL) {
return 0;
}
int cmp_result = PyUnicode_Compare(obj, arg);
if (cmp_result == -1 && PyErr_Occurred()) {
return -1;
@@ -190,10 +193,19 @@ check_matched(PyInterpreterState *interp, PyObject *obj, PyObject *arg)
}
/* Otherwise assume a regex filter and call its match() method */
result = PyObject_CallMethodOneArg(obj, &_Py_ID(match), arg);
if (arg != NULL) {
result = PyObject_CallMethodOneArg(obj, &_Py_ID(match), arg);
}
else {
PyObject *match = PyImport_ImportModuleAttrString("_py_warnings", "_match_filename");
if (match == NULL) {
return -1;
}
result = PyObject_CallFunctionObjArgs(match, obj, arg2, NULL);
Py_DECREF(match);
}
if (result == NULL)
return -1;
rc = PyObject_IsTrue(result);
Py_DECREF(result);
return rc;
@@ -423,7 +435,7 @@ get_default_action(PyInterpreterState *interp)
static bool
filter_search(PyInterpreterState *interp, PyObject *category,
PyObject *text, Py_ssize_t lineno,
PyObject *module, char *list_name, PyObject *filters,
PyObject *module, PyObject *filename, char *list_name, PyObject *filters,
PyObject **item, PyObject **matched_action) {
bool result = true;
*matched_action = NULL;
@@ -459,14 +471,14 @@ filter_search(PyInterpreterState *interp, PyObject *category,
break;
}
good_msg = check_matched(interp, msg, text);
good_msg = check_matched(interp, msg, text, NULL);
if (good_msg == -1) {
Py_DECREF(tmp_item);
result = false;
break;
}
good_mod = check_matched(interp, mod, module);
good_mod = check_matched(interp, mod, module, filename);
if (good_mod == -1) {
Py_DECREF(tmp_item);
result = false;
@@ -504,7 +516,7 @@ filter_search(PyInterpreterState *interp, PyObject *category,
static PyObject*
get_filter(PyInterpreterState *interp, PyObject *category,
PyObject *text, Py_ssize_t lineno,
PyObject *module, PyObject **item)
PyObject *module, PyObject *filename, PyObject **item)
{
#ifdef Py_DEBUG
WarningsState *st = warnings_get_state(interp);
@@ -522,7 +534,7 @@ get_filter(PyInterpreterState *interp, PyObject *category,
use_global_filters = true;
} else {
PyObject *context_action = NULL;
if (!filter_search(interp, category, text, lineno, module, "_warnings_context _filters",
if (!filter_search(interp, category, text, lineno, module, filename, "_warnings_context _filters",
context_filters, item, &context_action)) {
Py_DECREF(context_filters);
return NULL;
@@ -541,7 +553,7 @@ get_filter(PyInterpreterState *interp, PyObject *category,
if (filters == NULL) {
return NULL;
}
if (!filter_search(interp, category, text, lineno, module, "filters",
if (!filter_search(interp, category, text, lineno, module, filename, "filters",
filters, item, &action)) {
return NULL;
}
@@ -612,39 +624,6 @@ already_warned(PyInterpreterState *interp, PyObject *registry, PyObject *key,
return 0;
}
/* New reference. */
static PyObject *
normalize_module(PyObject *filename)
{
PyObject *module;
int kind;
const void *data;
Py_ssize_t len;
len = PyUnicode_GetLength(filename);
if (len < 0)
return NULL;
if (len == 0)
return PyUnicode_FromString("<unknown>");
kind = PyUnicode_KIND(filename);
data = PyUnicode_DATA(filename);
/* if filename.endswith(".py"): */
if (len >= 3 &&
PyUnicode_READ(kind, data, len-3) == '.' &&
PyUnicode_READ(kind, data, len-2) == 'p' &&
PyUnicode_READ(kind, data, len-1) == 'y')
{
module = PyUnicode_Substring(filename, 0, len-3);
}
else {
module = Py_NewRef(filename);
}
return module;
}
static int
update_registry(PyInterpreterState *interp, PyObject *registry, PyObject *text,
PyObject *category, int add_zero)
@@ -812,15 +791,6 @@ warn_explicit(PyThreadState *tstate, PyObject *category, PyObject *message,
return NULL;
}
/* Normalize module. */
if (module == NULL) {
module = normalize_module(filename);
if (module == NULL)
return NULL;
}
else
Py_INCREF(module);
/* Normalize message. */
Py_INCREF(message); /* DECREF'ed in cleanup. */
if (PyObject_TypeCheck(message, (PyTypeObject *)PyExc_Warning)) {
@@ -858,7 +828,7 @@ warn_explicit(PyThreadState *tstate, PyObject *category, PyObject *message,
/* Else this warning hasn't been generated before. */
}
action = get_filter(interp, category, text, lineno, module, &item);
action = get_filter(interp, category, text, lineno, module, filename, &item);
if (action == NULL)
goto cleanup;
@@ -921,7 +891,6 @@ warn_explicit(PyThreadState *tstate, PyObject *category, PyObject *message,
Py_XDECREF(key);
Py_XDECREF(text);
Py_XDECREF(lineno_obj);
Py_DECREF(module);
Py_XDECREF(message);
return result; /* Py_None or NULL. */
}