Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new hook pytest_warning_recorded #7255

Merged
merged 7 commits into from May 31, 2020
3 changes: 3 additions & 0 deletions 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.
1 change: 1 addition & 0 deletions doc/en/reference.rst
Expand Up @@ -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:

Expand Down
5 changes: 5 additions & 0 deletions src/_pytest/deprecated.py
Expand Up @@ -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."
)
42 changes: 36 additions & 6 deletions src/_pytest/hookspec.py
Expand Up @@ -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
Expand Down Expand Up @@ -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):
gnikonorov marked this conversation as resolved.
Show resolved Hide resolved
"""
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
Expand All @@ -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:
Expand All @@ -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 <module> when the execution context is at the module level.
"""


# -------------------------------------------------------------------------
# doctest hooks
# -------------------------------------------------------------------------
Expand Down
6 changes: 2 additions & 4 deletions src/_pytest/terminal.py
Expand Up @@ -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``).
Expand Down Expand Up @@ -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 ""
gnikonorov marked this conversation as resolved.
Show resolved Hide resolved
warning_report = WarningReport(
fslocation=fslocation, message=message, nodeid=nodeid
)
Expand Down
18 changes: 16 additions & 2 deletions src/_pytest/warnings.py
Expand Up @@ -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")
Expand All @@ -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
gnikonorov marked this conversation as resolved.
Show resolved Hide resolved
if item is not None:
for mark in item.iter_markers(name="filterwarnings"):
for arg in mark.args:
Expand All @@ -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(
gnikonorov marked this conversation as resolved.
Show resolved Hide resolved
kwargs=dict(
warning_message=warning_message,
nodeid=nodeid,
when=when,
location=None,
)
)


def warning_record_to_str(warning_message):
Expand Down Expand Up @@ -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.
Expand All @@ -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(
gnikonorov marked this conversation as resolved.
Show resolved Hide resolved
kwargs=dict(
warning_message=records[0], when="config", nodeid="", location=location
gnikonorov marked this conversation as resolved.
Show resolved Hide resolved
)
)
31 changes: 23 additions & 8 deletions testing/test_warnings.py
Expand Up @@ -268,21 +268,36 @@ 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*"])

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")
Expand Down Expand Up @@ -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()]
Expand Down