Skip to content

Commit 19e99ab

Browse files
nicoddemusbluetech
andauthored
Integrate warnings filtering directly into Config (#7700)
Warnings are a central part of Python, so much that Python itself has command-line and environtment variables to handle warnings. By moving the concept of warning handling into Config, it becomes natural to filter warnings issued as early as possible, even before the "_pytest.warnings" plugin is given a chance to spring into action. This also avoids the weird coupling between config and the warnings plugin that was required before. Fix #6681 Fix #2891 Fix #7620 Fix #7626 Close #7649 Co-authored-by: Ran Benita <[email protected]>
1 parent 91dbdb6 commit 19e99ab

File tree

8 files changed

+277
-146
lines changed

8 files changed

+277
-146
lines changed

changelog/6681.improvement.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Internal pytest warnings issued during the early stages of initialization are now properly handled and can filtered through :confval:`filterwarnings` or ``--pythonwarnings/-W``.
2+
3+
This also fixes a number of long standing issues: `#2891 <https://github.com/pytest-dev/pytest/issues/2891>`__, `#7620 <https://github.com/pytest-dev/pytest/issues/7620>`__, `#7426 <https://github.com/pytest-dev/pytest/issues/7426>`__.

src/_pytest/assertion/rewrite.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,13 +267,11 @@ def mark_rewrite(self, *names: str) -> None:
267267

268268
def _warn_already_imported(self, name: str) -> None:
269269
from _pytest.warning_types import PytestAssertRewriteWarning
270-
from _pytest.warnings import _issue_warning_captured
271270

272-
_issue_warning_captured(
271+
self.config.issue_config_time_warning(
273272
PytestAssertRewriteWarning(
274273
"Module already imported so cannot be rewritten: %s" % name
275274
),
276-
self.config.hook,
277275
stacklevel=5,
278276
)
279277

src/_pytest/config/__init__.py

Lines changed: 128 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import enum
77
import inspect
88
import os
9+
import re
910
import shlex
1011
import sys
1112
import types
@@ -15,6 +16,7 @@
1516
from typing import Any
1617
from typing import Callable
1718
from typing import Dict
19+
from typing import Generator
1820
from typing import IO
1921
from typing import Iterable
2022
from typing import Iterator
@@ -342,6 +344,13 @@ def __init__(self) -> None:
342344
self._noconftest = False
343345
self._duplicatepaths = set() # type: Set[py.path.local]
344346

347+
# plugins that were explicitly skipped with pytest.skip
348+
# list of (module name, skip reason)
349+
# previously we would issue a warning when a plugin was skipped, but
350+
# since we refactored warnings as first citizens of Config, they are
351+
# just stored here to be used later.
352+
self.skipped_plugins = [] # type: List[Tuple[str, str]]
353+
345354
self.add_hookspecs(_pytest.hookspec)
346355
self.register(self)
347356
if os.environ.get("PYTEST_DEBUG"):
@@ -694,13 +703,7 @@ def import_plugin(self, modname: str, consider_entry_points: bool = False) -> No
694703
).with_traceback(e.__traceback__) from e
695704

696705
except Skipped as e:
697-
from _pytest.warnings import _issue_warning_captured
698-
699-
_issue_warning_captured(
700-
PytestConfigWarning("skipped plugin {!r}: {}".format(modname, e.msg)),
701-
self.hook,
702-
stacklevel=2,
703-
)
706+
self.skipped_plugins.append((modname, e.msg or ""))
704707
else:
705708
mod = sys.modules[importspec]
706709
self.register(mod, modname)
@@ -1092,6 +1095,9 @@ def _preparse(self, args: List[str], addopts: bool = True) -> None:
10921095
self._validate_args(self.getini("addopts"), "via addopts config") + args
10931096
)
10941097

1098+
self.known_args_namespace = self._parser.parse_known_args(
1099+
args, namespace=copy.copy(self.option)
1100+
)
10951101
self._checkversion()
10961102
self._consider_importhook(args)
10971103
self.pluginmanager.consider_preparse(args, exclude_only=False)
@@ -1100,10 +1106,10 @@ def _preparse(self, args: List[str], addopts: bool = True) -> None:
11001106
# plugins are going to be loaded.
11011107
self.pluginmanager.load_setuptools_entrypoints("pytest11")
11021108
self.pluginmanager.consider_env()
1103-
self.known_args_namespace = ns = self._parser.parse_known_args(
1104-
args, namespace=copy.copy(self.option)
1105-
)
1109+
11061110
self._validate_plugins()
1111+
self._warn_about_skipped_plugins()
1112+
11071113
if self.known_args_namespace.confcutdir is None and self.inifile:
11081114
confcutdir = py.path.local(self.inifile).dirname
11091115
self.known_args_namespace.confcutdir = confcutdir
@@ -1112,21 +1118,24 @@ def _preparse(self, args: List[str], addopts: bool = True) -> None:
11121118
early_config=self, args=args, parser=self._parser
11131119
)
11141120
except ConftestImportFailure as e:
1115-
if ns.help or ns.version:
1121+
if self.known_args_namespace.help or self.known_args_namespace.version:
11161122
# we don't want to prevent --help/--version to work
11171123
# so just let is pass and print a warning at the end
1118-
from _pytest.warnings import _issue_warning_captured
1119-
1120-
_issue_warning_captured(
1124+
self.issue_config_time_warning(
11211125
PytestConfigWarning(
11221126
"could not load initial conftests: {}".format(e.path)
11231127
),
1124-
self.hook,
11251128
stacklevel=2,
11261129
)
11271130
else:
11281131
raise
1129-
self._validate_keys()
1132+
1133+
@hookimpl(hookwrapper=True)
1134+
def pytest_collection(self) -> Generator[None, None, None]:
1135+
"""Validate invalid ini keys after collection is done so we take in account
1136+
options added by late-loading conftest files."""
1137+
yield
1138+
self._validate_config_options()
11301139

11311140
def _checkversion(self) -> None:
11321141
import pytest
@@ -1147,9 +1156,9 @@ def _checkversion(self) -> None:
11471156
% (self.inifile, minver, pytest.__version__,)
11481157
)
11491158

1150-
def _validate_keys(self) -> None:
1159+
def _validate_config_options(self) -> None:
11511160
for key in sorted(self._get_unknown_ini_keys()):
1152-
self._warn_or_fail_if_strict("Unknown config ini key: {}\n".format(key))
1161+
self._warn_or_fail_if_strict("Unknown config option: {}\n".format(key))
11531162

11541163
def _validate_plugins(self) -> None:
11551164
required_plugins = sorted(self.getini("required_plugins"))
@@ -1165,7 +1174,6 @@ def _validate_plugins(self) -> None:
11651174

11661175
missing_plugins = []
11671176
for required_plugin in required_plugins:
1168-
spec = None
11691177
try:
11701178
spec = Requirement(required_plugin)
11711179
except InvalidRequirement:
@@ -1187,11 +1195,7 @@ def _warn_or_fail_if_strict(self, message: str) -> None:
11871195
if self.known_args_namespace.strict_config:
11881196
fail(message, pytrace=False)
11891197

1190-
from _pytest.warnings import _issue_warning_captured
1191-
1192-
_issue_warning_captured(
1193-
PytestConfigWarning(message), self.hook, stacklevel=3,
1194-
)
1198+
self.issue_config_time_warning(PytestConfigWarning(message), stacklevel=3)
11951199

11961200
def _get_unknown_ini_keys(self) -> List[str]:
11971201
parser_inicfg = self._parser._inidict
@@ -1222,6 +1226,49 @@ def parse(self, args: List[str], addopts: bool = True) -> None:
12221226
except PrintHelp:
12231227
pass
12241228

1229+
def issue_config_time_warning(self, warning: Warning, stacklevel: int) -> None:
1230+
"""Issue and handle a warning during the "configure" stage.
1231+
1232+
During ``pytest_configure`` we can't capture warnings using the ``catch_warnings_for_item``
1233+
function because it is not possible to have hookwrappers around ``pytest_configure``.
1234+
1235+
This function is mainly intended for plugins that need to issue warnings during
1236+
``pytest_configure`` (or similar stages).
1237+
1238+
:param warning: The warning instance.
1239+
:param stacklevel: stacklevel forwarded to warnings.warn.
1240+
"""
1241+
if self.pluginmanager.is_blocked("warnings"):
1242+
return
1243+
1244+
cmdline_filters = self.known_args_namespace.pythonwarnings or []
1245+
config_filters = self.getini("filterwarnings")
1246+
1247+
with warnings.catch_warnings(record=True) as records:
1248+
warnings.simplefilter("always", type(warning))
1249+
apply_warning_filters(config_filters, cmdline_filters)
1250+
warnings.warn(warning, stacklevel=stacklevel)
1251+
1252+
if records:
1253+
frame = sys._getframe(stacklevel - 1)
1254+
location = frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name
1255+
self.hook.pytest_warning_captured.call_historic(
1256+
kwargs=dict(
1257+
warning_message=records[0],
1258+
when="config",
1259+
item=None,
1260+
location=location,
1261+
)
1262+
)
1263+
self.hook.pytest_warning_recorded.call_historic(
1264+
kwargs=dict(
1265+
warning_message=records[0],
1266+
when="config",
1267+
nodeid="",
1268+
location=location,
1269+
)
1270+
)
1271+
12251272
def addinivalue_line(self, name: str, line: str) -> None:
12261273
"""Add a line to an ini-file option. The option must have been
12271274
declared but might not yet be set in which case the line becomes
@@ -1365,8 +1412,6 @@ def getvalueorskip(self, name: str, path=None):
13651412

13661413
def _warn_about_missing_assertion(self, mode: str) -> None:
13671414
if not _assertion_supported():
1368-
from _pytest.warnings import _issue_warning_captured
1369-
13701415
if mode == "plain":
13711416
warning_text = (
13721417
"ASSERTIONS ARE NOT EXECUTED"
@@ -1381,8 +1426,15 @@ def _warn_about_missing_assertion(self, mode: str) -> None:
13811426
"by the underlying Python interpreter "
13821427
"(are you using python -O?)\n"
13831428
)
1384-
_issue_warning_captured(
1385-
PytestConfigWarning(warning_text), self.hook, stacklevel=3,
1429+
self.issue_config_time_warning(
1430+
PytestConfigWarning(warning_text), stacklevel=3,
1431+
)
1432+
1433+
def _warn_about_skipped_plugins(self) -> None:
1434+
for module_name, msg in self.pluginmanager.skipped_plugins:
1435+
self.issue_config_time_warning(
1436+
PytestConfigWarning("skipped plugin {!r}: {}".format(module_name, msg)),
1437+
stacklevel=2,
13861438
)
13871439

13881440

@@ -1435,3 +1487,51 @@ def _strtobool(val: str) -> bool:
14351487
return False
14361488
else:
14371489
raise ValueError("invalid truth value {!r}".format(val))
1490+
1491+
1492+
@lru_cache(maxsize=50)
1493+
def parse_warning_filter(
1494+
arg: str, *, escape: bool
1495+
) -> "Tuple[str, str, Type[Warning], str, int]":
1496+
"""Parse a warnings filter string.
1497+
1498+
This is copied from warnings._setoption, but does not apply the filter,
1499+
only parses it, and makes the escaping optional.
1500+
"""
1501+
parts = arg.split(":")
1502+
if len(parts) > 5:
1503+
raise warnings._OptionError("too many fields (max 5): {!r}".format(arg))
1504+
while len(parts) < 5:
1505+
parts.append("")
1506+
action_, message, category_, module, lineno_ = [s.strip() for s in parts]
1507+
action = warnings._getaction(action_) # type: str # type: ignore[attr-defined]
1508+
category = warnings._getcategory(
1509+
category_
1510+
) # type: Type[Warning] # type: ignore[attr-defined]
1511+
if message and escape:
1512+
message = re.escape(message)
1513+
if module and escape:
1514+
module = re.escape(module) + r"\Z"
1515+
if lineno_:
1516+
try:
1517+
lineno = int(lineno_)
1518+
if lineno < 0:
1519+
raise ValueError
1520+
except (ValueError, OverflowError) as e:
1521+
raise warnings._OptionError("invalid lineno {!r}".format(lineno_)) from e
1522+
else:
1523+
lineno = 0
1524+
return action, message, category, module, lineno
1525+
1526+
1527+
def apply_warning_filters(
1528+
config_filters: Iterable[str], cmdline_filters: Iterable[str]
1529+
) -> None:
1530+
"""Applies pytest-configured filters to the warnings module"""
1531+
# Filters should have this precedence: cmdline options, config.
1532+
# Filters should be applied in the inverse order of precedence.
1533+
for arg in config_filters:
1534+
warnings.filterwarnings(*parse_warning_filter(arg, escape=False))
1535+
1536+
for arg in cmdline_filters:
1537+
warnings.filterwarnings(*parse_warning_filter(arg, escape=True))

src/_pytest/faulthandler.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,15 @@ def pytest_configure(config: Config) -> None:
3030
# of enabling faulthandler before each test executes.
3131
config.pluginmanager.register(FaultHandlerHooks(), "faulthandler-hooks")
3232
else:
33-
from _pytest.warnings import _issue_warning_captured
34-
3533
# Do not handle dumping to stderr if faulthandler is already enabled, so warn
3634
# users that the option is being ignored.
3735
timeout = FaultHandlerHooks.get_timeout_config_value(config)
3836
if timeout > 0:
39-
_issue_warning_captured(
37+
config.issue_config_time_warning(
4038
pytest.PytestConfigWarning(
4139
"faulthandler module enabled before pytest configuration step, "
4240
"'faulthandler_timeout' option ignored"
4341
),
44-
config.hook,
4542
stacklevel=2,
4643
)
4744

src/_pytest/main.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,20 @@ def pytest_addoption(parser: Parser) -> None:
6969
const=1,
7070
help="exit instantly on first error or failed test.",
7171
)
72+
group = parser.getgroup("pytest-warnings")
73+
group.addoption(
74+
"-W",
75+
"--pythonwarnings",
76+
action="append",
77+
help="set which warnings to report, see -W option of python itself.",
78+
)
79+
parser.addini(
80+
"filterwarnings",
81+
type="linelist",
82+
help="Each line specifies a pattern for "
83+
"warnings.filterwarnings. "
84+
"Processed after -W/--pythonwarnings.",
85+
)
7286
group._addoption(
7387
"--maxfail",
7488
metavar="num",

0 commit comments

Comments
 (0)