diff --git a/changelog/7255.feature.rst b/changelog/7255.feature.rst new file mode 100644 index 00000000000..4073589b05a --- /dev/null +++ b/changelog/7255.feature.rst @@ -0,0 +1,3 @@ +Introduced a new hook named `pytest_warning_recorded` to convey information about warnings captured by the internal `pytest` warnings plugin. + +This hook is meant to replace `pytest_warning_captured`, which will be removed in a future release. diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 6bc7657c570..7348636a2dd 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -711,6 +711,7 @@ Session related reporting hooks: .. autofunction:: pytest_fixture_setup .. autofunction:: pytest_fixture_post_finalizer .. autofunction:: pytest_warning_captured +.. autofunction:: pytest_warning_recorded Central hook for reporting about test execution: diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index f981a4a4b9e..1ce4e1e39b2 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -80,3 +80,8 @@ "The `-k 'expr:'` syntax to -k is deprecated.\n" "Please open an issue if you use this and want a replacement." ) + +WARNING_CAPTURED_HOOK = PytestDeprecationWarning( + "The pytest_warning_captured is deprecated and will be removed in a future release.\n" + "Please use pytest_warning_recorded instead." +) diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index b4fab332d43..341f0a250b2 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -8,9 +8,11 @@ from pluggy import HookspecMarker from .deprecated import COLLECT_DIRECTORY_HOOK +from .deprecated import WARNING_CAPTURED_HOOK from _pytest.compat import TYPE_CHECKING if TYPE_CHECKING: + import warnings from _pytest.config import Config from _pytest.main import Session from _pytest.reports import BaseReport @@ -620,10 +622,12 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config): """ -@hookspec(historic=True) +@hookspec(historic=True, warn_on_impl=WARNING_CAPTURED_HOOK) def pytest_warning_captured(warning_message, when, item, location): - """ - Process a warning captured by the internal pytest warnings plugin. + """(**Deprecated**) Process a warning captured by the internal pytest warnings plugin. + + This hook is considered deprecated and will be removed in a future pytest version. + Use :func:`pytest_warning_recorded` instead. :param warnings.WarningMessage warning_message: The captured warning. This is the same object produced by :py:func:`warnings.catch_warnings`, and contains @@ -637,9 +641,6 @@ def pytest_warning_captured(warning_message, when, item, location): * ``"runtest"``: during test execution. :param pytest.Item|None item: - **DEPRECATED**: This parameter is incompatible with ``pytest-xdist``, and will always receive ``None`` - in a future release. - The item being executed if ``when`` is ``"runtest"``, otherwise ``None``. :param tuple location: @@ -648,6 +649,35 @@ def pytest_warning_captured(warning_message, when, item, location): """ +@hookspec(historic=True) +def pytest_warning_recorded( + warning_message: "warnings.WarningMessage", + when: str, + nodeid: str, + location: Tuple[str, int, str], +): + """ + Process a warning captured by the internal pytest warnings plugin. + + :param warnings.WarningMessage warning_message: + The captured warning. This is the same object produced by :py:func:`warnings.catch_warnings`, and contains + the same attributes as the parameters of :py:func:`warnings.showwarning`. + + :param str when: + Indicates when the warning was captured. Possible values: + + * ``"config"``: during pytest configuration/initialization stage. + * ``"collect"``: during test collection. + * ``"runtest"``: during test execution. + + :param str nodeid: full id of the item + + :param tuple location: + Holds information about the execution context of the captured warning (filename, linenumber, function). + ``function`` evaluates to when the execution context is at the module level. + """ + + # ------------------------------------------------------------------------- # doctest hooks # ------------------------------------------------------------------------- diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 8ecb5a16b63..d26db2c510b 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -227,7 +227,7 @@ def pytest_report_teststatus(report: TestReport) -> Tuple[str, str, str]: @attr.s class WarningReport: """ - Simple structure to hold warnings information captured by ``pytest_warning_captured``. + Simple structure to hold warnings information captured by ``pytest_warning_recorded``. :ivar str message: user friendly message about the warning :ivar str|None nodeid: node id that generated the warning (see ``get_location``). @@ -411,14 +411,12 @@ def pytest_internalerror(self, excrepr): self.write_line("INTERNALERROR> " + line) return 1 - def pytest_warning_captured(self, warning_message, item): - # from _pytest.nodes import get_fslocation_from_item + def pytest_warning_recorded(self, warning_message, nodeid): from _pytest.warnings import warning_record_to_str fslocation = warning_message.filename, warning_message.lineno message = warning_record_to_str(warning_message) - nodeid = item.nodeid if item is not None else "" warning_report = WarningReport( fslocation=fslocation, message=message, nodeid=nodeid ) diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 527bb03b001..8828a53d611 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -81,7 +81,7 @@ def catch_warnings_for_item(config, ihook, when, item): ``item`` can be None if we are not in the context of an item execution. - Each warning captured triggers the ``pytest_warning_captured`` hook. + Each warning captured triggers the ``pytest_warning_recorded`` hook. """ cmdline_filters = config.getoption("pythonwarnings") or [] inifilters = config.getini("filterwarnings") @@ -102,6 +102,7 @@ def catch_warnings_for_item(config, ihook, when, item): for arg in cmdline_filters: warnings.filterwarnings(*_parse_filter(arg, escape=True)) + 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: @@ -113,6 +114,14 @@ def catch_warnings_for_item(config, ihook, when, item): ihook.pytest_warning_captured.call_historic( kwargs=dict(warning_message=warning_message, when=when, item=item) ) + 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): @@ -166,7 +175,7 @@ def pytest_sessionfinish(session): def _issue_warning_captured(warning, hook, stacklevel): """ This function should be used instead of calling ``warnings.warn`` directly when we are in the "configure" stage: - at this point the actual options might not have been set, so we manually trigger the pytest_warning_captured + at this point the actual options might not have been set, so we manually trigger the pytest_warning_recorded hook so we can display these warnings in the terminal. This is a hack until we can sort out #2891. :param warning: the warning instance. @@ -185,3 +194,8 @@ def _issue_warning_captured(warning, hook, stacklevel): 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 + ) + ) diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 51d1286b465..ea7ab397dfe 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -268,9 +268,8 @@ def test_func(fix): collected = [] class WarningCollector: - def pytest_warning_captured(self, warning_message, when, item): - imge_name = item.name if item is not None else "" - collected.append((str(warning_message.message), when, imge_name)) + def pytest_warning_recorded(self, warning_message, when, nodeid, location): + collected.append((str(warning_message.message), when, nodeid, location)) result = testdir.runpytest(plugins=[WarningCollector()]) result.stdout.fnmatch_lines(["*1 passed*"]) @@ -278,11 +277,27 @@ def pytest_warning_captured(self, warning_message, when, item): expected = [ ("config warning", "config", ""), ("collect warning", "collect", ""), - ("setup warning", "runtest", "test_func"), - ("call warning", "runtest", "test_func"), - ("teardown warning", "runtest", "test_func"), + ("setup warning", "runtest", "test_warning_captured_hook.py::test_func"), + ("call warning", "runtest", "test_warning_captured_hook.py::test_func"), + ("teardown warning", "runtest", "test_warning_captured_hook.py::test_func"), ] - assert collected == expected + for index in range(len(expected)): + collected_result = collected[index] + expected_result = expected[index] + + assert collected_result[0] == expected_result[0], str(collected) + assert collected_result[1] == expected_result[1], str(collected) + assert collected_result[2] == expected_result[2], str(collected) + + # NOTE: collected_result[3] is location, which differs based on the platform you are on + # thus, the best we can do here is assert the types of the paremeters match what we expect + # and not try and preload it in the expected array + if collected_result[3] is not None: + assert type(collected_result[3][0]) is str, str(collected) + assert type(collected_result[3][1]) is int, str(collected) + assert type(collected_result[3][2]) is str, str(collected) + else: + assert collected_result[3] is None, str(collected) @pytest.mark.filterwarnings("always") @@ -649,7 +664,7 @@ class CapturedWarnings: captured = [] @classmethod - def pytest_warning_captured(cls, warning_message, when, item, location): + def pytest_warning_recorded(cls, warning_message, when, nodeid, location): cls.captured.append((warning_message, location)) testdir.plugins = [CapturedWarnings()]