apply warnings filter as soon as possible, and remove it as late as possible (#13057)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Thomas Grainger 2024-12-18 11:09:33 +00:00 committed by GitHub
parent b39b871e79
commit 868e1d225e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 181 additions and 34 deletions

View File

@ -123,6 +123,7 @@ jobs:
python: "3.9"
os: ubuntu-latest
tox_env: "py39-lsof-numpy-pexpect"
use_coverage: true
- name: "ubuntu-py39-pluggy"
python: "3.9"

View File

@ -0,0 +1,7 @@
Apply filterwarnings from config/cli as soon as possible, and revert them as late as possible
so that warnings as errors are collected throughout the pytest run and before the
unraisable and threadexcept hooks are removed.
This allows very late warnings and unraisable/threadexcept exceptions to fail the test suite.
This also changes the warning that the lsof plugin issues from PytestWarning to the new warning PytestFDWarning so it can be more easily filtered.

View File

@ -403,6 +403,9 @@ filterwarnings = [
"ignore:VendorImporter\\.find_spec\\(\\) not found; falling back to find_module\\(\\):ImportWarning",
# https://github.com/pytest-dev/execnet/pull/127
"ignore:isSet\\(\\) is deprecated, use is_set\\(\\) instead:DeprecationWarning",
# https://github.com/pytest-dev/pytest/issues/2366
# https://github.com/pytest-dev/pytest/pull/13057
"default::pytest.PytestFDWarning",
]
pytester_example_dir = "testing/example_scripts"
markers = [

View File

@ -264,11 +264,11 @@ default_plugins = (
"setuponly",
"setupplan",
"stepwise",
"unraisableexception",
"threadexception",
"warnings",
"logging",
"reports",
"unraisableexception",
"threadexception",
"faulthandler",
)
@ -1112,9 +1112,7 @@ class Config:
def _do_configure(self) -> None:
assert not self._configured
self._configured = True
with warnings.catch_warnings():
warnings.simplefilter("default")
self.hook.pytest_configure.call_historic(kwargs=dict(config=self))
self.hook.pytest_configure.call_historic(kwargs=dict(config=self))
def _ensure_unconfigure(self) -> None:
try:

View File

@ -65,7 +65,7 @@ from _pytest.pathlib import make_numbered_dir
from _pytest.reports import CollectReport
from _pytest.reports import TestReport
from _pytest.tmpdir import TempPathFactory
from _pytest.warning_types import PytestWarning
from _pytest.warning_types import PytestFDWarning
if TYPE_CHECKING:
@ -188,7 +188,7 @@ class LsofFdLeakChecker:
"*** function {}:{}: {} ".format(*item.location),
"See issue #2366",
]
item.warn(PytestWarning("\n".join(error)))
item.warn(PytestFDWarning("\n".join(error)))
# used at least by pytest-xdist plugin

View File

@ -123,6 +123,13 @@ class UnformattedWarning(Generic[_W]):
return self.category(self.template.format(**kwargs))
@final
class PytestFDWarning(PytestWarning):
"""When the lsof plugin finds leaked fds."""
__module__ = "pytest"
def warn_explicit_for(method: FunctionType, message: PytestWarning) -> None:
"""
Issue the warning :param:`message` for the definition of the given :param:`method`

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Generator
from contextlib import contextmanager
from contextlib import ExitStack
import sys
from typing import Literal
import warnings
@ -17,20 +18,14 @@ from _pytest.tracemalloc import tracemalloc_message
import pytest
def pytest_configure(config: Config) -> None:
config.addinivalue_line(
"markers",
"filterwarnings(warning): add a warning filter to the given test. "
"see https://docs.pytest.org/en/stable/how-to/capture-warnings.html#pytest-mark-filterwarnings ",
)
@contextmanager
def catch_warnings_for_item(
config: Config,
ihook,
when: Literal["config", "collect", "runtest"],
item: Item | None,
*,
record: bool = True,
) -> Generator[None]:
"""Context manager that catches warnings generated in the contained execution block.
@ -40,10 +35,7 @@ def catch_warnings_for_item(
"""
config_filters = config.getini("filterwarnings")
cmdline_filters = config.known_args_namespace.pythonwarnings or []
with warnings.catch_warnings(record=True) as log:
# mypy can't infer that record=True means log is not None; help it.
assert log is not None
with warnings.catch_warnings(record=record) as log:
if not sys.warnoptions:
# If user is not explicitly configuring warning filters, show deprecation warnings by default (#2908).
warnings.filterwarnings("always", category=DeprecationWarning)
@ -64,15 +56,19 @@ def catch_warnings_for_item(
try:
yield
finally:
for warning_message in log:
ihook.pytest_warning_recorded.call_historic(
kwargs=dict(
warning_message=warning_message,
nodeid=nodeid,
when=when,
location=None,
if record:
# mypy can't infer that record=True means log is not None; help it.
assert log is not None
for warning_message in log:
ihook.pytest_warning_recorded.call_historic(
kwargs=dict(
warning_message=warning_message,
nodeid=nodeid,
when=when,
location=None,
)
)
)
def warning_record_to_str(warning_message: warnings.WarningMessage) -> str:
@ -131,3 +127,26 @@ def pytest_load_initial_conftests(
config=early_config, ihook=early_config.hook, when="config", item=None
):
return (yield)
def pytest_configure(config: Config) -> None:
with ExitStack() as stack:
stack.enter_context(
catch_warnings_for_item(
config=config,
ihook=config.hook,
when="config",
item=None,
# this disables recording because the terminalreporter has
# finished by the time it comes to reporting logged warnings
# from the end of config cleanup. So for now, this is only
# useful for setting a warning filter with an 'error' action.
record=False,
)
)
config.addinivalue_line(
"markers",
"filterwarnings(warning): add a warning filter to the given test. "
"see https://docs.pytest.org/en/stable/how-to/capture-warnings.html#pytest-mark-filterwarnings ",
)
config.add_cleanup(stack.pop_all().close)

View File

@ -77,6 +77,7 @@ from _pytest.warning_types import PytestCollectionWarning
from _pytest.warning_types import PytestConfigWarning
from _pytest.warning_types import PytestDeprecationWarning
from _pytest.warning_types import PytestExperimentalApiWarning
from _pytest.warning_types import PytestFDWarning
from _pytest.warning_types import PytestRemovedIn9Warning
from _pytest.warning_types import PytestUnhandledThreadExceptionWarning
from _pytest.warning_types import PytestUnknownMarkWarning
@ -124,6 +125,7 @@ __all__ = [
"PytestConfigWarning",
"PytestDeprecationWarning",
"PytestExperimentalApiWarning",
"PytestFDWarning",
"PytestPluginManager",
"PytestRemovedIn9Warning",
"PytestUnhandledThreadExceptionWarning",

View File

@ -1,5 +1,7 @@
from __future__ import annotations
from collections.abc import Generator
import contextlib
import gc
import sys
from unittest import mock
@ -203,7 +205,25 @@ def test_unraisable_collection_failure(pytester: Pytester) -> None:
)
def test_create_task_unraisable(pytester: Pytester) -> None:
def _set_gc_state(enabled: bool) -> bool:
was_enabled = gc.isenabled()
if enabled:
gc.enable()
else:
gc.disable()
return was_enabled
@contextlib.contextmanager
def _disable_gc() -> Generator[None]:
was_enabled = _set_gc_state(enabled=False)
try:
yield
finally:
_set_gc_state(enabled=was_enabled)
def test_refcycle_unraisable(pytester: Pytester) -> None:
# see: https://github.com/pytest-dev/pytest/issues/10404
pytester.makepyfile(
test_it="""
@ -221,13 +241,8 @@ def test_create_task_unraisable(pytester: Pytester) -> None:
"""
)
was_enabled = gc.isenabled()
gc.disable()
try:
with _disable_gc():
result = pytester.runpytest()
finally:
if was_enabled:
gc.enable()
# TODO: should be a test failure or error
assert result.ret == pytest.ExitCode.INTERNAL_ERROR
@ -236,6 +251,101 @@ def test_create_task_unraisable(pytester: Pytester) -> None:
result.stderr.fnmatch_lines("ValueError: del is broken")
@pytest.mark.filterwarnings("default::pytest.PytestUnraisableExceptionWarning")
def test_refcycle_unraisable_warning_filter(pytester: Pytester) -> None:
# note that the host pytest warning filter is disabled and the pytester
# warning filter applies during config teardown of unraisablehook.
# see: https://github.com/pytest-dev/pytest/issues/10404
pytester.makepyfile(
test_it="""
import pytest
class BrokenDel:
def __init__(self):
self.self = self # make a reference cycle
def __del__(self):
raise ValueError("del is broken")
def test_it():
BrokenDel()
"""
)
with _disable_gc():
result = pytester.runpytest("-Werror")
# TODO: should be a test failure or error
assert result.ret == pytest.ExitCode.INTERNAL_ERROR
result.assert_outcomes(passed=1)
result.stderr.fnmatch_lines("ValueError: del is broken")
@pytest.mark.filterwarnings("default::pytest.PytestUnraisableExceptionWarning")
def test_create_task_raises_unraisable_warning_filter(pytester: Pytester) -> None:
# note that the host pytest warning filter is disabled and the pytester
# warning filter applies during config teardown of unraisablehook.
# see: https://github.com/pytest-dev/pytest/issues/10404
# This is a dupe of the above test, but using the exact reproducer from
# the issue
pytester.makepyfile(
test_it="""
import asyncio
import pytest
async def my_task():
pass
def test_scheduler_must_be_created_within_running_loop() -> None:
with pytest.raises(RuntimeError) as _:
asyncio.create_task(my_task())
"""
)
with _disable_gc():
result = pytester.runpytest("-Werror")
# TODO: should be a test failure or error
assert result.ret == pytest.ExitCode.INTERNAL_ERROR
result.assert_outcomes(passed=1)
result.stderr.fnmatch_lines("RuntimeWarning: coroutine 'my_task' was never awaited")
def test_refcycle_unraisable_warning_filter_default(pytester: Pytester) -> None:
# note this time we use a default warning filter for pytester
# and run it in a subprocess, because the warning can only go to the
# sys.stdout rather than the terminal reporter, which has already
# finished.
# see: https://github.com/pytest-dev/pytest/pull/13057#discussion_r1888396126
pytester.makepyfile(
test_it="""
import pytest
class BrokenDel:
def __init__(self):
self.self = self # make a reference cycle
def __del__(self):
raise ValueError("del is broken")
def test_it():
BrokenDel()
"""
)
with _disable_gc():
result = pytester.runpytest_subprocess("-Wdefault")
assert result.ret == pytest.ExitCode.OK
# TODO: should be warnings=1, but the outcome has already come out
# by the time the warning triggers
result.assert_outcomes(passed=1)
result.stderr.fnmatch_lines("ValueError: del is broken")
@pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning")
def test_possibly_none_excinfo(pytester: Pytester) -> None:
pytester.makepyfile(