Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ Tomer Keren
Tony Narlock
Tor Colvin
Trevor Bekolay
Trey Shaffer
Tushar Sadhwani
Tyler Goodlet
Tyler Smart
Expand Down
1 change: 1 addition & 0 deletions changelog/13201.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed color inconsistency in verbose mode where test status showed green instead of yellow for passed tests with warnings.
1 change: 1 addition & 0 deletions coverage.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/_pytest/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -1377,6 +1377,9 @@ def _determine_main_color(self, unknown_type_seen: bool) -> str:
stats = self.stats
if "failed" in stats or "error" in stats:
main_color = "red"
elif self.showlongtestinfo and not self._is_last_item:
# In verbose mode, keep progress green while tests are running
main_color = "green"
elif "warnings" in stats or "xpassed" in stats or unknown_type_seen:
main_color = "yellow"
elif "passed" in stats or not self._is_last_item:
Expand Down
84 changes: 68 additions & 16 deletions src/_pytest/warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,20 @@
from _pytest.config import parse_warning_filter
from _pytest.main import Session
from _pytest.nodes import Item
from _pytest.reports import TestReport
from _pytest.runner import CallInfo
from _pytest.stash import StashKey
from _pytest.terminal import TerminalReporter
from _pytest.tracemalloc import tracemalloc_message
import pytest


# StashKey for storing warning log on items
warning_captured_log_key = StashKey[list[warnings.WarningMessage]]()
# StashKey for tracking the index of the last dispatched warning
warning_last_dispatched_idx_key = StashKey[int]()


@contextmanager
def catch_warnings_for_item(
config: Config,
Expand Down Expand Up @@ -51,23 +60,27 @@ def catch_warnings_for_item(
for mark in item.iter_markers(name="filterwarnings"):
for arg in mark.args:
warnings.filterwarnings(*parse_warning_filter(arg, escape=False))

try:
yield
finally:
if record:
# mypy can't infer that record=True means log is not None; help it.
assert log is not None

for warning_message in log:
ihook.pytest_warning_recorded.call_historic(
kwargs=dict(
warning_message=warning_message,
nodeid=nodeid,
when=when,
location=None,
)
# Store the warning log on the item so it can be accessed during reporting
if record and log is not None:
item.stash[warning_captured_log_key] = log

yield

# For config and collect phases, dispatch warnings immediately.
# For runtest phase, warnings are dispatched from pytest_runtest_makereport.
if when != "runtest" and record:
# mypy can't infer that record=True means log is not None; help it.
assert log is not None

for warning_message in log:
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: warnings.WarningMessage) -> str:
Expand All @@ -89,6 +102,45 @@ def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
return (yield)


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(
item: Item, call: CallInfo[None]
) -> Generator[None, TestReport, None]:
"""Process warnings from stash and dispatch pytest_warning_recorded hooks."""
outcome = yield
report: TestReport = outcome.get_result()

warning_log = item.stash.get(warning_captured_log_key, None)
if warning_log:
# Set has_warnings attribute on call phase for xdist compatibility and yellow coloring
if report.when == "call":
report.has_warnings = True # type: ignore[attr-defined]

# Only dispatch warnings that haven't been dispatched yet
last_idx = item.stash.get(warning_last_dispatched_idx_key, -1)
for idx in range(last_idx + 1, len(warning_log)):
warning_message = warning_log[idx]
item.ihook.pytest_warning_recorded.call_historic(
kwargs=dict(
warning_message=warning_message,
nodeid=item.nodeid,
when="runtest",
location=None,
)
)
# Update the last dispatched index
item.stash[warning_last_dispatched_idx_key] = len(warning_log) - 1


@pytest.hookimpl(tryfirst=True)
def pytest_report_teststatus(report: TestReport, config: Config):
"""Provide yellow markup for passed tests that have warnings."""
if report.passed and report.when == "call":
if hasattr(report, "has_warnings") and report.has_warnings:
# Return (category, shortletter, verbose_word) with yellow markup
return "passed", ".", ("PASSED", {"yellow": True})


@pytest.hookimpl(wrapper=True, tryfirst=True)
def pytest_collection(session: Session) -> Generator[None, object, object]:
config = session.config
Expand Down
175 changes: 174 additions & 1 deletion testing/test_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -2191,7 +2191,7 @@ def test_foobar(i): raise ValueError()
[
r"test_axfail.py {yellow}x{reset}{green} \s+ \[ 4%\]{reset}",
r"test_bar.py ({green}\.{reset}){{10}}{green} \s+ \[ 52%\]{reset}",
r"test_foo.py ({green}\.{reset}){{5}}{yellow} \s+ \[ 76%\]{reset}",
r"test_foo.py ({yellow}\.{reset}){{5}}{yellow} \s+ \[ 76%\]{reset}",
r"test_foobar.py ({red}F{reset}){{5}}{red} \s+ \[100%\]{reset}",
]
)
Expand All @@ -2208,6 +2208,179 @@ def test_foobar(i): raise ValueError()
)
)

def test_verbose_colored_warnings(
self, pytester: Pytester, monkeypatch, color_mapping
) -> None:
"""Test that verbose mode shows yellow PASSED for tests with warnings."""
monkeypatch.setenv("PY_COLORS", "1")
pytester.makepyfile(
test_warning="""
import warnings
def test_with_warning():
warnings.warn("test warning", DeprecationWarning)

def test_without_warning():
pass
"""
)
result = pytester.runpytest("-v")
result.stdout.re_match_lines(
color_mapping.format_for_rematch(
[
r"test_warning.py::test_with_warning {yellow}PASSED{reset}{green} \s+ \[ 50%\]{reset}",
r"test_warning.py::test_without_warning {green}PASSED{reset}{yellow} \s+ \[100%\]{reset}",
]
)
)

def test_verbose_colored_warnings_xdist(
self, pytester: Pytester, monkeypatch, color_mapping
) -> None:
"""Test that warning coloring works correctly with pytest-xdist parallel execution."""
pytest.importorskip("xdist")
monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False)
monkeypatch.setenv("PY_COLORS", "1")
pytester.makepyfile(
test_warning_xdist="""
import warnings
def test_with_warning_1():
warnings.warn("warning in test 1", DeprecationWarning)
pass

def test_with_warning_2():
warnings.warn("warning in test 2", DeprecationWarning)
pass

def test_without_warning():
pass
"""
)

output = pytester.runpytest("-v", "-n2")
# xdist outputs in random order, and uses format:
# [gw#][cyan] [%] [reset][color]STATUS[reset] test_name
# Note: \x1b[36m is cyan, which isn't in color_mapping
output.stdout.re_match_lines_random(
color_mapping.format_for_rematch(
[
r"\[gw\d\]\x1b\[36m \[\s*\d+%\] {reset}{yellow}PASSED{reset} "
r"test_warning_xdist.py::test_with_warning_1",
r"\[gw\d\]\x1b\[36m \[\s*\d+%\] {reset}{yellow}PASSED{reset} "
r"test_warning_xdist.py::test_with_warning_2",
r"\[gw\d\]\x1b\[36m \[\s*\d+%\] {reset}{green}PASSED{reset} "
r"test_warning_xdist.py::test_without_warning",
]
)
)

def test_failed_test_with_warnings_shows_red(
self, pytester: Pytester, monkeypatch, color_mapping
) -> None:
"""Test that failed tests with warnings show RED, not yellow."""
monkeypatch.setenv("PY_COLORS", "1")
pytester.makepyfile(
test_failed_warning="""
import warnings
def test_fails_with_warning():
warnings.warn("This will fail", DeprecationWarning)
assert False, "Expected failure"

def test_passes_with_warning():
warnings.warn("This passes", DeprecationWarning)
assert True
"""
)
result = pytester.runpytest("-v")
# Failed test should be RED even though it has warnings
result.stdout.re_match_lines(
color_mapping.format_for_rematch(
[
r"test_failed_warning.py::test_fails_with_warning {red}FAILED{reset}",
r"test_failed_warning.py::test_passes_with_warning {yellow}PASSED{reset}",
]
)
)

def test_non_verbose_mode_with_warnings(
self, pytester: Pytester, monkeypatch, color_mapping
) -> None:
"""Test that non-verbose mode (dot output) works correctly with warnings."""
monkeypatch.setenv("PY_COLORS", "1")
pytester.makepyfile(
test_dots="""
import warnings
def test_with_warning():
warnings.warn("warning", DeprecationWarning)
pass

def test_without_warning():
pass
"""
)
result = pytester.runpytest() # No -v flag
# Should show dots, yellow for warning, green for clean pass
result.stdout.re_match_lines(
color_mapping.format_for_rematch(
[
r"test_dots.py {yellow}\.{reset}{green}\.{reset}",
]
)
)

def test_multiple_warnings_single_test(
self, pytester: Pytester, monkeypatch, color_mapping
) -> None:
"""Test that tests with multiple warnings still show yellow."""
monkeypatch.setenv("PY_COLORS", "1")
pytester.makepyfile(
test_multi="""
import warnings
def test_multiple_warnings():
warnings.warn("warning 1", DeprecationWarning)
warnings.warn("warning 2", DeprecationWarning)
warnings.warn("warning 3", DeprecationWarning)
pass
"""
)
result = pytester.runpytest("-v")
result.stdout.re_match_lines(
color_mapping.format_for_rematch(
[
r"test_multi.py::test_multiple_warnings {yellow}PASSED{reset}",
]
)
)

def test_warning_with_filterwarnings_mark(
self, pytester: Pytester, monkeypatch, color_mapping
) -> None:
"""Test that warnings with filterwarnings mark still show yellow."""
monkeypatch.setenv("PY_COLORS", "1")
pytester.makepyfile(
test_marked="""
import warnings
import pytest

@pytest.mark.filterwarnings("ignore::DeprecationWarning")
def test_with_ignored_warning():
warnings.warn("ignored warning", DeprecationWarning)
pass

def test_with_visible_warning():
warnings.warn("visible warning", DeprecationWarning)
pass
"""
)
result = pytester.runpytest("-v")
result.stdout.re_match_lines(
color_mapping.format_for_rematch(
[
r"test_marked.py::test_with_ignored_warning {green}PASSED{reset}",
r"test_marked.py::test_with_visible_warning {yellow}PASSED{reset}",
]
)
)

def test_count(self, many_tests_files, pytester: Pytester) -> None:
pytester.makeini(
"""
Expand Down
Loading