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 <ran@unusedvar.com>
This commit is contained in:
		
							parent
							
								
									91dbdb6093
								
							
						
					
					
						commit
						19e99ab413
					
				|  | @ -0,0 +1,3 @@ | ||||||
|  | Internal pytest warnings issued during the early stages of initialization are now properly handled and can filtered through :confval:`filterwarnings` or ``--pythonwarnings/-W``. | ||||||
|  | 
 | ||||||
|  | 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>`__. | ||||||
|  | @ -267,13 +267,11 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader) | ||||||
| 
 | 
 | ||||||
|     def _warn_already_imported(self, name: str) -> None: |     def _warn_already_imported(self, name: str) -> None: | ||||||
|         from _pytest.warning_types import PytestAssertRewriteWarning |         from _pytest.warning_types import PytestAssertRewriteWarning | ||||||
|         from _pytest.warnings import _issue_warning_captured |  | ||||||
| 
 | 
 | ||||||
|         _issue_warning_captured( |         self.config.issue_config_time_warning( | ||||||
|             PytestAssertRewriteWarning( |             PytestAssertRewriteWarning( | ||||||
|                 "Module already imported so cannot be rewritten: %s" % name |                 "Module already imported so cannot be rewritten: %s" % name | ||||||
|             ), |             ), | ||||||
|             self.config.hook, |  | ||||||
|             stacklevel=5, |             stacklevel=5, | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -6,6 +6,7 @@ import copy | ||||||
| import enum | import enum | ||||||
| import inspect | import inspect | ||||||
| import os | import os | ||||||
|  | import re | ||||||
| import shlex | import shlex | ||||||
| import sys | import sys | ||||||
| import types | import types | ||||||
|  | @ -15,6 +16,7 @@ from types import TracebackType | ||||||
| from typing import Any | from typing import Any | ||||||
| from typing import Callable | from typing import Callable | ||||||
| from typing import Dict | from typing import Dict | ||||||
|  | from typing import Generator | ||||||
| from typing import IO | from typing import IO | ||||||
| from typing import Iterable | from typing import Iterable | ||||||
| from typing import Iterator | from typing import Iterator | ||||||
|  | @ -342,6 +344,13 @@ class PytestPluginManager(PluginManager): | ||||||
|         self._noconftest = False |         self._noconftest = False | ||||||
|         self._duplicatepaths = set()  # type: Set[py.path.local] |         self._duplicatepaths = set()  # type: Set[py.path.local] | ||||||
| 
 | 
 | ||||||
|  |         # plugins that were explicitly skipped with pytest.skip | ||||||
|  |         # list of (module name, skip reason) | ||||||
|  |         # previously we would issue a warning when a plugin was skipped, but | ||||||
|  |         # since we refactored warnings as first citizens of Config, they are | ||||||
|  |         # just stored here to be used later. | ||||||
|  |         self.skipped_plugins = []  # type: List[Tuple[str, str]] | ||||||
|  | 
 | ||||||
|         self.add_hookspecs(_pytest.hookspec) |         self.add_hookspecs(_pytest.hookspec) | ||||||
|         self.register(self) |         self.register(self) | ||||||
|         if os.environ.get("PYTEST_DEBUG"): |         if os.environ.get("PYTEST_DEBUG"): | ||||||
|  | @ -694,13 +703,7 @@ class PytestPluginManager(PluginManager): | ||||||
|             ).with_traceback(e.__traceback__) from e |             ).with_traceback(e.__traceback__) from e | ||||||
| 
 | 
 | ||||||
|         except Skipped as e: |         except Skipped as e: | ||||||
|             from _pytest.warnings import _issue_warning_captured |             self.skipped_plugins.append((modname, e.msg or "")) | ||||||
| 
 |  | ||||||
|             _issue_warning_captured( |  | ||||||
|                 PytestConfigWarning("skipped plugin {!r}: {}".format(modname, e.msg)), |  | ||||||
|                 self.hook, |  | ||||||
|                 stacklevel=2, |  | ||||||
|             ) |  | ||||||
|         else: |         else: | ||||||
|             mod = sys.modules[importspec] |             mod = sys.modules[importspec] | ||||||
|             self.register(mod, modname) |             self.register(mod, modname) | ||||||
|  | @ -1092,6 +1095,9 @@ class Config: | ||||||
|                 self._validate_args(self.getini("addopts"), "via addopts config") + args |                 self._validate_args(self.getini("addopts"), "via addopts config") + args | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|  |         self.known_args_namespace = self._parser.parse_known_args( | ||||||
|  |             args, namespace=copy.copy(self.option) | ||||||
|  |         ) | ||||||
|         self._checkversion() |         self._checkversion() | ||||||
|         self._consider_importhook(args) |         self._consider_importhook(args) | ||||||
|         self.pluginmanager.consider_preparse(args, exclude_only=False) |         self.pluginmanager.consider_preparse(args, exclude_only=False) | ||||||
|  | @ -1100,10 +1106,10 @@ class Config: | ||||||
|             # plugins are going to be loaded. |             # plugins are going to be loaded. | ||||||
|             self.pluginmanager.load_setuptools_entrypoints("pytest11") |             self.pluginmanager.load_setuptools_entrypoints("pytest11") | ||||||
|         self.pluginmanager.consider_env() |         self.pluginmanager.consider_env() | ||||||
|         self.known_args_namespace = ns = self._parser.parse_known_args( | 
 | ||||||
|             args, namespace=copy.copy(self.option) |  | ||||||
|         ) |  | ||||||
|         self._validate_plugins() |         self._validate_plugins() | ||||||
|  |         self._warn_about_skipped_plugins() | ||||||
|  | 
 | ||||||
|         if self.known_args_namespace.confcutdir is None and self.inifile: |         if self.known_args_namespace.confcutdir is None and self.inifile: | ||||||
|             confcutdir = py.path.local(self.inifile).dirname |             confcutdir = py.path.local(self.inifile).dirname | ||||||
|             self.known_args_namespace.confcutdir = confcutdir |             self.known_args_namespace.confcutdir = confcutdir | ||||||
|  | @ -1112,21 +1118,24 @@ class Config: | ||||||
|                 early_config=self, args=args, parser=self._parser |                 early_config=self, args=args, parser=self._parser | ||||||
|             ) |             ) | ||||||
|         except ConftestImportFailure as e: |         except ConftestImportFailure as e: | ||||||
|             if ns.help or ns.version: |             if self.known_args_namespace.help or self.known_args_namespace.version: | ||||||
|                 # we don't want to prevent --help/--version to work |                 # we don't want to prevent --help/--version to work | ||||||
|                 # so just let is pass and print a warning at the end |                 # so just let is pass and print a warning at the end | ||||||
|                 from _pytest.warnings import _issue_warning_captured |                 self.issue_config_time_warning( | ||||||
| 
 |  | ||||||
|                 _issue_warning_captured( |  | ||||||
|                     PytestConfigWarning( |                     PytestConfigWarning( | ||||||
|                         "could not load initial conftests: {}".format(e.path) |                         "could not load initial conftests: {}".format(e.path) | ||||||
|                     ), |                     ), | ||||||
|                     self.hook, |  | ||||||
|                     stacklevel=2, |                     stacklevel=2, | ||||||
|                 ) |                 ) | ||||||
|             else: |             else: | ||||||
|                 raise |                 raise | ||||||
|         self._validate_keys() | 
 | ||||||
|  |     @hookimpl(hookwrapper=True) | ||||||
|  |     def pytest_collection(self) -> Generator[None, None, None]: | ||||||
|  |         """Validate invalid ini keys after collection is done so we take in account | ||||||
|  |         options added by late-loading conftest files.""" | ||||||
|  |         yield | ||||||
|  |         self._validate_config_options() | ||||||
| 
 | 
 | ||||||
|     def _checkversion(self) -> None: |     def _checkversion(self) -> None: | ||||||
|         import pytest |         import pytest | ||||||
|  | @ -1147,9 +1156,9 @@ class Config: | ||||||
|                     % (self.inifile, minver, pytest.__version__,) |                     % (self.inifile, minver, pytest.__version__,) | ||||||
|                 ) |                 ) | ||||||
| 
 | 
 | ||||||
|     def _validate_keys(self) -> None: |     def _validate_config_options(self) -> None: | ||||||
|         for key in sorted(self._get_unknown_ini_keys()): |         for key in sorted(self._get_unknown_ini_keys()): | ||||||
|             self._warn_or_fail_if_strict("Unknown config ini key: {}\n".format(key)) |             self._warn_or_fail_if_strict("Unknown config option: {}\n".format(key)) | ||||||
| 
 | 
 | ||||||
|     def _validate_plugins(self) -> None: |     def _validate_plugins(self) -> None: | ||||||
|         required_plugins = sorted(self.getini("required_plugins")) |         required_plugins = sorted(self.getini("required_plugins")) | ||||||
|  | @ -1165,7 +1174,6 @@ class Config: | ||||||
| 
 | 
 | ||||||
|         missing_plugins = [] |         missing_plugins = [] | ||||||
|         for required_plugin in required_plugins: |         for required_plugin in required_plugins: | ||||||
|             spec = None |  | ||||||
|             try: |             try: | ||||||
|                 spec = Requirement(required_plugin) |                 spec = Requirement(required_plugin) | ||||||
|             except InvalidRequirement: |             except InvalidRequirement: | ||||||
|  | @ -1187,11 +1195,7 @@ class Config: | ||||||
|         if self.known_args_namespace.strict_config: |         if self.known_args_namespace.strict_config: | ||||||
|             fail(message, pytrace=False) |             fail(message, pytrace=False) | ||||||
| 
 | 
 | ||||||
|         from _pytest.warnings import _issue_warning_captured |         self.issue_config_time_warning(PytestConfigWarning(message), stacklevel=3) | ||||||
| 
 |  | ||||||
|         _issue_warning_captured( |  | ||||||
|             PytestConfigWarning(message), self.hook, stacklevel=3, |  | ||||||
|         ) |  | ||||||
| 
 | 
 | ||||||
|     def _get_unknown_ini_keys(self) -> List[str]: |     def _get_unknown_ini_keys(self) -> List[str]: | ||||||
|         parser_inicfg = self._parser._inidict |         parser_inicfg = self._parser._inidict | ||||||
|  | @ -1222,6 +1226,49 @@ class Config: | ||||||
|         except PrintHelp: |         except PrintHelp: | ||||||
|             pass |             pass | ||||||
| 
 | 
 | ||||||
|  |     def issue_config_time_warning(self, warning: Warning, stacklevel: int) -> None: | ||||||
|  |         """Issue and handle a warning during the "configure" stage. | ||||||
|  | 
 | ||||||
|  |         During ``pytest_configure`` we can't capture warnings using the ``catch_warnings_for_item`` | ||||||
|  |         function because it is not possible to have hookwrappers around ``pytest_configure``. | ||||||
|  | 
 | ||||||
|  |         This function is mainly intended for plugins that need to issue warnings during | ||||||
|  |         ``pytest_configure`` (or similar stages). | ||||||
|  | 
 | ||||||
|  |         :param warning: The warning instance. | ||||||
|  |         :param stacklevel: stacklevel forwarded to warnings.warn. | ||||||
|  |         """ | ||||||
|  |         if self.pluginmanager.is_blocked("warnings"): | ||||||
|  |             return | ||||||
|  | 
 | ||||||
|  |         cmdline_filters = self.known_args_namespace.pythonwarnings or [] | ||||||
|  |         config_filters = self.getini("filterwarnings") | ||||||
|  | 
 | ||||||
|  |         with warnings.catch_warnings(record=True) as records: | ||||||
|  |             warnings.simplefilter("always", type(warning)) | ||||||
|  |             apply_warning_filters(config_filters, cmdline_filters) | ||||||
|  |             warnings.warn(warning, stacklevel=stacklevel) | ||||||
|  | 
 | ||||||
|  |         if records: | ||||||
|  |             frame = sys._getframe(stacklevel - 1) | ||||||
|  |             location = frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name | ||||||
|  |             self.hook.pytest_warning_captured.call_historic( | ||||||
|  |                 kwargs=dict( | ||||||
|  |                     warning_message=records[0], | ||||||
|  |                     when="config", | ||||||
|  |                     item=None, | ||||||
|  |                     location=location, | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |             self.hook.pytest_warning_recorded.call_historic( | ||||||
|  |                 kwargs=dict( | ||||||
|  |                     warning_message=records[0], | ||||||
|  |                     when="config", | ||||||
|  |                     nodeid="", | ||||||
|  |                     location=location, | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|     def addinivalue_line(self, name: str, line: str) -> None: |     def addinivalue_line(self, name: str, line: str) -> None: | ||||||
|         """Add a line to an ini-file option. The option must have been |         """Add a line to an ini-file option. The option must have been | ||||||
|         declared but might not yet be set in which case the line becomes |         declared but might not yet be set in which case the line becomes | ||||||
|  | @ -1365,8 +1412,6 @@ class Config: | ||||||
| 
 | 
 | ||||||
|     def _warn_about_missing_assertion(self, mode: str) -> None: |     def _warn_about_missing_assertion(self, mode: str) -> None: | ||||||
|         if not _assertion_supported(): |         if not _assertion_supported(): | ||||||
|             from _pytest.warnings import _issue_warning_captured |  | ||||||
| 
 |  | ||||||
|             if mode == "plain": |             if mode == "plain": | ||||||
|                 warning_text = ( |                 warning_text = ( | ||||||
|                     "ASSERTIONS ARE NOT EXECUTED" |                     "ASSERTIONS ARE NOT EXECUTED" | ||||||
|  | @ -1381,8 +1426,15 @@ class Config: | ||||||
|                     "by the underlying Python interpreter " |                     "by the underlying Python interpreter " | ||||||
|                     "(are you using python -O?)\n" |                     "(are you using python -O?)\n" | ||||||
|                 ) |                 ) | ||||||
|             _issue_warning_captured( |             self.issue_config_time_warning( | ||||||
|                 PytestConfigWarning(warning_text), self.hook, stacklevel=3, |                 PytestConfigWarning(warning_text), stacklevel=3, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |     def _warn_about_skipped_plugins(self) -> None: | ||||||
|  |         for module_name, msg in self.pluginmanager.skipped_plugins: | ||||||
|  |             self.issue_config_time_warning( | ||||||
|  |                 PytestConfigWarning("skipped plugin {!r}: {}".format(module_name, msg)), | ||||||
|  |                 stacklevel=2, | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -1435,3 +1487,51 @@ def _strtobool(val: str) -> bool: | ||||||
|         return False |         return False | ||||||
|     else: |     else: | ||||||
|         raise ValueError("invalid truth value {!r}".format(val)) |         raise ValueError("invalid truth value {!r}".format(val)) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @lru_cache(maxsize=50) | ||||||
|  | def parse_warning_filter( | ||||||
|  |     arg: str, *, escape: bool | ||||||
|  | ) -> "Tuple[str, str, Type[Warning], str, int]": | ||||||
|  |     """Parse a warnings filter string. | ||||||
|  | 
 | ||||||
|  |     This is copied from warnings._setoption, but does not apply the filter, | ||||||
|  |     only parses it, and makes the escaping optional. | ||||||
|  |     """ | ||||||
|  |     parts = arg.split(":") | ||||||
|  |     if len(parts) > 5: | ||||||
|  |         raise warnings._OptionError("too many fields (max 5): {!r}".format(arg)) | ||||||
|  |     while len(parts) < 5: | ||||||
|  |         parts.append("") | ||||||
|  |     action_, message, category_, module, lineno_ = [s.strip() for s in parts] | ||||||
|  |     action = warnings._getaction(action_)  # type: str # type: ignore[attr-defined] | ||||||
|  |     category = warnings._getcategory( | ||||||
|  |         category_ | ||||||
|  |     )  # type: Type[Warning] # type: ignore[attr-defined] | ||||||
|  |     if message and escape: | ||||||
|  |         message = re.escape(message) | ||||||
|  |     if module and escape: | ||||||
|  |         module = re.escape(module) + r"\Z" | ||||||
|  |     if lineno_: | ||||||
|  |         try: | ||||||
|  |             lineno = int(lineno_) | ||||||
|  |             if lineno < 0: | ||||||
|  |                 raise ValueError | ||||||
|  |         except (ValueError, OverflowError) as e: | ||||||
|  |             raise warnings._OptionError("invalid lineno {!r}".format(lineno_)) from e | ||||||
|  |     else: | ||||||
|  |         lineno = 0 | ||||||
|  |     return action, message, category, module, lineno | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def apply_warning_filters( | ||||||
|  |     config_filters: Iterable[str], cmdline_filters: Iterable[str] | ||||||
|  | ) -> None: | ||||||
|  |     """Applies pytest-configured filters to the warnings module""" | ||||||
|  |     # Filters should have this precedence: cmdline options, config. | ||||||
|  |     # Filters should be applied in the inverse order of precedence. | ||||||
|  |     for arg in config_filters: | ||||||
|  |         warnings.filterwarnings(*parse_warning_filter(arg, escape=False)) | ||||||
|  | 
 | ||||||
|  |     for arg in cmdline_filters: | ||||||
|  |         warnings.filterwarnings(*parse_warning_filter(arg, escape=True)) | ||||||
|  |  | ||||||
|  | @ -30,18 +30,15 @@ def pytest_configure(config: Config) -> None: | ||||||
|         # of enabling faulthandler before each test executes. |         # of enabling faulthandler before each test executes. | ||||||
|         config.pluginmanager.register(FaultHandlerHooks(), "faulthandler-hooks") |         config.pluginmanager.register(FaultHandlerHooks(), "faulthandler-hooks") | ||||||
|     else: |     else: | ||||||
|         from _pytest.warnings import _issue_warning_captured |  | ||||||
| 
 |  | ||||||
|         # Do not handle dumping to stderr if faulthandler is already enabled, so warn |         # Do not handle dumping to stderr if faulthandler is already enabled, so warn | ||||||
|         # users that the option is being ignored. |         # users that the option is being ignored. | ||||||
|         timeout = FaultHandlerHooks.get_timeout_config_value(config) |         timeout = FaultHandlerHooks.get_timeout_config_value(config) | ||||||
|         if timeout > 0: |         if timeout > 0: | ||||||
|             _issue_warning_captured( |             config.issue_config_time_warning( | ||||||
|                 pytest.PytestConfigWarning( |                 pytest.PytestConfigWarning( | ||||||
|                     "faulthandler module enabled before pytest configuration step, " |                     "faulthandler module enabled before pytest configuration step, " | ||||||
|                     "'faulthandler_timeout' option ignored" |                     "'faulthandler_timeout' option ignored" | ||||||
|                 ), |                 ), | ||||||
|                 config.hook, |  | ||||||
|                 stacklevel=2, |                 stacklevel=2, | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -69,6 +69,20 @@ def pytest_addoption(parser: Parser) -> None: | ||||||
|         const=1, |         const=1, | ||||||
|         help="exit instantly on first error or failed test.", |         help="exit instantly on first error or failed test.", | ||||||
|     ) |     ) | ||||||
|  |     group = parser.getgroup("pytest-warnings") | ||||||
|  |     group.addoption( | ||||||
|  |         "-W", | ||||||
|  |         "--pythonwarnings", | ||||||
|  |         action="append", | ||||||
|  |         help="set which warnings to report, see -W option of python itself.", | ||||||
|  |     ) | ||||||
|  |     parser.addini( | ||||||
|  |         "filterwarnings", | ||||||
|  |         type="linelist", | ||||||
|  |         help="Each line specifies a pattern for " | ||||||
|  |         "warnings.filterwarnings. " | ||||||
|  |         "Processed after -W/--pythonwarnings.", | ||||||
|  |     ) | ||||||
|     group._addoption( |     group._addoption( | ||||||
|         "--maxfail", |         "--maxfail", | ||||||
|         metavar="num", |         metavar="num", | ||||||
|  |  | ||||||
|  | @ -1,77 +1,22 @@ | ||||||
| import re |  | ||||||
| import sys | import sys | ||||||
| import warnings | import warnings | ||||||
| from contextlib import contextmanager | from contextlib import contextmanager | ||||||
| from functools import lru_cache |  | ||||||
| from typing import Generator | from typing import Generator | ||||||
| from typing import Optional | from typing import Optional | ||||||
| from typing import Tuple |  | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| from _pytest.compat import TYPE_CHECKING | from _pytest.compat import TYPE_CHECKING | ||||||
|  | from _pytest.config import apply_warning_filters | ||||||
| from _pytest.config import Config | from _pytest.config import Config | ||||||
| from _pytest.config.argparsing import Parser | from _pytest.config import parse_warning_filter | ||||||
| from _pytest.main import Session | from _pytest.main import Session | ||||||
| from _pytest.nodes import Item | from _pytest.nodes import Item | ||||||
| from _pytest.terminal import TerminalReporter | from _pytest.terminal import TerminalReporter | ||||||
| 
 | 
 | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|     from typing import Type |  | ||||||
|     from typing_extensions import Literal |     from typing_extensions import Literal | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @lru_cache(maxsize=50) |  | ||||||
| def _parse_filter( |  | ||||||
|     arg: str, *, escape: bool |  | ||||||
| ) -> "Tuple[str, str, Type[Warning], str, int]": |  | ||||||
|     """Parse a warnings filter string. |  | ||||||
| 
 |  | ||||||
|     This is copied from warnings._setoption, but does not apply the filter, |  | ||||||
|     only parses it, and makes the escaping optional. |  | ||||||
|     """ |  | ||||||
|     parts = arg.split(":") |  | ||||||
|     if len(parts) > 5: |  | ||||||
|         raise warnings._OptionError("too many fields (max 5): {!r}".format(arg)) |  | ||||||
|     while len(parts) < 5: |  | ||||||
|         parts.append("") |  | ||||||
|     action_, message, category_, module, lineno_ = [s.strip() for s in parts] |  | ||||||
|     action = warnings._getaction(action_)  # type: str # type: ignore[attr-defined] |  | ||||||
|     category = warnings._getcategory( |  | ||||||
|         category_ |  | ||||||
|     )  # type: Type[Warning] # type: ignore[attr-defined] |  | ||||||
|     if message and escape: |  | ||||||
|         message = re.escape(message) |  | ||||||
|     if module and escape: |  | ||||||
|         module = re.escape(module) + r"\Z" |  | ||||||
|     if lineno_: |  | ||||||
|         try: |  | ||||||
|             lineno = int(lineno_) |  | ||||||
|             if lineno < 0: |  | ||||||
|                 raise ValueError |  | ||||||
|         except (ValueError, OverflowError) as e: |  | ||||||
|             raise warnings._OptionError("invalid lineno {!r}".format(lineno_)) from e |  | ||||||
|     else: |  | ||||||
|         lineno = 0 |  | ||||||
|     return (action, message, category, module, lineno) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def pytest_addoption(parser: Parser) -> None: |  | ||||||
|     group = parser.getgroup("pytest-warnings") |  | ||||||
|     group.addoption( |  | ||||||
|         "-W", |  | ||||||
|         "--pythonwarnings", |  | ||||||
|         action="append", |  | ||||||
|         help="set which warnings to report, see -W option of python itself.", |  | ||||||
|     ) |  | ||||||
|     parser.addini( |  | ||||||
|         "filterwarnings", |  | ||||||
|         type="linelist", |  | ||||||
|         help="Each line specifies a pattern for " |  | ||||||
|         "warnings.filterwarnings. " |  | ||||||
|         "Processed after -W/--pythonwarnings.", |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def pytest_configure(config: Config) -> None: | def pytest_configure(config: Config) -> None: | ||||||
|     config.addinivalue_line( |     config.addinivalue_line( | ||||||
|         "markers", |         "markers", | ||||||
|  | @ -93,8 +38,8 @@ def catch_warnings_for_item( | ||||||
| 
 | 
 | ||||||
|     Each warning captured triggers the ``pytest_warning_recorded`` hook. |     Each warning captured triggers the ``pytest_warning_recorded`` hook. | ||||||
|     """ |     """ | ||||||
|     cmdline_filters = config.getoption("pythonwarnings") or [] |     config_filters = config.getini("filterwarnings") | ||||||
|     inifilters = config.getini("filterwarnings") |     cmdline_filters = config.known_args_namespace.pythonwarnings or [] | ||||||
|     with warnings.catch_warnings(record=True) as log: |     with warnings.catch_warnings(record=True) as log: | ||||||
|         # mypy can't infer that record=True means log is not None; help it. |         # mypy can't infer that record=True means log is not None; help it. | ||||||
|         assert log is not None |         assert log is not None | ||||||
|  | @ -104,19 +49,14 @@ def catch_warnings_for_item( | ||||||
|             warnings.filterwarnings("always", category=DeprecationWarning) |             warnings.filterwarnings("always", category=DeprecationWarning) | ||||||
|             warnings.filterwarnings("always", category=PendingDeprecationWarning) |             warnings.filterwarnings("always", category=PendingDeprecationWarning) | ||||||
| 
 | 
 | ||||||
|         # Filters should have this precedence: mark, cmdline options, ini. |         apply_warning_filters(config_filters, cmdline_filters) | ||||||
|         # Filters should be applied in the inverse order of precedence. |  | ||||||
|         for arg in inifilters: |  | ||||||
|             warnings.filterwarnings(*_parse_filter(arg, escape=False)) |  | ||||||
| 
 |  | ||||||
|         for arg in cmdline_filters: |  | ||||||
|             warnings.filterwarnings(*_parse_filter(arg, escape=True)) |  | ||||||
| 
 | 
 | ||||||
|  |         # apply filters from "filterwarnings" marks | ||||||
|         nodeid = "" if item is None else item.nodeid |         nodeid = "" if item is None else item.nodeid | ||||||
|         if item is not None: |         if item is not None: | ||||||
|             for mark in item.iter_markers(name="filterwarnings"): |             for mark in item.iter_markers(name="filterwarnings"): | ||||||
|                 for arg in mark.args: |                 for arg in mark.args: | ||||||
|                     warnings.filterwarnings(*_parse_filter(arg, escape=False)) |                     warnings.filterwarnings(*parse_warning_filter(arg, escape=False)) | ||||||
| 
 | 
 | ||||||
|         yield |         yield | ||||||
| 
 | 
 | ||||||
|  | @ -189,30 +129,11 @@ def pytest_sessionfinish(session: Session) -> Generator[None, None, None]: | ||||||
|         yield |         yield | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def _issue_warning_captured(warning: Warning, hook, stacklevel: int) -> None: | @pytest.hookimpl(hookwrapper=True) | ||||||
|     """A function that should be used instead of calling ``warnings.warn`` | def pytest_load_initial_conftests( | ||||||
|     directly when we are in the "configure" stage. |     early_config: "Config", | ||||||
| 
 | ) -> Generator[None, None, None]: | ||||||
|     At this point the actual options might not have been set, so we manually |     with catch_warnings_for_item( | ||||||
|     trigger the pytest_warning_recorded hook so we can display these warnings |         config=early_config, ihook=early_config.hook, when="config", item=None | ||||||
|     in the terminal. This is a hack until we can sort out #2891. |     ): | ||||||
| 
 |         yield | ||||||
|     :param warning: The warning instance. |  | ||||||
|     :param hook: The hook caller. |  | ||||||
|     :param stacklevel: stacklevel forwarded to warnings.warn. |  | ||||||
|     """ |  | ||||||
|     with warnings.catch_warnings(record=True) as records: |  | ||||||
|         warnings.simplefilter("always", type(warning)) |  | ||||||
|         warnings.warn(warning, stacklevel=stacklevel) |  | ||||||
|     frame = sys._getframe(stacklevel - 1) |  | ||||||
|     location = frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name |  | ||||||
|     hook.pytest_warning_captured.call_historic( |  | ||||||
|         kwargs=dict( |  | ||||||
|             warning_message=records[0], when="config", item=None, location=location |  | ||||||
|         ) |  | ||||||
|     ) |  | ||||||
|     hook.pytest_warning_recorded.call_historic( |  | ||||||
|         kwargs=dict( |  | ||||||
|             warning_message=records[0], when="config", nodeid="", location=location |  | ||||||
|         ) |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  | @ -5,6 +5,7 @@ import textwrap | ||||||
| from typing import Dict | from typing import Dict | ||||||
| from typing import List | from typing import List | ||||||
| from typing import Sequence | from typing import Sequence | ||||||
|  | from typing import Tuple | ||||||
| 
 | 
 | ||||||
| import attr | import attr | ||||||
| import py.path | import py.path | ||||||
|  | @ -12,11 +13,14 @@ import py.path | ||||||
| import _pytest._code | import _pytest._code | ||||||
| import pytest | import pytest | ||||||
| from _pytest.compat import importlib_metadata | from _pytest.compat import importlib_metadata | ||||||
|  | from _pytest.compat import TYPE_CHECKING | ||||||
| from _pytest.config import _get_plugin_specs_as_list | from _pytest.config import _get_plugin_specs_as_list | ||||||
| from _pytest.config import _iter_rewritable_modules | from _pytest.config import _iter_rewritable_modules | ||||||
|  | from _pytest.config import _strtobool | ||||||
| from _pytest.config import Config | from _pytest.config import Config | ||||||
| from _pytest.config import ConftestImportFailure | from _pytest.config import ConftestImportFailure | ||||||
| from _pytest.config import ExitCode | from _pytest.config import ExitCode | ||||||
|  | from _pytest.config import parse_warning_filter | ||||||
| from _pytest.config.exceptions import UsageError | from _pytest.config.exceptions import UsageError | ||||||
| from _pytest.config.findpaths import determine_setup | from _pytest.config.findpaths import determine_setup | ||||||
| from _pytest.config.findpaths import get_common_ancestor | from _pytest.config.findpaths import get_common_ancestor | ||||||
|  | @ -25,6 +29,9 @@ from _pytest.monkeypatch import MonkeyPatch | ||||||
| from _pytest.pathlib import Path | from _pytest.pathlib import Path | ||||||
| from _pytest.pytester import Testdir | from _pytest.pytester import Testdir | ||||||
| 
 | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from typing import Type | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class TestParseIni: | class TestParseIni: | ||||||
|     @pytest.mark.parametrize( |     @pytest.mark.parametrize( | ||||||
|  | @ -183,10 +190,10 @@ class TestParseIni: | ||||||
|                 ["unknown_ini", "another_unknown_ini"], |                 ["unknown_ini", "another_unknown_ini"], | ||||||
|                 [ |                 [ | ||||||
|                     "=*= warnings summary =*=", |                     "=*= warnings summary =*=", | ||||||
|                     "*PytestConfigWarning:*Unknown config ini key: another_unknown_ini", |                     "*PytestConfigWarning:*Unknown config option: another_unknown_ini", | ||||||
|                     "*PytestConfigWarning:*Unknown config ini key: unknown_ini", |                     "*PytestConfigWarning:*Unknown config option: unknown_ini", | ||||||
|                 ], |                 ], | ||||||
|                 "Unknown config ini key: another_unknown_ini", |                 "Unknown config option: another_unknown_ini", | ||||||
|             ), |             ), | ||||||
|             ( |             ( | ||||||
|                 """ |                 """ | ||||||
|  | @ -197,9 +204,9 @@ class TestParseIni: | ||||||
|                 ["unknown_ini"], |                 ["unknown_ini"], | ||||||
|                 [ |                 [ | ||||||
|                     "=*= warnings summary =*=", |                     "=*= warnings summary =*=", | ||||||
|                     "*PytestConfigWarning:*Unknown config ini key: unknown_ini", |                     "*PytestConfigWarning:*Unknown config option: unknown_ini", | ||||||
|                 ], |                 ], | ||||||
|                 "Unknown config ini key: unknown_ini", |                 "Unknown config option: unknown_ini", | ||||||
|             ), |             ), | ||||||
|             ( |             ( | ||||||
|                 """ |                 """ | ||||||
|  | @ -232,7 +239,8 @@ class TestParseIni: | ||||||
|             ), |             ), | ||||||
|         ], |         ], | ||||||
|     ) |     ) | ||||||
|     def test_invalid_ini_keys( |     @pytest.mark.filterwarnings("default") | ||||||
|  |     def test_invalid_config_options( | ||||||
|         self, testdir, ini_file_text, invalid_keys, warning_output, exception_text |         self, testdir, ini_file_text, invalid_keys, warning_output, exception_text | ||||||
|     ): |     ): | ||||||
|         testdir.makeconftest( |         testdir.makeconftest( | ||||||
|  | @ -250,10 +258,40 @@ class TestParseIni: | ||||||
|         result.stdout.fnmatch_lines(warning_output) |         result.stdout.fnmatch_lines(warning_output) | ||||||
| 
 | 
 | ||||||
|         if exception_text: |         if exception_text: | ||||||
|             with pytest.raises(pytest.fail.Exception, match=exception_text): |             result = testdir.runpytest("--strict-config") | ||||||
|                 testdir.runpytest("--strict-config") |             result.stdout.fnmatch_lines("INTERNALERROR>*" + exception_text) | ||||||
|         else: | 
 | ||||||
|             testdir.runpytest("--strict-config") |     @pytest.mark.filterwarnings("default") | ||||||
|  |     def test_silence_unknown_key_warning(self, testdir: Testdir) -> None: | ||||||
|  |         """Unknown config key warnings can be silenced using filterwarnings (#7620)""" | ||||||
|  |         testdir.makeini( | ||||||
|  |             """ | ||||||
|  |             [pytest] | ||||||
|  |             filterwarnings = | ||||||
|  |                 ignore:Unknown config option:pytest.PytestConfigWarning | ||||||
|  |             foobar=1 | ||||||
|  |         """ | ||||||
|  |         ) | ||||||
|  |         result = testdir.runpytest() | ||||||
|  |         result.stdout.no_fnmatch_line("*PytestConfigWarning*") | ||||||
|  | 
 | ||||||
|  |     @pytest.mark.filterwarnings("default") | ||||||
|  |     def test_disable_warnings_plugin_disables_config_warnings( | ||||||
|  |         self, testdir: Testdir | ||||||
|  |     ) -> None: | ||||||
|  |         """Disabling 'warnings' plugin also disables config time warnings""" | ||||||
|  |         testdir.makeconftest( | ||||||
|  |             """ | ||||||
|  |             import pytest | ||||||
|  |             def pytest_configure(config): | ||||||
|  |                 config.issue_config_time_warning( | ||||||
|  |                     pytest.PytestConfigWarning("custom config warning"), | ||||||
|  |                     stacklevel=2, | ||||||
|  |                 ) | ||||||
|  |         """ | ||||||
|  |         ) | ||||||
|  |         result = testdir.runpytest("-pno:warnings") | ||||||
|  |         result.stdout.no_fnmatch_line("*PytestConfigWarning*") | ||||||
| 
 | 
 | ||||||
|     @pytest.mark.parametrize( |     @pytest.mark.parametrize( | ||||||
|         "ini_file_text, exception_text", |         "ini_file_text, exception_text", | ||||||
|  | @ -1132,7 +1170,7 @@ def test_load_initial_conftest_last_ordering(_config_for_test): | ||||||
|     pm.register(m) |     pm.register(m) | ||||||
|     hc = pm.hook.pytest_load_initial_conftests |     hc = pm.hook.pytest_load_initial_conftests | ||||||
|     values = hc._nonwrappers + hc._wrappers |     values = hc._nonwrappers + hc._wrappers | ||||||
|     expected = ["_pytest.config", m.__module__, "_pytest.capture"] |     expected = ["_pytest.config", m.__module__, "_pytest.capture", "_pytest.warnings"] | ||||||
|     assert [x.function.__module__ for x in values] == expected |     assert [x.function.__module__ for x in values] == expected | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -1816,3 +1854,52 @@ def test_conftest_import_error_repr(tmpdir): | ||||||
|             assert exc.__traceback__ is not None |             assert exc.__traceback__ is not None | ||||||
|             exc_info = (type(exc), exc, exc.__traceback__) |             exc_info = (type(exc), exc, exc.__traceback__) | ||||||
|             raise ConftestImportFailure(path, exc_info) from exc |             raise ConftestImportFailure(path, exc_info) from exc | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_strtobool(): | ||||||
|  |     assert _strtobool("YES") | ||||||
|  |     assert not _strtobool("NO") | ||||||
|  |     with pytest.raises(ValueError): | ||||||
|  |         _strtobool("unknown") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "arg, escape, expected", | ||||||
|  |     [ | ||||||
|  |         ("ignore", False, ("ignore", "", Warning, "", 0)), | ||||||
|  |         ( | ||||||
|  |             "ignore::DeprecationWarning", | ||||||
|  |             False, | ||||||
|  |             ("ignore", "", DeprecationWarning, "", 0), | ||||||
|  |         ), | ||||||
|  |         ( | ||||||
|  |             "ignore:some msg:DeprecationWarning", | ||||||
|  |             False, | ||||||
|  |             ("ignore", "some msg", DeprecationWarning, "", 0), | ||||||
|  |         ), | ||||||
|  |         ( | ||||||
|  |             "ignore::DeprecationWarning:mod", | ||||||
|  |             False, | ||||||
|  |             ("ignore", "", DeprecationWarning, "mod", 0), | ||||||
|  |         ), | ||||||
|  |         ( | ||||||
|  |             "ignore::DeprecationWarning:mod:42", | ||||||
|  |             False, | ||||||
|  |             ("ignore", "", DeprecationWarning, "mod", 42), | ||||||
|  |         ), | ||||||
|  |         ("error:some\\msg:::", True, ("error", "some\\\\msg", Warning, "", 0)), | ||||||
|  |         ("error:::mod\\foo:", True, ("error", "", Warning, "mod\\\\foo\\Z", 0)), | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | def test_parse_warning_filter( | ||||||
|  |     arg: str, escape: bool, expected: "Tuple[str, str, Type[Warning], str, int]" | ||||||
|  | ) -> None: | ||||||
|  |     assert parse_warning_filter(arg, escape=escape) == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize("arg", [":" * 5, "::::-1", "::::not-a-number"]) | ||||||
|  | def test_parse_warning_filter_failure(arg: str) -> None: | ||||||
|  |     import warnings | ||||||
|  | 
 | ||||||
|  |     with pytest.raises(warnings._OptionError): | ||||||
|  |         parse_warning_filter(arg, escape=True) | ||||||
|  |  | ||||||
|  | @ -240,9 +240,8 @@ def test_filterwarnings_mark_registration(testdir): | ||||||
| def test_warning_captured_hook(testdir): | def test_warning_captured_hook(testdir): | ||||||
|     testdir.makeconftest( |     testdir.makeconftest( | ||||||
|         """ |         """ | ||||||
|         from _pytest.warnings import _issue_warning_captured |  | ||||||
|         def pytest_configure(config): |         def pytest_configure(config): | ||||||
|             _issue_warning_captured(UserWarning("config warning"), config.hook, stacklevel=2) |             config.issue_config_time_warning(UserWarning("config warning"), stacklevel=2) | ||||||
|     """ |     """ | ||||||
|     ) |     ) | ||||||
|     testdir.makepyfile( |     testdir.makepyfile( | ||||||
|  | @ -716,10 +715,22 @@ class TestStackLevel: | ||||||
|         assert "config{sep}__init__.py".format(sep=os.sep) in file |         assert "config{sep}__init__.py".format(sep=os.sep) in file | ||||||
|         assert func == "_preparse" |         assert func == "_preparse" | ||||||
| 
 | 
 | ||||||
|     def test_issue4445_import_plugin(self, testdir, capwarn): |     @pytest.mark.filterwarnings("default") | ||||||
|         """#4445: Make sure the warning points to a reasonable location |     def test_conftest_warning_captured(self, testdir: Testdir) -> None: | ||||||
|         See origin of _issue_warning_captured at: _pytest.config.__init__.py:585 |         """Warnings raised during importing of conftest.py files is captured (#2891).""" | ||||||
|  |         testdir.makeconftest( | ||||||
|             """ |             """ | ||||||
|  |             import warnings | ||||||
|  |             warnings.warn(UserWarning("my custom warning")) | ||||||
|  |             """ | ||||||
|  |         ) | ||||||
|  |         result = testdir.runpytest() | ||||||
|  |         result.stdout.fnmatch_lines( | ||||||
|  |             ["conftest.py:2", "*UserWarning: my custom warning*"] | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     def test_issue4445_import_plugin(self, testdir, capwarn): | ||||||
|  |         """#4445: Make sure the warning points to a reasonable location""" | ||||||
|         testdir.makepyfile( |         testdir.makepyfile( | ||||||
|             some_plugin=""" |             some_plugin=""" | ||||||
|             import pytest |             import pytest | ||||||
|  | @ -738,7 +749,7 @@ class TestStackLevel: | ||||||
| 
 | 
 | ||||||
|         assert "skipped plugin 'some_plugin': thing" in str(warning.message) |         assert "skipped plugin 'some_plugin': thing" in str(warning.message) | ||||||
|         assert "config{sep}__init__.py".format(sep=os.sep) in file |         assert "config{sep}__init__.py".format(sep=os.sep) in file | ||||||
|         assert func == "import_plugin" |         assert func == "_warn_about_skipped_plugins" | ||||||
| 
 | 
 | ||||||
|     def test_issue4445_issue5928_mark_generator(self, testdir): |     def test_issue4445_issue5928_mark_generator(self, testdir): | ||||||
|         """#4445 and #5928: Make sure the warning from an unknown mark points to |         """#4445 and #5928: Make sure the warning from an unknown mark points to | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue