Skip to content

Commit 8df2e57

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 <noreply@anthropic.com>
1 parent c97a401 commit 8df2e57

File tree

6 files changed

+309
-18
lines changed

6 files changed

+309
-18
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.

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: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,18 @@
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+
27+
2128
@contextmanager
2229
def catch_warnings_for_item(
2330
config: Config,
@@ -46,28 +53,17 @@ def catch_warnings_for_item(
4653
apply_warning_filters(config_filters, cmdline_filters)
4754

4855
# apply filters from "filterwarnings" marks
49-
nodeid = "" if item is None else item.nodeid
5056
if item is not None:
5157
for mark in item.iter_markers(name="filterwarnings"):
5258
for arg in mark.args:
5359
warnings.filterwarnings(*parse_warning_filter(arg, escape=False))
60+
# Store the warning log on the item so it can be accessed during reporting
61+
if record and log is not None:
62+
item.stash[warning_captured_log_key] = log
5463

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-
)
70-
)
64+
yield
65+
# Note: pytest_warning_recorded hooks are now dispatched from
66+
# pytest_runtest_makereport for better timing and integration
7167

7268

7369
def warning_record_to_str(warning_message: warnings.WarningMessage) -> str:
@@ -89,6 +85,40 @@ def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
8985
return (yield)
9086

9187

88+
@pytest.hookimpl(hookwrapper=True)
89+
def pytest_runtest_makereport(
90+
item: Item, call: CallInfo[None]
91+
) -> Generator[None, TestReport, None]:
92+
"""Process warnings from stash and dispatch pytest_warning_recorded hooks."""
93+
outcome = yield
94+
report: TestReport = outcome.get_result()
95+
96+
if report.when == "call":
97+
warning_log = item.stash.get(warning_captured_log_key, None)
98+
if warning_log:
99+
# Set attribute on report for xdist compatibility
100+
report.has_warnings = True # type: ignore[attr-defined]
101+
102+
for warning_message in warning_log:
103+
item.ihook.pytest_warning_recorded.call_historic(
104+
kwargs=dict(
105+
warning_message=warning_message,
106+
nodeid=item.nodeid,
107+
when="runtest",
108+
location=None,
109+
)
110+
)
111+
112+
113+
@pytest.hookimpl(tryfirst=True)
114+
def pytest_report_teststatus(report: TestReport, config: Config):
115+
"""Provide yellow markup for passed tests that have warnings."""
116+
if report.passed and report.when == "call":
117+
if hasattr(report, "has_warnings") and report.has_warnings:
118+
# Return (category, shortletter, verbose_word) with yellow markup
119+
return "passed", ".", ("PASSED", {"yellow": True})
120+
121+
92122
@pytest.hookimpl(wrapper=True, tryfirst=True)
93123
def pytest_collection(session: Session) -> Generator[None, object, object]:
94124
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
"""

testing/test_warnings.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,3 +888,86 @@ def test_resource_warning(tmp_path):
888888
else []
889889
)
890890
result.stdout.fnmatch_lines([*expected_extra, "*1 passed*"])
891+
892+
893+
def test_warning_tracking_for_yellow_coloring(pytester: Pytester) -> None:
894+
"""Test that warnings set has_warnings attribute on reports for yellow markup."""
895+
pytester.makepyfile(
896+
"""
897+
import warnings
898+
def test_with_warning():
899+
warnings.warn("test warning", DeprecationWarning)
900+
assert True
901+
902+
def test_without_warning():
903+
assert True
904+
"""
905+
)
906+
907+
# Use inline_run to verify the has_warnings attribute is set correctly
908+
reprec = pytester.inline_run()
909+
910+
# Get all call phase reports
911+
reports = reprec.getreports("pytest_runtest_logreport")
912+
call_reports = [r for r in reports if r.when == "call"]
913+
914+
# Find the reports for each test
915+
test_with_warning_report = None
916+
test_without_warning_report = None
917+
for report in call_reports:
918+
if "test_with_warning" in report.nodeid:
919+
test_with_warning_report = report
920+
elif "test_without_warning" in report.nodeid:
921+
test_without_warning_report = report
922+
923+
# Verify test_with_warning has the has_warnings attribute set
924+
assert test_with_warning_report is not None, (
925+
"Expected to find test_with_warning report"
926+
)
927+
assert hasattr(test_with_warning_report, "has_warnings"), (
928+
"Expected test_with_warning report to have has_warnings attribute"
929+
)
930+
assert test_with_warning_report.has_warnings is True, (
931+
"Expected has_warnings to be True for test with warnings"
932+
)
933+
934+
# Verify test_without_warning does NOT have the has_warnings attribute
935+
assert test_without_warning_report is not None, (
936+
"Expected to find test_without_warning report"
937+
)
938+
assert not hasattr(test_without_warning_report, "has_warnings"), (
939+
"Did not expect test_without_warning report to have has_warnings attribute"
940+
)
941+
942+
943+
def test_warning_stash_storage(pytester: Pytester) -> None:
944+
"""Test that warning log is stored in item.stash during test execution."""
945+
pytester.makepyfile(
946+
"""
947+
import warnings
948+
949+
def test_with_warning():
950+
warnings.warn("test warning", DeprecationWarning)
951+
pass
952+
"""
953+
)
954+
955+
# Use a plugin to capture the item and check the stash
956+
captured_item = []
957+
958+
class StashChecker:
959+
def pytest_runtest_call(self, item):
960+
captured_item.append(item)
961+
962+
pytester.inline_run(plugins=[StashChecker()])
963+
964+
assert len(captured_item) == 1
965+
item = captured_item[0]
966+
967+
# Check that the warning log was stored in the stash
968+
from _pytest.warnings import warning_captured_log_key
969+
970+
warning_log = item.stash.get(warning_captured_log_key, None)
971+
assert warning_log is not None, "Expected warning log to be stored in item.stash"
972+
assert len(warning_log) > 0, "Expected at least one warning in the log"
973+
assert "test warning" in str(warning_log[0].message)

0 commit comments

Comments
 (0)