Skip to content

Commit b89c1ce

Browse files
afcmrpnicoddemus
andauthored
Add confval console_output_style option times (#13126)
Fixes #13125 --------- Co-authored-by: Bruno Oliveira <[email protected]>
1 parent 195f7e1 commit b89c1ce

File tree

5 files changed

+184
-7
lines changed

5 files changed

+184
-7
lines changed

AUTHORS

+1
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ Eero Vaher
142142
Eli Boyarski
143143
Elizaveta Shashkova
144144
Éloi Rivard
145+
Emil Hjelm
145146
Endre Galaczi
146147
Eric Hunsberger
147148
Eric Liu

changelog/13125.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:confval:`console_output_style` now supports ``times`` to show execution time of each test.

doc/en/reference/reference.rst

+1
Original file line numberDiff line numberDiff line change
@@ -1355,6 +1355,7 @@ passed multiple times. The expected format is ``name=value``. For example::
13551355
* ``progress``: like classic pytest output, but with a progress indicator.
13561356
* ``progress-even-when-capture-no``: allows the use of the progress indicator even when ``capture=no``.
13571357
* ``count``: like progress, but shows progress as the number of tests completed instead of a percent.
1358+
* ``times``: show tests duration.
13581359

13591360
The default is ``progress``, but you can fallback to ``classic`` if you prefer or
13601361
the new mode is causing unexpected problems:

src/_pytest/terminal.py

+63-7
Original file line numberDiff line numberDiff line change
@@ -382,12 +382,15 @@ def __init__(self, config: Config, file: TextIO | None = None) -> None:
382382
self.hasmarkup = self._tw.hasmarkup
383383
self.isatty = file.isatty()
384384
self._progress_nodeids_reported: set[str] = set()
385+
self._timing_nodeids_reported: set[str] = set()
385386
self._show_progress_info = self._determine_show_progress_info()
386387
self._collect_report_last_write: float | None = None
387388
self._already_displayed_warnings: int | None = None
388389
self._keyboardinterrupt_memo: ExceptionRepr | None = None
389390

390-
def _determine_show_progress_info(self) -> Literal["progress", "count", False]:
391+
def _determine_show_progress_info(
392+
self,
393+
) -> Literal["progress", "count", "times", False]:
391394
"""Return whether we should display progress information based on the current config."""
392395
# do not show progress if we are not capturing output (#3038) unless explicitly
393396
# overridden by progress-even-when-capture-no
@@ -405,6 +408,8 @@ def _determine_show_progress_info(self) -> Literal["progress", "count", False]:
405408
return "progress"
406409
elif cfg == "count":
407410
return "count"
411+
elif cfg == "times":
412+
return "times"
408413
else:
409414
return False
410415

@@ -692,19 +697,47 @@ def _get_progress_information_message(self) -> str:
692697
format_string = f" [{counter_format}/{{}}]"
693698
return format_string.format(progress, collected)
694699
return f" [ {collected} / {collected} ]"
695-
else:
696-
if collected:
697-
return (
698-
f" [{len(self._progress_nodeids_reported) * 100 // collected:3d}%]"
699-
)
700-
return " [100%]"
700+
if self._show_progress_info == "times":
701+
if not collected:
702+
return ""
703+
all_reports = (
704+
self._get_reports_to_display("passed")
705+
+ self._get_reports_to_display("xpassed")
706+
+ self._get_reports_to_display("failed")
707+
+ self._get_reports_to_display("xfailed")
708+
+ self._get_reports_to_display("skipped")
709+
+ self._get_reports_to_display("error")
710+
+ self._get_reports_to_display("")
711+
)
712+
current_location = all_reports[-1].location[0]
713+
not_reported = [
714+
r for r in all_reports if r.nodeid not in self._timing_nodeids_reported
715+
]
716+
tests_in_module = sum(
717+
i.location[0] == current_location for i in self._session.items
718+
)
719+
tests_completed = sum(
720+
r.when == "setup"
721+
for r in not_reported
722+
if r.location[0] == current_location
723+
)
724+
last_in_module = tests_completed == tests_in_module
725+
if self.showlongtestinfo or last_in_module:
726+
self._timing_nodeids_reported.update(r.nodeid for r in not_reported)
727+
return format_node_duration(sum(r.duration for r in not_reported))
728+
return ""
729+
if collected:
730+
return f" [{len(self._progress_nodeids_reported) * 100 // collected:3d}%]"
731+
return " [100%]"
701732

702733
def _write_progress_information_if_past_edge(self) -> None:
703734
w = self._width_of_current_line
704735
if self._show_progress_info == "count":
705736
assert self._session
706737
num_tests = self._session.testscollected
707738
progress_length = len(f" [{num_tests}/{num_tests}]")
739+
elif self._show_progress_info == "times":
740+
progress_length = len(" 99h 59m")
708741
else:
709742
progress_length = len(" [100%]")
710743
past_edge = w + progress_length + 1 >= self._screen_width
@@ -1554,6 +1587,29 @@ def format_session_duration(seconds: float) -> str:
15541587
return f"{seconds:.2f}s ({dt})"
15551588

15561589

1590+
def format_node_duration(seconds: float) -> str:
1591+
"""Format the given seconds in a human readable manner to show in the test progress."""
1592+
# The formatting is designed to be compact and readable, with at most 7 characters
1593+
# for durations below 100 hours.
1594+
if seconds < 0.00001:
1595+
return f" {seconds * 1000000:.3f}us"
1596+
if seconds < 0.0001:
1597+
return f" {seconds * 1000000:.2f}us"
1598+
if seconds < 0.001:
1599+
return f" {seconds * 1000000:.1f}us"
1600+
if seconds < 0.01:
1601+
return f" {seconds * 1000:.3f}ms"
1602+
if seconds < 0.1:
1603+
return f" {seconds * 1000:.2f}ms"
1604+
if seconds < 1:
1605+
return f" {seconds * 1000:.1f}ms"
1606+
if seconds < 60:
1607+
return f" {seconds:.3f}s"
1608+
if seconds < 3600:
1609+
return f" {seconds // 60:.0f}m {seconds % 60:.0f}s"
1610+
return f" {seconds // 3600:.0f}h {(seconds % 3600) // 60:.0f}m"
1611+
1612+
15571613
def _get_raw_skip_reason(report: TestReport) -> str:
15581614
"""Get the reason string of a skip/xfail/xpass test report.
15591615

testing/test_terminal.py

+118
Original file line numberDiff line numberDiff line change
@@ -2073,6 +2073,21 @@ def test_foobar(i): pass
20732073
""",
20742074
)
20752075

2076+
@pytest.fixture
2077+
def more_tests_files(self, pytester: Pytester) -> None:
2078+
pytester.makepyfile(
2079+
test_bar="""
2080+
import pytest
2081+
@pytest.mark.parametrize('i', range(30))
2082+
def test_bar(i): pass
2083+
""",
2084+
test_foo="""
2085+
import pytest
2086+
@pytest.mark.parametrize('i', range(5))
2087+
def test_foo(i): pass
2088+
""",
2089+
)
2090+
20762091
def test_zero_tests_collected(self, pytester: Pytester) -> None:
20772092
"""Some plugins (testmon for example) might issue pytest_runtest_logreport without any tests being
20782093
actually collected (#2971)."""
@@ -2169,6 +2184,52 @@ def test_count(self, many_tests_files, pytester: Pytester) -> None:
21692184
]
21702185
)
21712186

2187+
def test_times(self, many_tests_files, pytester: Pytester) -> None:
2188+
pytester.makeini(
2189+
"""
2190+
[pytest]
2191+
console_output_style = times
2192+
"""
2193+
)
2194+
output = pytester.runpytest()
2195+
output.stdout.re_match_lines(
2196+
[
2197+
r"test_bar.py \.{10} \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$",
2198+
r"test_foo.py \.{5} \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$",
2199+
r"test_foobar.py \.{5} \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$",
2200+
]
2201+
)
2202+
2203+
def test_times_multiline(
2204+
self, more_tests_files, monkeypatch, pytester: Pytester
2205+
) -> None:
2206+
monkeypatch.setenv("COLUMNS", "40")
2207+
pytester.makeini(
2208+
"""
2209+
[pytest]
2210+
console_output_style = times
2211+
"""
2212+
)
2213+
output = pytester.runpytest()
2214+
output.stdout.re_match_lines(
2215+
[
2216+
r"test_bar.py ...................",
2217+
r"........... \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$",
2218+
r"test_foo.py \.{5} \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$",
2219+
],
2220+
consecutive=True,
2221+
)
2222+
2223+
def test_times_none_collected(self, pytester: Pytester) -> None:
2224+
pytester.makeini(
2225+
"""
2226+
[pytest]
2227+
console_output_style = times
2228+
"""
2229+
)
2230+
output = pytester.runpytest()
2231+
assert output.ret == ExitCode.NO_TESTS_COLLECTED
2232+
21722233
def test_verbose(self, many_tests_files, pytester: Pytester) -> None:
21732234
output = pytester.runpytest("-v")
21742235
output.stdout.re_match_lines(
@@ -2195,6 +2256,22 @@ def test_verbose_count(self, many_tests_files, pytester: Pytester) -> None:
21952256
]
21962257
)
21972258

2259+
def test_verbose_times(self, many_tests_files, pytester: Pytester) -> None:
2260+
pytester.makeini(
2261+
"""
2262+
[pytest]
2263+
console_output_style = times
2264+
"""
2265+
)
2266+
output = pytester.runpytest("-v")
2267+
output.stdout.re_match_lines(
2268+
[
2269+
r"test_bar.py::test_bar\[0\] PASSED \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$",
2270+
r"test_foo.py::test_foo\[4\] PASSED \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$",
2271+
r"test_foobar.py::test_foobar\[4\] PASSED \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$",
2272+
]
2273+
)
2274+
21982275
def test_xdist_normal(
21992276
self, many_tests_files, pytester: Pytester, monkeypatch
22002277
) -> None:
@@ -2247,6 +2324,26 @@ def test_xdist_verbose(
22472324
]
22482325
)
22492326

2327+
def test_xdist_times(
2328+
self, many_tests_files, pytester: Pytester, monkeypatch
2329+
) -> None:
2330+
pytest.importorskip("xdist")
2331+
monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False)
2332+
pytester.makeini(
2333+
"""
2334+
[pytest]
2335+
console_output_style = times
2336+
"""
2337+
)
2338+
output = pytester.runpytest("-n2", "-v")
2339+
output.stdout.re_match_lines_random(
2340+
[
2341+
r"\[gw\d\] \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2} PASSED test_bar.py::test_bar\[1\]",
2342+
r"\[gw\d\] \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2} PASSED test_foo.py::test_foo\[1\]",
2343+
r"\[gw\d\] \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2} PASSED test_foobar.py::test_foobar\[1\]",
2344+
]
2345+
)
2346+
22502347
def test_capture_no(self, many_tests_files, pytester: Pytester) -> None:
22512348
output = pytester.runpytest("-s")
22522349
output.stdout.re_match_lines(
@@ -2537,6 +2634,27 @@ def test_format_session_duration(seconds, expected):
25372634
assert format_session_duration(seconds) == expected
25382635

25392636

2637+
@pytest.mark.parametrize(
2638+
"seconds, expected",
2639+
[
2640+
(3600 * 100 - 60, " 99h 59m"),
2641+
(31 * 60 - 1, " 30m 59s"),
2642+
(10.1236, " 10.124s"),
2643+
(9.1236, " 9.124s"),
2644+
(0.1236, " 123.6ms"),
2645+
(0.01236, " 12.36ms"),
2646+
(0.001236, " 1.236ms"),
2647+
(0.0001236, " 123.6us"),
2648+
(0.00001236, " 12.36us"),
2649+
(0.000001236, " 1.236us"),
2650+
],
2651+
)
2652+
def test_format_node_duration(seconds: float, expected: str) -> None:
2653+
from _pytest.terminal import format_node_duration
2654+
2655+
assert format_node_duration(seconds) == expected
2656+
2657+
25402658
def test_collecterror(pytester: Pytester) -> None:
25412659
p1 = pytester.makepyfile("raise SyntaxError()")
25422660
result = pytester.runpytest("-ra", str(p1))

0 commit comments

Comments
 (0)