Skip to content
Open
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
4 changes: 4 additions & 0 deletions changelog/11933.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 35 additions & 1 deletion src/_pytest/recwarn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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,
)

Expand Down
47 changes: 47 additions & 0 deletions testing/test_recwarn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
Loading