Skip to content

Commit 868e1d2

Browse files
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>
1 parent b39b871 commit 868e1d2

File tree

9 files changed

+181
-34
lines changed

9 files changed

+181
-34
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ jobs:
123123
python: "3.9"
124124
os: ubuntu-latest
125125
tox_env: "py39-lsof-numpy-pexpect"
126+
use_coverage: true
126127

127128
- name: "ubuntu-py39-pluggy"
128129
python: "3.9"

changelog/10404.bugfix.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Apply filterwarnings from config/cli as soon as possible, and revert them as late as possible
2+
so that warnings as errors are collected throughout the pytest run and before the
3+
unraisable and threadexcept hooks are removed.
4+
5+
This allows very late warnings and unraisable/threadexcept exceptions to fail the test suite.
6+
7+
This also changes the warning that the lsof plugin issues from PytestWarning to the new warning PytestFDWarning so it can be more easily filtered.

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,9 @@ filterwarnings = [
403403
"ignore:VendorImporter\\.find_spec\\(\\) not found; falling back to find_module\\(\\):ImportWarning",
404404
# https://github.com/pytest-dev/execnet/pull/127
405405
"ignore:isSet\\(\\) is deprecated, use is_set\\(\\) instead:DeprecationWarning",
406+
# https://github.com/pytest-dev/pytest/issues/2366
407+
# https://github.com/pytest-dev/pytest/pull/13057
408+
"default::pytest.PytestFDWarning",
406409
]
407410
pytester_example_dir = "testing/example_scripts"
408411
markers = [

src/_pytest/config/__init__.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -264,11 +264,11 @@ def directory_arg(path: str, optname: str) -> str:
264264
"setuponly",
265265
"setupplan",
266266
"stepwise",
267+
"unraisableexception",
268+
"threadexception",
267269
"warnings",
268270
"logging",
269271
"reports",
270-
"unraisableexception",
271-
"threadexception",
272272
"faulthandler",
273273
)
274274

@@ -1112,9 +1112,7 @@ def add_cleanup(self, func: Callable[[], None]) -> None:
11121112
def _do_configure(self) -> None:
11131113
assert not self._configured
11141114
self._configured = True
1115-
with warnings.catch_warnings():
1116-
warnings.simplefilter("default")
1117-
self.hook.pytest_configure.call_historic(kwargs=dict(config=self))
1115+
self.hook.pytest_configure.call_historic(kwargs=dict(config=self))
11181116

11191117
def _ensure_unconfigure(self) -> None:
11201118
try:

src/_pytest/pytester.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
from _pytest.reports import CollectReport
6666
from _pytest.reports import TestReport
6767
from _pytest.tmpdir import TempPathFactory
68-
from _pytest.warning_types import PytestWarning
68+
from _pytest.warning_types import PytestFDWarning
6969

7070

7171
if TYPE_CHECKING:
@@ -188,7 +188,7 @@ def pytest_runtest_protocol(self, item: Item) -> Generator[None, object, object]
188188
"*** function {}:{}: {} ".format(*item.location),
189189
"See issue #2366",
190190
]
191-
item.warn(PytestWarning("\n".join(error)))
191+
item.warn(PytestFDWarning("\n".join(error)))
192192

193193

194194
# used at least by pytest-xdist plugin

src/_pytest/warning_types.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ def format(self, **kwargs: Any) -> _W:
123123
return self.category(self.template.format(**kwargs))
124124

125125

126+
@final
127+
class PytestFDWarning(PytestWarning):
128+
"""When the lsof plugin finds leaked fds."""
129+
130+
__module__ = "pytest"
131+
132+
126133
def warn_explicit_for(method: FunctionType, message: PytestWarning) -> None:
127134
"""
128135
Issue the warning :param:`message` for the definition of the given :param:`method`

src/_pytest/warnings.py

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from collections.abc import Generator
55
from contextlib import contextmanager
6+
from contextlib import ExitStack
67
import sys
78
from typing import Literal
89
import warnings
@@ -17,20 +18,14 @@
1718
import pytest
1819

1920

20-
def pytest_configure(config: Config) -> None:
21-
config.addinivalue_line(
22-
"markers",
23-
"filterwarnings(warning): add a warning filter to the given test. "
24-
"see https://docs.pytest.org/en/stable/how-to/capture-warnings.html#pytest-mark-filterwarnings ",
25-
)
26-
27-
2821
@contextmanager
2922
def catch_warnings_for_item(
3023
config: Config,
3124
ihook,
3225
when: Literal["config", "collect", "runtest"],
3326
item: Item | None,
27+
*,
28+
record: bool = True,
3429
) -> Generator[None]:
3530
"""Context manager that catches warnings generated in the contained execution block.
3631
@@ -40,10 +35,7 @@ def catch_warnings_for_item(
4035
"""
4136
config_filters = config.getini("filterwarnings")
4237
cmdline_filters = config.known_args_namespace.pythonwarnings or []
43-
with warnings.catch_warnings(record=True) as log:
44-
# mypy can't infer that record=True means log is not None; help it.
45-
assert log is not None
46-
38+
with warnings.catch_warnings(record=record) as log:
4739
if not sys.warnoptions:
4840
# If user is not explicitly configuring warning filters, show deprecation warnings by default (#2908).
4941
warnings.filterwarnings("always", category=DeprecationWarning)
@@ -64,15 +56,19 @@ def catch_warnings_for_item(
6456
try:
6557
yield
6658
finally:
67-
for warning_message in log:
68-
ihook.pytest_warning_recorded.call_historic(
69-
kwargs=dict(
70-
warning_message=warning_message,
71-
nodeid=nodeid,
72-
when=when,
73-
location=None,
59+
if record:
60+
# mypy can't infer that record=True means log is not None; help it.
61+
assert log is not None
62+
63+
for warning_message in log:
64+
ihook.pytest_warning_recorded.call_historic(
65+
kwargs=dict(
66+
warning_message=warning_message,
67+
nodeid=nodeid,
68+
when=when,
69+
location=None,
70+
)
7471
)
75-
)
7672

7773

7874
def warning_record_to_str(warning_message: warnings.WarningMessage) -> str:
@@ -131,3 +127,26 @@ def pytest_load_initial_conftests(
131127
config=early_config, ihook=early_config.hook, when="config", item=None
132128
):
133129
return (yield)
130+
131+
132+
def pytest_configure(config: Config) -> None:
133+
with ExitStack() as stack:
134+
stack.enter_context(
135+
catch_warnings_for_item(
136+
config=config,
137+
ihook=config.hook,
138+
when="config",
139+
item=None,
140+
# this disables recording because the terminalreporter has
141+
# finished by the time it comes to reporting logged warnings
142+
# from the end of config cleanup. So for now, this is only
143+
# useful for setting a warning filter with an 'error' action.
144+
record=False,
145+
)
146+
)
147+
config.addinivalue_line(
148+
"markers",
149+
"filterwarnings(warning): add a warning filter to the given test. "
150+
"see https://docs.pytest.org/en/stable/how-to/capture-warnings.html#pytest-mark-filterwarnings ",
151+
)
152+
config.add_cleanup(stack.pop_all().close)

src/pytest/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
from _pytest.warning_types import PytestConfigWarning
7878
from _pytest.warning_types import PytestDeprecationWarning
7979
from _pytest.warning_types import PytestExperimentalApiWarning
80+
from _pytest.warning_types import PytestFDWarning
8081
from _pytest.warning_types import PytestRemovedIn9Warning
8182
from _pytest.warning_types import PytestUnhandledThreadExceptionWarning
8283
from _pytest.warning_types import PytestUnknownMarkWarning
@@ -124,6 +125,7 @@
124125
"PytestConfigWarning",
125126
"PytestDeprecationWarning",
126127
"PytestExperimentalApiWarning",
128+
"PytestFDWarning",
127129
"PytestPluginManager",
128130
"PytestRemovedIn9Warning",
129131
"PytestUnhandledThreadExceptionWarning",

testing/test_unraisableexception.py

Lines changed: 117 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
from collections.abc import Generator
4+
import contextlib
35
import gc
46
import sys
57
from unittest import mock
@@ -203,7 +205,25 @@ class MyError(BaseException):
203205
)
204206

205207

206-
def test_create_task_unraisable(pytester: Pytester) -> None:
208+
def _set_gc_state(enabled: bool) -> bool:
209+
was_enabled = gc.isenabled()
210+
if enabled:
211+
gc.enable()
212+
else:
213+
gc.disable()
214+
return was_enabled
215+
216+
217+
@contextlib.contextmanager
218+
def _disable_gc() -> Generator[None]:
219+
was_enabled = _set_gc_state(enabled=False)
220+
try:
221+
yield
222+
finally:
223+
_set_gc_state(enabled=was_enabled)
224+
225+
226+
def test_refcycle_unraisable(pytester: Pytester) -> None:
207227
# see: https://github.com/pytest-dev/pytest/issues/10404
208228
pytester.makepyfile(
209229
test_it="""
@@ -221,13 +241,8 @@ def test_it():
221241
"""
222242
)
223243

224-
was_enabled = gc.isenabled()
225-
gc.disable()
226-
try:
244+
with _disable_gc():
227245
result = pytester.runpytest()
228-
finally:
229-
if was_enabled:
230-
gc.enable()
231246

232247
# TODO: should be a test failure or error
233248
assert result.ret == pytest.ExitCode.INTERNAL_ERROR
@@ -236,6 +251,101 @@ def test_it():
236251
result.stderr.fnmatch_lines("ValueError: del is broken")
237252

238253

254+
@pytest.mark.filterwarnings("default::pytest.PytestUnraisableExceptionWarning")
255+
def test_refcycle_unraisable_warning_filter(pytester: Pytester) -> None:
256+
# note that the host pytest warning filter is disabled and the pytester
257+
# warning filter applies during config teardown of unraisablehook.
258+
# see: https://github.com/pytest-dev/pytest/issues/10404
259+
pytester.makepyfile(
260+
test_it="""
261+
import pytest
262+
263+
class BrokenDel:
264+
def __init__(self):
265+
self.self = self # make a reference cycle
266+
267+
def __del__(self):
268+
raise ValueError("del is broken")
269+
270+
def test_it():
271+
BrokenDel()
272+
"""
273+
)
274+
275+
with _disable_gc():
276+
result = pytester.runpytest("-Werror")
277+
278+
# TODO: should be a test failure or error
279+
assert result.ret == pytest.ExitCode.INTERNAL_ERROR
280+
281+
result.assert_outcomes(passed=1)
282+
result.stderr.fnmatch_lines("ValueError: del is broken")
283+
284+
285+
@pytest.mark.filterwarnings("default::pytest.PytestUnraisableExceptionWarning")
286+
def test_create_task_raises_unraisable_warning_filter(pytester: Pytester) -> None:
287+
# note that the host pytest warning filter is disabled and the pytester
288+
# warning filter applies during config teardown of unraisablehook.
289+
# see: https://github.com/pytest-dev/pytest/issues/10404
290+
# This is a dupe of the above test, but using the exact reproducer from
291+
# the issue
292+
pytester.makepyfile(
293+
test_it="""
294+
import asyncio
295+
import pytest
296+
297+
async def my_task():
298+
pass
299+
300+
def test_scheduler_must_be_created_within_running_loop() -> None:
301+
with pytest.raises(RuntimeError) as _:
302+
asyncio.create_task(my_task())
303+
"""
304+
)
305+
306+
with _disable_gc():
307+
result = pytester.runpytest("-Werror")
308+
309+
# TODO: should be a test failure or error
310+
assert result.ret == pytest.ExitCode.INTERNAL_ERROR
311+
312+
result.assert_outcomes(passed=1)
313+
result.stderr.fnmatch_lines("RuntimeWarning: coroutine 'my_task' was never awaited")
314+
315+
316+
def test_refcycle_unraisable_warning_filter_default(pytester: Pytester) -> None:
317+
# note this time we use a default warning filter for pytester
318+
# and run it in a subprocess, because the warning can only go to the
319+
# sys.stdout rather than the terminal reporter, which has already
320+
# finished.
321+
# see: https://github.com/pytest-dev/pytest/pull/13057#discussion_r1888396126
322+
pytester.makepyfile(
323+
test_it="""
324+
import pytest
325+
326+
class BrokenDel:
327+
def __init__(self):
328+
self.self = self # make a reference cycle
329+
330+
def __del__(self):
331+
raise ValueError("del is broken")
332+
333+
def test_it():
334+
BrokenDel()
335+
"""
336+
)
337+
338+
with _disable_gc():
339+
result = pytester.runpytest_subprocess("-Wdefault")
340+
341+
assert result.ret == pytest.ExitCode.OK
342+
343+
# TODO: should be warnings=1, but the outcome has already come out
344+
# by the time the warning triggers
345+
result.assert_outcomes(passed=1)
346+
result.stderr.fnmatch_lines("ValueError: del is broken")
347+
348+
239349
@pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning")
240350
def test_possibly_none_excinfo(pytester: Pytester) -> None:
241351
pytester.makepyfile(

0 commit comments

Comments
 (0)