From 58c82de7cec28fdb5bdd0fb8e0110381f41dcf2b Mon Sep 17 00:00:00 2001 From: mokashang Date: Fri, 12 Jun 2026 09:45:55 -0700 Subject: [PATCH] recwarn: pass originating module name when re-emitting unmatched warnings The re-emit loop in WarningsChecker.__exit__ passed module=w.__module__ to warnings.warn_explicit, but w is a warnings.WarningMessage instance, so w.__module__ is always the string "warnings" (the module that WarningMessage is defined in). As a result, a user-installed filter such as warnings.filterwarnings("ignore", module=r"my_module") never matches the re-emitted warning, even though it matches the original. Recover the originating module's importable name by looking it up in sys.modules by filename. Fall back to None when no module matches, which lets warn_explicit derive the name from the filename itself (matching its own default behavior). Add a regression test using a packaged module so the importable name (regr_pkg.inner) differs from the filename-derived default. The test's module-anchored regex matches only the correct importable name and fails for both the original buggy value ("warnings") and the filename-derived fallback, ensuring the test pins the fix down rather than the looser invariant. Fixes #11933 --- changelog/11933.bugfix.rst | 4 ++++ src/_pytest/recwarn.py | 36 ++++++++++++++++++++++++++++- testing/test_recwarn.py | 47 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 changelog/11933.bugfix.rst diff --git a/changelog/11933.bugfix.rst b/changelog/11933.bugfix.rst new file mode 100644 index 00000000000..d770b8dd669 --- /dev/null +++ b/changelog/11933.bugfix.rst @@ -0,0 +1,4 @@ +Fixed :func:`pytest.warns` re-emitting unmatched warnings with the wrong +``module`` argument, which prevented user-installed +``warnings.filterwarnings(..., module=...)`` rules from matching the +re-emitted warning. diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index c3cb10b7f08..27c0689fa09 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -8,6 +8,7 @@ from collections.abc import Iterator from pprint import pformat import re +import sys from types import TracebackType from typing import Any from typing import final @@ -33,6 +34,30 @@ T = TypeVar("T") +def _module_name_for_filename(filename: str | None) -> str | None: + """Recover the importable module name for a warning's ``filename``. + + :class:`warnings.WarningMessage` does not preserve the ``module`` argument + that :func:`warnings.warn_explicit` was originally called with, so when + re-emitting an unmatched warning we cannot use ``w.__module__`` (that + attribute is the module *the WarningMessage class is defined in*, i.e. + ``"warnings"``). As the next best thing, look up the module in + :data:`sys.modules` whose ``__file__`` matches the warning's filename. + + Returns ``None`` if no module can be matched (for example, the warning + originated from ``exec``-ed code, a frozen / built-in module, or a file + that has since been unloaded). In that case + :func:`warnings.warn_explicit` derives the module name from ``filename`` + itself, matching its own default behavior. + """ + if not filename: + return None + for name, mod in sys.modules.items(): + if getattr(mod, "__file__", None) == filename: + return name + return None + + @fixture def recwarn() -> Generator[WarningsRecorder]: """Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions. @@ -347,7 +372,16 @@ def found_str() -> str: category=w.category, filename=w.filename, lineno=w.lineno, - module=w.__module__, + # ``w.__module__`` is always ``"warnings"`` (the module + # ``WarningMessage`` is defined in), which causes + # ``warnings.filterwarnings(..., module=...)`` rules + # supplied by the user to never match this re-emit. + # Recover the originating module's importable name from + # its filename so the user's module-scoped filters can + # still act on the re-emitted warning. Falling back to + # ``None`` lets :func:`warnings.warn_explicit` derive a + # name from the filename, matching its default behavior. + module=_module_name_for_filename(w.filename), source=w.source, ) diff --git a/testing/test_recwarn.py b/testing/test_recwarn.py index f05fc6e4871..b1c15ef6a41 100644 --- a/testing/test_recwarn.py +++ b/testing/test_recwarn.py @@ -501,6 +501,53 @@ def test_re_emit_non_match_single(self) -> None: warnings.warn("v1 warning", UserWarning) warnings.warn("non-matching v2 warning", UserWarning) + def test_re_emit_preserves_module_name(self, pytester: Pytester) -> None: + r"""Regression test for #11933. + + :func:`pytest.warns` re-emits unmatched warnings via + :func:`warnings.warn_explicit`. The ``module`` argument must reflect the + originating module's importable name; otherwise user-installed + ``warnings.filterwarnings(..., module=...)`` rules silently fail to + match the re-emitted warning. + + The package layout below makes the importable name (``regr_pkg.inner``) + differ from :func:`warnings.warn_explicit`'s filename-derived default + (which would be ``"<...>/regr_pkg/inner"``). The regex + ``^regr_pkg\.inner$`` matches the former and nothing else, so the + outer ``"error"`` filter fires only when re-emit passes the correct + module name — distinguishing the fix from both the original + (``module="warnings"``) and from omitting the argument entirely. + """ + pkg = pytester.mkpydir("regr_pkg") + pkg.joinpath("inner.py").write_text( + "import warnings\ndef emit(msg):\n warnings.warn(msg, UserWarning)\n" + ) + pytester.makepyfile( + test_module=r""" + import warnings + import pytest + from regr_pkg import inner + + def test_module_filter_applies_to_reemitted_warning(): + with warnings.catch_warnings(): + warnings.resetwarnings() + warnings.simplefilter("always") + warnings.filterwarnings( + "error", + category=UserWarning, + module=r"^regr_pkg\.inner$", + ) + with pytest.raises(UserWarning, match="from inner"): + with pytest.warns(UserWarning, match="other"): + # Unmatched -> re-emitted on pytest.warns __exit__. + inner.emit("from inner") + # Matched -> satisfies the inner assertion. + warnings.warn("other", UserWarning) + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1) + def test_catch_warning_within_raise(self) -> None: # warns-in-raises works since https://github.com/pytest-dev/pytest/pull/11129 with pytest.raises(ValueError, match="some exception"):