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
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Ariel Pillemer
Armin Rigo
Aron Coyle
Aron Curzon
Arron Zou
Arthur Richard
Ashish Kurmi
Ashley Whetter
Expand Down
3 changes: 3 additions & 0 deletions changelog/13485.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Warning filters registered by collected modules and ``conftest.py`` files
(for example via :func:`warnings.filterwarnings`) are now honored during
the test run instead of being discarded when collection finishes.
5 changes: 4 additions & 1 deletion doc/en/how-to/capture-warnings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,10 @@ See :ref:`@pytest.mark.filterwarnings <filterwarnings>` and

Also pytest doesn't follow :pep:`565` suggestion of resetting all warning filters because
it might break test suites that configure warning filters themselves
by calling :func:`warnings.simplefilter` (see :issue:`2430` for an example of that).
by calling :func:`warnings.simplefilter` or :func:`warnings.filterwarnings`
(see :issue:`2430` and :issue:`13485`).
Filters installed at module level in test modules or ``conftest.py`` during
collection are kept for the test run.


.. _`ensuring a function triggers a deprecation warning`:
Expand Down
58 changes: 56 additions & 2 deletions src/_pytest/warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,45 @@

from collections.abc import Generator
from contextlib import contextmanager
from typing import Any
from typing import cast
from typing import Literal
import warnings

from _pytest.config import Config
from _pytest.config import parse_warning_filter
from _pytest.main import Session
from _pytest.nodes import Item
from _pytest.stash import StashKey
from _pytest.terminal import TerminalReporter
from _pytest.tracemalloc import tracemalloc_message
import pytest


# Filters that user code added while an earlier catching context was active
# (initial conftest import, collection). Re-applied in later catching contexts
# so module-level warnings.filterwarnings() is not discarded (#2430, #13485).
_persisted_filters_key = StashKey[list[Any]]()


def _copy_filters() -> list[Any]:
return list(warnings.filters)


def _filter_list() -> list[Any]:
# warnings.filters is a mutable list; the stub types it as Sequence.
return cast(list[Any], warnings.filters)


def _prepend_filters(filters: list[Any]) -> None:
if filters:
_filter_list()[:0] = filters


def _filters_added(before: list[Any], after: list[Any]) -> list[Any]:
return [f for f in after if f not in before]


@contextmanager
def catch_warnings_for_item(
config: Config,
Expand All @@ -23,24 +50,41 @@ def catch_warnings_for_item(
item: Item | None,
*,
record: bool = True,
persist_new_filters: bool = False,
) -> Generator[None]:
"""Context manager that catches warnings generated in the contained execution block.

``item`` can be None if we are not in the context of an item execution.

Each warning captured triggers the ``pytest_warning_recorded`` hook.

If ``persist_new_filters`` is true, filters installed by the block (for
example ``warnings.filterwarnings`` in a collected module) are kept and
re-applied in later catching contexts.
"""
added: list[Any] = []
with config._catch_configured_warnings(record=record) as log:
persisted = config.stash.get(_persisted_filters_key, None)
if persisted:
_prepend_filters(persisted)
# apply filters from "filterwarnings" marks
nodeid = "" if item is None else item.nodeid
if item is not None:
for mark in item.iter_markers(name="filterwarnings"):
for arg in mark.args:
warnings.filterwarnings(*parse_warning_filter(arg, escape=False))

before = _copy_filters() if persist_new_filters else []
try:
yield
finally:
if persist_new_filters:
added = _filters_added(before, _copy_filters())
if added:
prev = config.stash.get(_persisted_filters_key, [])
config.stash[_persisted_filters_key] = added + [
f for f in prev if f not in added
]
if record:
# mypy can't infer that record=True means log is not None; help it.
assert log is not None
Expand All @@ -54,6 +98,8 @@ def catch_warnings_for_item(
location=None,
)
)
# Promote newly added filters into the enclosing warnings context.
_prepend_filters(added)


def warning_record_to_str(warning_message: warnings.WarningMessage) -> str:
Expand All @@ -79,7 +125,11 @@ def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
def pytest_collection(session: Session) -> Generator[None, object, object]:
config = session.config
with catch_warnings_for_item(
config=config, ihook=config.hook, when="collect", item=None
config=config,
ihook=config.hook,
when="collect",
item=None,
persist_new_filters=True,
):
return (yield)

Expand Down Expand Up @@ -109,7 +159,11 @@ def pytest_load_initial_conftests(
early_config: Config,
) -> Generator[None]:
with catch_warnings_for_item(
config=early_config, ihook=early_config.hook, when="config", item=None
config=early_config,
ihook=early_config.hook,
when="config",
item=None,
persist_new_filters=True,
):
return (yield)

Expand Down
71 changes: 66 additions & 5 deletions testing/test_warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,8 @@ def test_func(fix):
)


@pytest.mark.skip("issue #13485")
def test_works_with_filterwarnings(pytester: Pytester) -> None:
"""Ensure our warnings capture does not mess with pre-installed filters (#2430)."""
"""Module-level warnings.filterwarnings during collection apply at run (#2430, #13485)."""
pytester.makepyfile(
"""
import warnings
Expand All @@ -162,7 +161,7 @@ class MyWarning(Warning):

warnings.filterwarnings("error", category=MyWarning)

class TestWarnings(object):
class TestWarnings:
def test_my_warning(self):
try:
warnings.warn(MyWarning("warn!"))
Expand All @@ -171,8 +170,70 @@ def test_my_warning(self):
assert True
"""
)
result = pytester.runpytest()
result.stdout.fnmatch_lines(["*== 1 passed in *"])
# Subprocess + -Wdefault so this does not pass only because the outer
# suite uses filterwarnings=error (#13480).
result = pytester.runpytest_subprocess("-W", "default")
result.assert_outcomes(passed=1)


def test_collection_filterwarnings_ignore_during_run(pytester: Pytester) -> None:
"""Ignore filters registered while collecting a module apply during the test run."""
pytester.makepyfile(
"""
import warnings

warnings.filterwarnings("ignore", category=UserWarning)

def test_hidden():
warnings.warn(UserWarning("from collected module"))
"""
)
result = pytester.runpytest_subprocess("-W", "always")
result.assert_outcomes(passed=1, warnings=0)
assert WARNINGS_SUMMARY_HEADER not in result.stdout.str()


def test_collection_filterwarnings_from_conftest(pytester: Pytester) -> None:
"""Filters set in conftest.py apply during the test run (#13485)."""
pytester.makeconftest(
"""
import warnings

warnings.filterwarnings("ignore", category=UserWarning)
"""
)
pytester.makepyfile(
"""
import warnings

def test_hidden():
warnings.warn(UserWarning("from test"))
"""
)
result = pytester.runpytest_subprocess("-W", "always")
result.assert_outcomes(passed=1, warnings=0)
assert WARNINGS_SUMMARY_HEADER not in result.stdout.str()


def test_mark_filterwarnings_overrides_collection_filters(
pytester: Pytester,
) -> None:
"""@mark.filterwarnings still takes precedence over collection-time filters."""
pytester.makepyfile(
"""
import warnings
import pytest

warnings.filterwarnings("ignore", category=UserWarning)

@pytest.mark.filterwarnings("error::UserWarning")
def test_mark_wins():
with pytest.raises(UserWarning):
warnings.warn(UserWarning("from test"))
"""
)
result = pytester.runpytest_subprocess("-W", "default")
result.assert_outcomes(passed=1)


@pytest.mark.parametrize("default_config", ["ini", "cmdline"])
Expand Down