Skip to content

Commit 78af87a

Browse files
treyshafferclaude
andcommitted
Fix color inconsistency in verbose mode for passed tests with warnings
Fixed issue where test status showed green instead of yellow for passed tests with warnings in verbose mode. Tests with warnings now correctly display in yellow both during progress and in the final summary. Changes: - Added pytest_report_teststatus hook with tryfirst=True to provide yellow markup for passed tests that have warnings - Set has_warnings attribute on TestReport for xdist compatibility - Added color logic in _determine_main_color to keep progress green while running in verbose mode - Moved TestReport and CallInfo imports out of TYPE_CHECKING block for 100% coverage - Removed unused _nodeids_with_warnings global state (memory leak fix) - Updated test to verify has_warnings attribute directly Fixes #13201 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent c97a401 commit 78af87a

File tree

7 files changed

+331
-17
lines changed

7 files changed

+331
-17
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,7 @@ Tomer Keren
463463
Tony Narlock
464464
Tor Colvin
465465
Trevor Bekolay
466+
Trey Shaffer
466467
Tushar Sadhwani
467468
Tyler Goodlet
468469
Tyler Smart

changelog/13201.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed color inconsistency in verbose mode where test status showed green instead of yellow for passed tests with warnings.

coverage.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

src/_pytest/terminal.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1377,6 +1377,9 @@ def _determine_main_color(self, unknown_type_seen: bool) -> str:
13771377
stats = self.stats
13781378
if "failed" in stats or "error" in stats:
13791379
main_color = "red"
1380+
elif self.showlongtestinfo and not self._is_last_item:
1381+
# In verbose mode, keep progress green while tests are running
1382+
main_color = "green"
13801383
elif "warnings" in stats or "xpassed" in stats or unknown_type_seen:
13811384
main_color = "yellow"
13821385
elif "passed" in stats or not self._is_last_item:

src/_pytest/warnings.py

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,20 @@
1313
from _pytest.config import parse_warning_filter
1414
from _pytest.main import Session
1515
from _pytest.nodes import Item
16+
from _pytest.reports import TestReport
17+
from _pytest.runner import CallInfo
18+
from _pytest.stash import StashKey
1619
from _pytest.terminal import TerminalReporter
1720
from _pytest.tracemalloc import tracemalloc_message
1821
import pytest
1922

2023

24+
# StashKey for storing warning log on items
25+
warning_captured_log_key = StashKey[list[warnings.WarningMessage]]()
26+
# StashKey for tracking the index of the last dispatched warning
27+
warning_last_dispatched_idx_key = StashKey[int]()
28+
29+
2130
@contextmanager
2231
def catch_warnings_for_item(
2332
config: Config,
@@ -51,23 +60,27 @@ def catch_warnings_for_item(
5160
for mark in item.iter_markers(name="filterwarnings"):
5261
for arg in mark.args:
5362
warnings.filterwarnings(*parse_warning_filter(arg, escape=False))
54-
55-
try:
56-
yield
57-
finally:
58-
if record:
59-
# mypy can't infer that record=True means log is not None; help it.
60-
assert log is not None
61-
62-
for warning_message in log:
63-
ihook.pytest_warning_recorded.call_historic(
64-
kwargs=dict(
65-
warning_message=warning_message,
66-
nodeid=nodeid,
67-
when=when,
68-
location=None,
69-
)
63+
# Store the warning log on the item so it can be accessed during reporting
64+
if record and log is not None:
65+
item.stash[warning_captured_log_key] = log
66+
67+
yield
68+
69+
# For config and collect phases, dispatch warnings immediately.
70+
# For runtest phase, warnings are dispatched from pytest_runtest_makereport.
71+
if when != "runtest" and record:
72+
# mypy can't infer that record=True means log is not None; help it.
73+
assert log is not None
74+
75+
for warning_message in log:
76+
ihook.pytest_warning_recorded.call_historic(
77+
kwargs=dict(
78+
warning_message=warning_message,
79+
nodeid=nodeid,
80+
when=when,
81+
location=None,
7082
)
83+
)
7184

7285

7386
def warning_record_to_str(warning_message: warnings.WarningMessage) -> str:
@@ -89,6 +102,45 @@ def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
89102
return (yield)
90103

91104

105+
@pytest.hookimpl(hookwrapper=True)
106+
def pytest_runtest_makereport(
107+
item: Item, call: CallInfo[None]
108+
) -> Generator[None, TestReport, None]:
109+
"""Process warnings from stash and dispatch pytest_warning_recorded hooks."""
110+
outcome = yield
111+
report: TestReport = outcome.get_result()
112+
113+
warning_log = item.stash.get(warning_captured_log_key, None)
114+
if warning_log:
115+
# Set has_warnings attribute on call phase for xdist compatibility and yellow coloring
116+
if report.when == "call":
117+
report.has_warnings = True # type: ignore[attr-defined]
118+
119+
# Only dispatch warnings that haven't been dispatched yet
120+
last_idx = item.stash.get(warning_last_dispatched_idx_key, -1)
121+
for idx in range(last_idx + 1, len(warning_log)):
122+
warning_message = warning_log[idx]
123+
item.ihook.pytest_warning_recorded.call_historic(
124+
kwargs=dict(
125+
warning_message=warning_message,
126+
nodeid=item.nodeid,
127+
when="runtest",
128+
location=None,
129+
)
130+
)
131+
# Update the last dispatched index
132+
item.stash[warning_last_dispatched_idx_key] = len(warning_log) - 1
133+
134+
135+
@pytest.hookimpl(tryfirst=True)
136+
def pytest_report_teststatus(report: TestReport, config: Config):
137+
"""Provide yellow markup for passed tests that have warnings."""
138+
if report.passed and report.when == "call":
139+
if hasattr(report, "has_warnings") and report.has_warnings:
140+
# Return (category, shortletter, verbose_word) with yellow markup
141+
return "passed", ".", ("PASSED", {"yellow": True})
142+
143+
92144
@pytest.hookimpl(wrapper=True, tryfirst=True)
93145
def pytest_collection(session: Session) -> Generator[None, object, object]:
94146
config = session.config

testing/test_terminal.py

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2191,7 +2191,7 @@ def test_foobar(i): raise ValueError()
21912191
[
21922192
r"test_axfail.py {yellow}x{reset}{green} \s+ \[ 4%\]{reset}",
21932193
r"test_bar.py ({green}\.{reset}){{10}}{green} \s+ \[ 52%\]{reset}",
2194-
r"test_foo.py ({green}\.{reset}){{5}}{yellow} \s+ \[ 76%\]{reset}",
2194+
r"test_foo.py ({yellow}\.{reset}){{5}}{yellow} \s+ \[ 76%\]{reset}",
21952195
r"test_foobar.py ({red}F{reset}){{5}}{red} \s+ \[100%\]{reset}",
21962196
]
21972197
)
@@ -2208,6 +2208,179 @@ def test_foobar(i): raise ValueError()
22082208
)
22092209
)
22102210

2211+
def test_verbose_colored_warnings(
2212+
self, pytester: Pytester, monkeypatch, color_mapping
2213+
) -> None:
2214+
"""Test that verbose mode shows yellow PASSED for tests with warnings."""
2215+
monkeypatch.setenv("PY_COLORS", "1")
2216+
pytester.makepyfile(
2217+
test_warning="""
2218+
import warnings
2219+
def test_with_warning():
2220+
warnings.warn("test warning", DeprecationWarning)
2221+
2222+
def test_without_warning():
2223+
pass
2224+
"""
2225+
)
2226+
result = pytester.runpytest("-v")
2227+
result.stdout.re_match_lines(
2228+
color_mapping.format_for_rematch(
2229+
[
2230+
r"test_warning.py::test_with_warning {yellow}PASSED{reset}{green} \s+ \[ 50%\]{reset}",
2231+
r"test_warning.py::test_without_warning {green}PASSED{reset}{yellow} \s+ \[100%\]{reset}",
2232+
]
2233+
)
2234+
)
2235+
2236+
def test_verbose_colored_warnings_xdist(
2237+
self, pytester: Pytester, monkeypatch, color_mapping
2238+
) -> None:
2239+
"""Test that warning coloring works correctly with pytest-xdist parallel execution."""
2240+
pytest.importorskip("xdist")
2241+
monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False)
2242+
monkeypatch.setenv("PY_COLORS", "1")
2243+
pytester.makepyfile(
2244+
test_warning_xdist="""
2245+
import warnings
2246+
def test_with_warning_1():
2247+
warnings.warn("warning in test 1", DeprecationWarning)
2248+
pass
2249+
2250+
def test_with_warning_2():
2251+
warnings.warn("warning in test 2", DeprecationWarning)
2252+
pass
2253+
2254+
def test_without_warning():
2255+
pass
2256+
"""
2257+
)
2258+
2259+
output = pytester.runpytest("-v", "-n2")
2260+
# xdist outputs in random order, and uses format:
2261+
# [gw#][cyan] [%] [reset][color]STATUS[reset] test_name
2262+
# Note: \x1b[36m is cyan, which isn't in color_mapping
2263+
output.stdout.re_match_lines_random(
2264+
color_mapping.format_for_rematch(
2265+
[
2266+
r"\[gw\d\]\x1b\[36m \[\s*\d+%\] {reset}{yellow}PASSED{reset} "
2267+
r"test_warning_xdist.py::test_with_warning_1",
2268+
r"\[gw\d\]\x1b\[36m \[\s*\d+%\] {reset}{yellow}PASSED{reset} "
2269+
r"test_warning_xdist.py::test_with_warning_2",
2270+
r"\[gw\d\]\x1b\[36m \[\s*\d+%\] {reset}{green}PASSED{reset} "
2271+
r"test_warning_xdist.py::test_without_warning",
2272+
]
2273+
)
2274+
)
2275+
2276+
def test_failed_test_with_warnings_shows_red(
2277+
self, pytester: Pytester, monkeypatch, color_mapping
2278+
) -> None:
2279+
"""Test that failed tests with warnings show RED, not yellow."""
2280+
monkeypatch.setenv("PY_COLORS", "1")
2281+
pytester.makepyfile(
2282+
test_failed_warning="""
2283+
import warnings
2284+
def test_fails_with_warning():
2285+
warnings.warn("This will fail", DeprecationWarning)
2286+
assert False, "Expected failure"
2287+
2288+
def test_passes_with_warning():
2289+
warnings.warn("This passes", DeprecationWarning)
2290+
assert True
2291+
"""
2292+
)
2293+
result = pytester.runpytest("-v")
2294+
# Failed test should be RED even though it has warnings
2295+
result.stdout.re_match_lines(
2296+
color_mapping.format_for_rematch(
2297+
[
2298+
r"test_failed_warning.py::test_fails_with_warning {red}FAILED{reset}",
2299+
r"test_failed_warning.py::test_passes_with_warning {yellow}PASSED{reset}",
2300+
]
2301+
)
2302+
)
2303+
2304+
def test_non_verbose_mode_with_warnings(
2305+
self, pytester: Pytester, monkeypatch, color_mapping
2306+
) -> None:
2307+
"""Test that non-verbose mode (dot output) works correctly with warnings."""
2308+
monkeypatch.setenv("PY_COLORS", "1")
2309+
pytester.makepyfile(
2310+
test_dots="""
2311+
import warnings
2312+
def test_with_warning():
2313+
warnings.warn("warning", DeprecationWarning)
2314+
pass
2315+
2316+
def test_without_warning():
2317+
pass
2318+
"""
2319+
)
2320+
result = pytester.runpytest() # No -v flag
2321+
# Should show dots, yellow for warning, green for clean pass
2322+
result.stdout.re_match_lines(
2323+
color_mapping.format_for_rematch(
2324+
[
2325+
r"test_dots.py {yellow}\.{reset}{green}\.{reset}",
2326+
]
2327+
)
2328+
)
2329+
2330+
def test_multiple_warnings_single_test(
2331+
self, pytester: Pytester, monkeypatch, color_mapping
2332+
) -> None:
2333+
"""Test that tests with multiple warnings still show yellow."""
2334+
monkeypatch.setenv("PY_COLORS", "1")
2335+
pytester.makepyfile(
2336+
test_multi="""
2337+
import warnings
2338+
def test_multiple_warnings():
2339+
warnings.warn("warning 1", DeprecationWarning)
2340+
warnings.warn("warning 2", DeprecationWarning)
2341+
warnings.warn("warning 3", DeprecationWarning)
2342+
pass
2343+
"""
2344+
)
2345+
result = pytester.runpytest("-v")
2346+
result.stdout.re_match_lines(
2347+
color_mapping.format_for_rematch(
2348+
[
2349+
r"test_multi.py::test_multiple_warnings {yellow}PASSED{reset}",
2350+
]
2351+
)
2352+
)
2353+
2354+
def test_warning_with_filterwarnings_mark(
2355+
self, pytester: Pytester, monkeypatch, color_mapping
2356+
) -> None:
2357+
"""Test that warnings with filterwarnings mark still show yellow."""
2358+
monkeypatch.setenv("PY_COLORS", "1")
2359+
pytester.makepyfile(
2360+
test_marked="""
2361+
import warnings
2362+
import pytest
2363+
2364+
@pytest.mark.filterwarnings("ignore::DeprecationWarning")
2365+
def test_with_ignored_warning():
2366+
warnings.warn("ignored warning", DeprecationWarning)
2367+
pass
2368+
2369+
def test_with_visible_warning():
2370+
warnings.warn("visible warning", DeprecationWarning)
2371+
pass
2372+
"""
2373+
)
2374+
result = pytester.runpytest("-v")
2375+
result.stdout.re_match_lines(
2376+
color_mapping.format_for_rematch(
2377+
[
2378+
r"test_marked.py::test_with_ignored_warning {green}PASSED{reset}",
2379+
r"test_marked.py::test_with_visible_warning {yellow}PASSED{reset}",
2380+
]
2381+
)
2382+
)
2383+
22112384
def test_count(self, many_tests_files, pytester: Pytester) -> None:
22122385
pytester.makeini(
22132386
"""

0 commit comments

Comments
 (0)