Skip to content

Commit 3692847

Browse files
authored
terminal: refactor, no yellow ("boring") for non-last item (#6409)
2 parents 9785ee4 + e872532 commit 3692847

File tree

3 files changed

+138
-69
lines changed

3 files changed

+138
-69
lines changed

changelog/6409.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fallback to green (instead of yellow) for non-last items without previous passes with colored terminal progress indicator.

src/_pytest/terminal.py

Lines changed: 82 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@
3333

3434
REPORT_COLLECTING_RESOLUTION = 0.5
3535

36+
KNOWN_TYPES = (
37+
"failed",
38+
"passed",
39+
"skipped",
40+
"deselected",
41+
"xfailed",
42+
"xpassed",
43+
"warnings",
44+
"error",
45+
)
46+
3647
_REPORTCHARS_DEFAULT = "fE"
3748

3849

@@ -254,6 +265,8 @@ def __init__(self, config: Config, file=None) -> None:
254265
self._showfspath = None
255266

256267
self.stats = {} # type: Dict[str, List[Any]]
268+
self._main_color = None # type: Optional[str]
269+
self._known_types = None # type: Optional[List]
257270
self.startdir = config.invocation_dir
258271
if file is None:
259272
file = sys.stdout
@@ -372,6 +385,12 @@ def section(self, title, sep="=", **kw):
372385
def line(self, msg, **kw):
373386
self._tw.line(msg, **kw)
374387

388+
def _add_stats(self, category: str, items: List) -> None:
389+
set_main_color = category not in self.stats
390+
self.stats.setdefault(category, []).extend(items[:])
391+
if set_main_color:
392+
self._set_main_color()
393+
375394
def pytest_internalerror(self, excrepr):
376395
for line in str(excrepr).split("\n"):
377396
self.write_line("INTERNALERROR> " + line)
@@ -381,15 +400,14 @@ def pytest_warning_captured(self, warning_message, item):
381400
# from _pytest.nodes import get_fslocation_from_item
382401
from _pytest.warnings import warning_record_to_str
383402

384-
warnings = self.stats.setdefault("warnings", [])
385403
fslocation = warning_message.filename, warning_message.lineno
386404
message = warning_record_to_str(warning_message)
387405

388406
nodeid = item.nodeid if item is not None else ""
389407
warning_report = WarningReport(
390408
fslocation=fslocation, message=message, nodeid=nodeid
391409
)
392-
warnings.append(warning_report)
410+
self._add_stats("warnings", [warning_report])
393411

394412
def pytest_plugin_registered(self, plugin):
395413
if self.config.option.traceconfig:
@@ -400,7 +418,7 @@ def pytest_plugin_registered(self, plugin):
400418
self.write_line(msg)
401419

402420
def pytest_deselected(self, items):
403-
self.stats.setdefault("deselected", []).extend(items)
421+
self._add_stats("deselected", items)
404422

405423
def pytest_runtest_logstart(self, nodeid, location):
406424
# ensure that the path is printed before the
@@ -421,7 +439,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None:
421439
word, markup = word
422440
else:
423441
markup = None
424-
self.stats.setdefault(category, []).append(rep)
442+
self._add_stats(category, [rep])
425443
if not letter and not word:
426444
# probably passed setup/teardown
427445
return
@@ -463,6 +481,10 @@ def pytest_runtest_logreport(self, report: TestReport) -> None:
463481
self._tw.write(" " + line)
464482
self.currentfspath = -2
465483

484+
@property
485+
def _is_last_item(self):
486+
return len(self._progress_nodeids_reported) == self._session.testscollected
487+
466488
def pytest_runtest_logfinish(self, nodeid):
467489
assert self._session
468490
if self.verbosity <= 0 and self._show_progress_info:
@@ -472,15 +494,12 @@ def pytest_runtest_logfinish(self, nodeid):
472494
else:
473495
progress_length = len(" [100%]")
474496

475-
main_color, _ = _get_main_color(self.stats)
476-
477497
self._progress_nodeids_reported.add(nodeid)
478-
is_last_item = (
479-
len(self._progress_nodeids_reported) == self._session.testscollected
480-
)
481-
if is_last_item:
482-
self._write_progress_information_filling_space(color=main_color)
498+
499+
if self._is_last_item:
500+
self._write_progress_information_filling_space()
483501
else:
502+
main_color, _ = self._get_main_color()
484503
w = self._width_of_current_line
485504
past_edge = w + progress_length + 1 >= self._screen_width
486505
if past_edge:
@@ -504,9 +523,8 @@ def _get_progress_information_message(self) -> str:
504523
)
505524
return " [100%]"
506525

507-
def _write_progress_information_filling_space(self, color=None):
508-
if not color:
509-
color, _ = _get_main_color(self.stats)
526+
def _write_progress_information_filling_space(self):
527+
color, _ = self._get_main_color()
510528
msg = self._get_progress_information_message()
511529
w = self._width_of_current_line
512530
fill = self._tw.fullwidth - w - 1
@@ -531,9 +549,9 @@ def pytest_collection(self) -> None:
531549

532550
def pytest_collectreport(self, report: CollectReport) -> None:
533551
if report.failed:
534-
self.stats.setdefault("error", []).append(report)
552+
self._add_stats("error", [report])
535553
elif report.skipped:
536-
self.stats.setdefault("skipped", []).append(report)
554+
self._add_stats("skipped", [report])
537555
items = [x for x in report.result if isinstance(x, pytest.Item)]
538556
self._numcollected += len(items)
539557
if self.isatty:
@@ -916,7 +934,7 @@ def summary_stats(self):
916934
return
917935

918936
session_duration = time.time() - self._sessionstarttime
919-
(parts, main_color) = build_summary_stats_line(self.stats)
937+
(parts, main_color) = self.build_summary_stats_line()
920938
line_parts = []
921939

922940
display_sep = self.verbosity >= 0
@@ -1017,6 +1035,53 @@ def show_skipped(lines: List[str]) -> None:
10171035
for line in lines:
10181036
self.write_line(line)
10191037

1038+
def _get_main_color(self) -> Tuple[str, List[str]]:
1039+
if self._main_color is None or self._known_types is None or self._is_last_item:
1040+
self._set_main_color()
1041+
assert self._main_color
1042+
assert self._known_types
1043+
return self._main_color, self._known_types
1044+
1045+
def _determine_main_color(self, unknown_type_seen: bool) -> str:
1046+
stats = self.stats
1047+
if "failed" in stats or "error" in stats:
1048+
main_color = "red"
1049+
elif "warnings" in stats or "xpassed" in stats or unknown_type_seen:
1050+
main_color = "yellow"
1051+
elif "passed" in stats or not self._is_last_item:
1052+
main_color = "green"
1053+
else:
1054+
main_color = "yellow"
1055+
return main_color
1056+
1057+
def _set_main_color(self) -> None:
1058+
unknown_types = [] # type: List[str]
1059+
for found_type in self.stats.keys():
1060+
if found_type: # setup/teardown reports have an empty key, ignore them
1061+
if found_type not in KNOWN_TYPES and found_type not in unknown_types:
1062+
unknown_types.append(found_type)
1063+
self._known_types = list(KNOWN_TYPES) + unknown_types
1064+
self._main_color = self._determine_main_color(bool(unknown_types))
1065+
1066+
def build_summary_stats_line(self) -> Tuple[List[Tuple[str, Dict[str, bool]]], str]:
1067+
main_color, known_types = self._get_main_color()
1068+
1069+
parts = []
1070+
for key in known_types:
1071+
reports = self.stats.get(key, None)
1072+
if reports:
1073+
count = sum(
1074+
1 for rep in reports if getattr(rep, "count_towards_summary", True)
1075+
)
1076+
color = _color_for_type.get(key, _color_for_type_default)
1077+
markup = {color: True, "bold": color == main_color}
1078+
parts.append(("%d %s" % _make_plural(count, key), markup))
1079+
1080+
if not parts:
1081+
parts = [("no tests ran", {_color_for_type_default: True})]
1082+
1083+
return parts, main_color
1084+
10201085

10211086
def _get_pos(config, rep):
10221087
nodeid = config.cwd_relative_nodeid(rep.nodeid)
@@ -1105,50 +1170,6 @@ def _make_plural(count, noun):
11051170
return count, noun + "s" if count != 1 else noun
11061171

11071172

1108-
def _get_main_color(stats) -> Tuple[str, List[str]]:
1109-
known_types = (
1110-
"failed passed skipped deselected xfailed xpassed warnings error".split()
1111-
)
1112-
unknown_type_seen = False
1113-
for found_type in stats.keys():
1114-
if found_type not in known_types:
1115-
if found_type: # setup/teardown reports have an empty key, ignore them
1116-
known_types.append(found_type)
1117-
unknown_type_seen = True
1118-
1119-
# main color
1120-
if "failed" in stats or "error" in stats:
1121-
main_color = "red"
1122-
elif "warnings" in stats or "xpassed" in stats or unknown_type_seen:
1123-
main_color = "yellow"
1124-
elif "passed" in stats:
1125-
main_color = "green"
1126-
else:
1127-
main_color = "yellow"
1128-
1129-
return main_color, known_types
1130-
1131-
1132-
def build_summary_stats_line(stats):
1133-
main_color, known_types = _get_main_color(stats)
1134-
1135-
parts = []
1136-
for key in known_types:
1137-
reports = stats.get(key, None)
1138-
if reports:
1139-
count = sum(
1140-
1 for rep in reports if getattr(rep, "count_towards_summary", True)
1141-
)
1142-
color = _color_for_type.get(key, _color_for_type_default)
1143-
markup = {color: True, "bold": color == main_color}
1144-
parts.append(("%d %s" % _make_plural(count, key), markup))
1145-
1146-
if not parts:
1147-
parts = [("no tests ran", {_color_for_type_default: True})]
1148-
1149-
return parts, main_color
1150-
1151-
11521173
def _plugin_nameversions(plugininfo) -> List[str]:
11531174
values = [] # type: List[str]
11541175
for plugin, dist in plugininfo:

testing/test_terminal.py

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,21 @@
66
import sys
77
import textwrap
88
from io import StringIO
9+
from typing import Dict
10+
from typing import List
11+
from typing import Tuple
912

1013
import pluggy
1114
import py
1215

16+
import _pytest.config
1317
import pytest
1418
from _pytest.config import ExitCode
1519
from _pytest.pytester import Testdir
1620
from _pytest.reports import BaseReport
1721
from _pytest.terminal import _folded_skips
1822
from _pytest.terminal import _get_line_with_reprcrash_message
1923
from _pytest.terminal import _plugin_nameversions
20-
from _pytest.terminal import build_summary_stats_line
2124
from _pytest.terminal import getreportopt
2225
from _pytest.terminal import TerminalReporter
2326

@@ -1409,6 +1412,12 @@ def test_failure():
14091412
assert stdout.count("=== warnings summary ") == 1
14101413

14111414

1415+
@pytest.fixture(scope="session")
1416+
def tr() -> TerminalReporter:
1417+
config = _pytest.config._prepareconfig()
1418+
return TerminalReporter(config)
1419+
1420+
14121421
@pytest.mark.parametrize(
14131422
"exp_color, exp_line, stats_arg",
14141423
[
@@ -1539,26 +1548,47 @@ def test_failure():
15391548
),
15401549
],
15411550
)
1542-
def test_summary_stats(exp_line, exp_color, stats_arg):
1551+
def test_summary_stats(
1552+
tr: TerminalReporter,
1553+
exp_line: List[Tuple[str, Dict[str, bool]]],
1554+
exp_color: str,
1555+
stats_arg: Dict[str, List],
1556+
) -> None:
1557+
tr.stats = stats_arg
1558+
1559+
# Fake "_is_last_item" to be True.
1560+
class fake_session:
1561+
testscollected = 0
1562+
1563+
tr._session = fake_session # type: ignore[assignment] # noqa: F821
1564+
assert tr._is_last_item
1565+
1566+
# Reset cache.
1567+
tr._main_color = None
1568+
15431569
print("Based on stats: %s" % stats_arg)
15441570
print('Expect summary: "{}"; with color "{}"'.format(exp_line, exp_color))
1545-
(line, color) = build_summary_stats_line(stats_arg)
1571+
(line, color) = tr.build_summary_stats_line()
15461572
print('Actually got: "{}"; with color "{}"'.format(line, color))
15471573
assert line == exp_line
15481574
assert color == exp_color
15491575

15501576

1551-
def test_skip_counting_towards_summary():
1577+
def test_skip_counting_towards_summary(tr):
15521578
class DummyReport(BaseReport):
15531579
count_towards_summary = True
15541580

15551581
r1 = DummyReport()
15561582
r2 = DummyReport()
1557-
res = build_summary_stats_line({"failed": (r1, r2)})
1583+
tr.stats = {"failed": (r1, r2)}
1584+
tr._main_color = None
1585+
res = tr.build_summary_stats_line()
15581586
assert res == ([("2 failed", {"bold": True, "red": True})], "red")
15591587

15601588
r1.count_towards_summary = False
1561-
res = build_summary_stats_line({"failed": (r1, r2)})
1589+
tr.stats = {"failed": (r1, r2)}
1590+
tr._main_color = None
1591+
res = tr.build_summary_stats_line()
15621592
assert res == ([("1 failed", {"bold": True, "red": True})], "red")
15631593

15641594

@@ -1660,6 +1690,11 @@ def test_normal(self, many_tests_files, testdir):
16601690
def test_colored_progress(self, testdir, monkeypatch, color_mapping):
16611691
monkeypatch.setenv("PY_COLORS", "1")
16621692
testdir.makepyfile(
1693+
test_axfail="""
1694+
import pytest
1695+
@pytest.mark.xfail
1696+
def test_axfail(): assert 0
1697+
""",
16631698
test_bar="""
16641699
import pytest
16651700
@pytest.mark.parametrize('i', range(10))
@@ -1683,13 +1718,25 @@ def test_foobar(i): raise ValueError()
16831718
result.stdout.re_match_lines(
16841719
color_mapping.format_for_rematch(
16851720
[
1686-
r"test_bar.py ({green}\.{reset}){{10}}{green} \s+ \[ 50%\]{reset}",
1687-
r"test_foo.py ({green}\.{reset}){{5}}{yellow} \s+ \[ 75%\]{reset}",
1721+
r"test_axfail.py {yellow}x{reset}{green} \s+ \[ 4%\]{reset}",
1722+
r"test_bar.py ({green}\.{reset}){{10}}{green} \s+ \[ 52%\]{reset}",
1723+
r"test_foo.py ({green}\.{reset}){{5}}{yellow} \s+ \[ 76%\]{reset}",
16881724
r"test_foobar.py ({red}F{reset}){{5}}{red} \s+ \[100%\]{reset}",
16891725
]
16901726
)
16911727
)
16921728

1729+
# Only xfail should have yellow progress indicator.
1730+
result = testdir.runpytest("test_axfail.py")
1731+
result.stdout.re_match_lines(
1732+
color_mapping.format_for_rematch(
1733+
[
1734+
r"test_axfail.py {yellow}x{reset}{yellow} \s+ \[100%\]{reset}",
1735+
r"^{yellow}=+ ({yellow}{bold}|{bold}{yellow})1 xfailed{reset}{yellow} in ",
1736+
]
1737+
)
1738+
)
1739+
16931740
def test_count(self, many_tests_files, testdir):
16941741
testdir.makeini(
16951742
"""

0 commit comments

Comments
 (0)