Skip to content

Commit b533c26

Browse files
Merge pull request #2858 from nicoddemus/console-progress-2657
Console progress output
2 parents ca1f4bc + dc574c6 commit b533c26

12 files changed

+209
-36
lines changed

_pytest/pytester.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,6 +1077,23 @@ def _getlines(self, lines2):
10771077
return lines2
10781078

10791079
def fnmatch_lines_random(self, lines2):
1080+
"""Check lines exist in the output using ``fnmatch.fnmatch``, in any order.
1081+
1082+
The argument is a list of lines which have to occur in the
1083+
output, in any order.
1084+
"""
1085+
self._match_lines_random(lines2, fnmatch)
1086+
1087+
def re_match_lines_random(self, lines2):
1088+
"""Check lines exist in the output using ``re.match``, in any order.
1089+
1090+
The argument is a list of lines which have to occur in the
1091+
output, in any order.
1092+
1093+
"""
1094+
self._match_lines_random(lines2, lambda name, pat: re.match(pat, name))
1095+
1096+
def _match_lines_random(self, lines2, match_func):
10801097
"""Check lines exist in the output.
10811098
10821099
The argument is a list of lines which have to occur in the
@@ -1086,7 +1103,7 @@ def fnmatch_lines_random(self, lines2):
10861103
lines2 = self._getlines(lines2)
10871104
for line in lines2:
10881105
for x in self.lines:
1089-
if line == x or fnmatch(x, line):
1106+
if line == x or match_func(x, line):
10901107
self._log("matched: ", repr(line))
10911108
break
10921109
else:
@@ -1111,13 +1128,37 @@ def _log_text(self):
11111128
return '\n'.join(self._log_output)
11121129

11131130
def fnmatch_lines(self, lines2):
1114-
"""Search the text for matching lines.
1131+
"""Search captured text for matching lines using ``fnmatch.fnmatch``.
11151132
11161133
The argument is a list of lines which have to match and can
1117-
use glob wildcards. If they do not match an pytest.fail() is
1134+
use glob wildcards. If they do not match a pytest.fail() is
11181135
called. The matches and non-matches are also printed on
11191136
stdout.
11201137
1138+
"""
1139+
self._match_lines(lines2, fnmatch, 'fnmatch')
1140+
1141+
def re_match_lines(self, lines2):
1142+
"""Search captured text for matching lines using ``re.match``.
1143+
1144+
The argument is a list of lines which have to match using ``re.match``.
1145+
If they do not match a pytest.fail() is called.
1146+
1147+
The matches and non-matches are also printed on
1148+
stdout.
1149+
"""
1150+
self._match_lines(lines2, lambda name, pat: re.match(pat, name), 're.match')
1151+
1152+
def _match_lines(self, lines2, match_func, match_nickname):
1153+
"""Underlying implementation of ``fnmatch_lines`` and ``re_match_lines``.
1154+
1155+
:param list[str] lines2: list of string patterns to match. The actual format depends on
1156+
``match_func``.
1157+
:param match_func: a callable ``match_func(line, pattern)`` where line is the captured
1158+
line from stdout/stderr and pattern is the matching pattern.
1159+
1160+
:param str match_nickname: the nickname for the match function that will be logged
1161+
to stdout when a match occurs.
11211162
"""
11221163
lines2 = self._getlines(lines2)
11231164
lines1 = self.lines[:]
@@ -1131,8 +1172,8 @@ def fnmatch_lines(self, lines2):
11311172
if line == nextline:
11321173
self._log("exact match:", repr(line))
11331174
break
1134-
elif fnmatch(nextline, line):
1135-
self._log("fnmatch:", repr(line))
1175+
elif match_func(nextline, line):
1176+
self._log("%s:" % match_nickname, repr(line))
11361177
self._log(" with:", repr(nextline))
11371178
break
11381179
else:

_pytest/terminal.py

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ def pytest_addoption(parser):
5151
choices=['yes', 'no', 'auto'],
5252
help="color terminal output (yes/no/auto).")
5353

54+
parser.addini("console_output_style",
55+
help="console output: classic or with additional progress information (classic|progress).",
56+
default='progress')
57+
5458

5559
def pytest_configure(config):
5660
config.option.verbose -= config.option.quiet
@@ -135,16 +139,20 @@ def __init__(self, config, file=None):
135139
self.showfspath = self.verbosity >= 0
136140
self.showlongtestinfo = self.verbosity > 0
137141
self._numcollected = 0
142+
self._session = None
138143

139144
self.stats = {}
140145
self.startdir = py.path.local()
141146
if file is None:
142147
file = sys.stdout
143148
self._writer = _pytest.config.create_terminal_writer(config, file)
149+
self._screen_width = self.writer.fullwidth
144150
self.currentfspath = None
145151
self.reportchars = getreportopt(config)
146152
self.hasmarkup = self.writer.hasmarkup
147153
self.isatty = file.isatty()
154+
self._progress_items_reported = 0
155+
self._show_progress_info = self.config.getini('console_output_style') == 'progress'
148156

149157
@property
150158
def writer(self):
@@ -163,6 +171,8 @@ def hasopt(self, char):
163171
def write_fspath_result(self, nodeid, res):
164172
fspath = self.config.rootdir.join(nodeid.split("::")[0])
165173
if fspath != self.currentfspath:
174+
if self.currentfspath is not None:
175+
self._write_progress_information_filling_space()
166176
self.currentfspath = fspath
167177
fspath = self.startdir.bestrelpath(fspath)
168178
self.writer.line()
@@ -177,6 +187,7 @@ def write_ensure_prefix(self, prefix, extra="", **kwargs):
177187
if extra:
178188
self.writer.write(extra, **kwargs)
179189
self.currentfspath = -2
190+
self._write_progress_information_filling_space()
180191

181192
def ensure_newline(self):
182193
if self.currentfspath:
@@ -203,7 +214,7 @@ def rewrite(self, line, **markup):
203214
"""
204215
erase = markup.pop('erase', False)
205216
if erase:
206-
fill_count = self.writer.fullwidth - len(line)
217+
fill_count = self.writer.fullwidth - len(line) - 1
207218
fill = ' ' * fill_count
208219
else:
209220
fill = ''
@@ -256,20 +267,25 @@ def pytest_runtest_logreport(self, report):
256267
rep = report
257268
res = self.config.hook.pytest_report_teststatus(report=rep)
258269
cat, letter, word = res
270+
if isinstance(word, tuple):
271+
word, markup = word
272+
else:
273+
markup = None
259274
self.stats.setdefault(cat, []).append(rep)
260275
self._tests_ran = True
261276
if not letter and not word:
262277
# probably passed setup/teardown
263278
return
279+
running_xdist = hasattr(rep, 'node')
280+
self._progress_items_reported += 1
264281
if self.verbosity <= 0:
265-
if not hasattr(rep, 'node') and self.showfspath:
282+
if not running_xdist and self.showfspath:
266283
self.write_fspath_result(rep.nodeid, letter)
267284
else:
268285
self.writer.write(letter)
286+
self._write_progress_if_past_edge()
269287
else:
270-
if isinstance(word, tuple):
271-
word, markup = word
272-
else:
288+
if markup is None:
273289
if rep.passed:
274290
markup = {'green': True}
275291
elif rep.failed:
@@ -279,17 +295,45 @@ def pytest_runtest_logreport(self, report):
279295
else:
280296
markup = {}
281297
line = self._locationline(rep.nodeid, *rep.location)
282-
if not hasattr(rep, 'node'):
298+
if not running_xdist:
283299
self.write_ensure_prefix(line, word, **markup)
284-
# self.writer.write(word, **markup)
285300
else:
286301
self.ensure_newline()
287-
if hasattr(rep, 'node'):
288-
self.writer.write("[%s] " % rep.node.gateway.id)
302+
self.writer.write("[%s]" % rep.node.gateway.id)
303+
if self._show_progress_info:
304+
self.writer.write(self._get_progress_information_message() + " ", cyan=True)
305+
else:
306+
self.writer.write(' ')
289307
self.writer.write(word, **markup)
290308
self.writer.write(" " + line)
291309
self.currentfspath = -2
292310

311+
def _write_progress_if_past_edge(self):
312+
if not self._show_progress_info:
313+
return
314+
last_item = self._progress_items_reported == self._session.testscollected
315+
if last_item:
316+
self._write_progress_information_filling_space()
317+
return
318+
319+
past_edge = self.writer.chars_on_current_line + self._PROGRESS_LENGTH + 1 >= self._screen_width
320+
if past_edge:
321+
msg = self._get_progress_information_message()
322+
self.writer.write(msg + '\n', cyan=True)
323+
324+
_PROGRESS_LENGTH = len(' [100%]')
325+
326+
def _get_progress_information_message(self):
327+
progress = self._progress_items_reported * 100 // self._session.testscollected
328+
return ' [{:3d}%]'.format(progress)
329+
330+
def _write_progress_information_filling_space(self):
331+
if not self._show_progress_info:
332+
return
333+
msg = self._get_progress_information_message()
334+
fill = ' ' * (self.writer.fullwidth - self.writer.chars_on_current_line - len(msg) - 1)
335+
self.write(fill + msg, cyan=True)
336+
293337
def pytest_collection(self):
294338
if not self.isatty and self.config.option.verbose >= 1:
295339
self.write("collecting ... ", bold=True)
@@ -332,6 +376,7 @@ def pytest_collection_modifyitems(self):
332376

333377
@pytest.hookimpl(trylast=True)
334378
def pytest_sessionstart(self, session):
379+
self._session = session
335380
self._sessionstarttime = time.time()
336381
if not self.showheader:
337382
return

changelog/2657.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Now pytest displays the total progress percentage while running tests. The previous output style can be set by setting the new ``console_output_style`` to ``classic``.

doc/en/customize.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,3 +312,22 @@ Builtin configuration file options
312312
relative to :ref:`rootdir <rootdir>`. Additionally path may contain environment
313313
variables, that will be expanded. For more information about cache plugin
314314
please refer to :ref:`cache_provider`.
315+
316+
317+
.. confval:: console_output_style
318+
319+
.. versionadded:: 3.3
320+
321+
Sets the console output style while running tests:
322+
323+
* ``classic``: classic pytest output.
324+
* ``progress``: like classic pytest output, but with a progress indicator.
325+
326+
The default is ``progress``, but you can fallback to ``classic`` if you prefer or
327+
the new mode is causing unexpected problems:
328+
329+
.. code-block:: ini
330+
331+
# content of pytest.ini
332+
[pytest]
333+
console_output_style = classic

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def has_environment_marker_support():
4545
def main():
4646
extras_require = {}
4747
install_requires = [
48-
'py>=1.4.33,<1.5',
48+
'py>=1.5.0',
4949
'six>=1.10.0',
5050
'setuptools',
5151
'attrs>=17.2.0',

testing/acceptance_test.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -630,18 +630,18 @@ def join_pythonpath(*dirs):
630630
testdir.chdir()
631631
assert result.ret == 0
632632
result.stdout.fnmatch_lines([
633-
"*test_hello.py::test_hello*PASSED",
634-
"*test_hello.py::test_other*PASSED",
635-
"*test_world.py::test_world*PASSED",
636-
"*test_world.py::test_other*PASSED",
633+
"*test_hello.py::test_hello*PASSED*",
634+
"*test_hello.py::test_other*PASSED*",
635+
"*test_world.py::test_world*PASSED*",
636+
"*test_world.py::test_other*PASSED*",
637637
"*4 passed*"
638638
])
639639

640640
# specify tests within a module
641641
result = testdir.runpytest("--pyargs", "-v", "ns_pkg.world.test_world::test_other")
642642
assert result.ret == 0
643643
result.stdout.fnmatch_lines([
644-
"*test_world.py::test_other*PASSED",
644+
"*test_world.py::test_other*PASSED*",
645645
"*1 passed*"
646646
])
647647

testing/python/fixture.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2119,6 +2119,10 @@ def test_2(arg):
21192119
assert values == [1, 1, 2, 2]
21202120

21212121
def test_module_parametrized_ordering(self, testdir):
2122+
testdir.makeini("""
2123+
[pytest]
2124+
console_output_style=classic
2125+
""")
21222126
testdir.makeconftest("""
21232127
import pytest
21242128
@@ -2165,6 +2169,10 @@ def test_func4(marg):
21652169
""")
21662170

21672171
def test_class_ordering(self, testdir):
2172+
testdir.makeini("""
2173+
[pytest]
2174+
console_output_style=classic
2175+
""")
21682176
testdir.makeconftest("""
21692177
import pytest
21702178

testing/python/metafunc.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,10 @@ def test_func(arg2):
960960
])
961961

962962
def test_parametrize_with_ids(self, testdir):
963+
testdir.makeini("""
964+
[pytest]
965+
console_output_style=classic
966+
""")
963967
testdir.makepyfile("""
964968
import pytest
965969
def pytest_generate_tests(metafunc):
@@ -1005,9 +1009,9 @@ def test_function(a, b):
10051009
result = testdir.runpytest("-v")
10061010
assert result.ret == 1
10071011
result.stdout.fnmatch_lines_random([
1008-
"*test_function*basic*PASSED",
1009-
"*test_function*1-1*PASSED",
1010-
"*test_function*advanced*FAILED",
1012+
"*test_function*basic*PASSED*",
1013+
"*test_function*1-1*PASSED*",
1014+
"*test_function*advanced*FAILED*",
10111015
])
10121016

10131017
def test_fixture_parametrized_empty_ids(self, testdir):
@@ -1062,8 +1066,8 @@ def test_function(a, b):
10621066
result = testdir.runpytest("-v")
10631067
assert result.ret == 1
10641068
result.stdout.fnmatch_lines_random([
1065-
"*test_function*a0*PASSED",
1066-
"*test_function*a1*FAILED"
1069+
"*test_function*a0*PASSED*",
1070+
"*test_function*a1*FAILED*"
10671071
])
10681072

10691073
@pytest.mark.parametrize(("scope", "length"),

testing/python/setup_only.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,6 @@ def test_arg(arg):
238238

239239
result.stdout.fnmatch_lines([
240240
'*SETUP F arg*',
241-
'*test_arg (fixtures used: arg)F',
241+
'*test_arg (fixtures used: arg)F*',
242242
'*TEARDOWN F arg*',
243243
])

testing/test_assertion.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -820,7 +820,7 @@ def test_onefails():
820820
""")
821821
result = testdir.runpytest(p1, "--tb=long")
822822
result.stdout.fnmatch_lines([
823-
"*test_traceback_failure.py F",
823+
"*test_traceback_failure.py F*",
824824
"====* FAILURES *====",
825825
"____*____",
826826
"",
@@ -840,7 +840,7 @@ def test_onefails():
840840

841841
result = testdir.runpytest(p1) # "auto"
842842
result.stdout.fnmatch_lines([
843-
"*test_traceback_failure.py F",
843+
"*test_traceback_failure.py F*",
844844
"====* FAILURES *====",
845845
"____*____",
846846
"",

testing/test_capture.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ def test_capturing_error():
266266
""")
267267
result = testdir.runpytest(p1)
268268
result.stdout.fnmatch_lines([
269-
"*test_capturing_outerr.py .F",
269+
"*test_capturing_outerr.py .F*",
270270
"====* FAILURES *====",
271271
"____*____",
272272
"*test_capturing_outerr.py:8: ValueError",

0 commit comments

Comments
 (0)