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"):