Skip to content

Console progress output #2858

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
51 changes: 46 additions & 5 deletions _pytest/pytester.py
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,23 @@ def _getlines(self, lines2):
return lines2

def fnmatch_lines_random(self, lines2):
"""Check lines exist in the output using ``fnmatch.fnmatch``, in any order.

The argument is a list of lines which have to occur in the
output, in any order.
"""
self._match_lines_random(lines2, fnmatch)

def re_match_lines_random(self, lines2):
"""Check lines exist in the output using ``re.match``, in any order.

The argument is a list of lines which have to occur in the
output, in any order.

"""
self._match_lines_random(lines2, lambda name, pat: re.match(pat, name))

def _match_lines_random(self, lines2, match_func):
"""Check lines exist in the output.

The argument is a list of lines which have to occur in the
Expand All @@ -1086,7 +1103,7 @@ def fnmatch_lines_random(self, lines2):
lines2 = self._getlines(lines2)
for line in lines2:
for x in self.lines:
if line == x or fnmatch(x, line):
if line == x or match_func(x, line):
self._log("matched: ", repr(line))
break
else:
Expand All @@ -1111,13 +1128,37 @@ def _log_text(self):
return '\n'.join(self._log_output)

def fnmatch_lines(self, lines2):
"""Search the text for matching lines.
"""Search captured text for matching lines using ``fnmatch.fnmatch``.

The argument is a list of lines which have to match and can
use glob wildcards. If they do not match an pytest.fail() is
use glob wildcards. If they do not match a pytest.fail() is
called. The matches and non-matches are also printed on
stdout.

"""
self._match_lines(lines2, fnmatch, 'fnmatch')

def re_match_lines(self, lines2):
"""Search captured text for matching lines using ``re.match``.

The argument is a list of lines which have to match using ``re.match``.
If they do not match a pytest.fail() is called.

The matches and non-matches are also printed on
stdout.
"""
self._match_lines(lines2, lambda name, pat: re.match(pat, name), 're.match')

def _match_lines(self, lines2, match_func, match_nickname):
"""Underlying implementation of ``fnmatch_lines`` and ``re_match_lines``.

:param list[str] lines2: list of string patterns to match. The actual format depends on
``match_func``.
:param match_func: a callable ``match_func(line, pattern)`` where line is the captured
line from stdout/stderr and pattern is the matching pattern.

:param str match_nickname: the nickname for the match function that will be logged
to stdout when a match occurs.
"""
lines2 = self._getlines(lines2)
lines1 = self.lines[:]
Expand All @@ -1131,8 +1172,8 @@ def fnmatch_lines(self, lines2):
if line == nextline:
self._log("exact match:", repr(line))
break
elif fnmatch(nextline, line):
self._log("fnmatch:", repr(line))
elif match_func(nextline, line):
self._log("%s:" % match_nickname, repr(line))
self._log(" with:", repr(nextline))
break
else:
Expand Down
63 changes: 54 additions & 9 deletions _pytest/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ def pytest_addoption(parser):
choices=['yes', 'no', 'auto'],
help="color terminal output (yes/no/auto).")

parser.addini("console_output_style",
help="console output: classic or with additional progress information (classic|progress).",
default='progress')


def pytest_configure(config):
config.option.verbose -= config.option.quiet
Expand Down Expand Up @@ -135,16 +139,20 @@ def __init__(self, config, file=None):
self.showfspath = self.verbosity >= 0
self.showlongtestinfo = self.verbosity > 0
self._numcollected = 0
self._session = None

self.stats = {}
self.startdir = py.path.local()
if file is None:
file = sys.stdout
self._writer = _pytest.config.create_terminal_writer(config, file)
self._screen_width = self.writer.fullwidth
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to cache this otherwise I noticed a good 20% performance penalty. We probably need to fix this in py somehow, which we have tried to do in the past.

self.currentfspath = None
self.reportchars = getreportopt(config)
self.hasmarkup = self.writer.hasmarkup
self.isatty = file.isatty()
self._progress_items_reported = 0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really have no way already to get how many tests have run so far?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that I know of; usually this is not really tracked by pytest explicitly, each runtestprotocol just triggers the appropriate hooks and each plugin does with that information what it needs.

self._show_progress_info = self.config.getini('console_output_style') == 'progress'

@property
def writer(self):
Expand All @@ -163,6 +171,8 @@ def hasopt(self, char):
def write_fspath_result(self, nodeid, res):
fspath = self.config.rootdir.join(nodeid.split("::")[0])
if fspath != self.currentfspath:
if self.currentfspath is not None:
self._write_progress_information_filling_space()
self.currentfspath = fspath
fspath = self.startdir.bestrelpath(fspath)
self.writer.line()
Expand All @@ -177,6 +187,7 @@ def write_ensure_prefix(self, prefix, extra="", **kwargs):
if extra:
self.writer.write(extra, **kwargs)
self.currentfspath = -2
self._write_progress_information_filling_space()

def ensure_newline(self):
if self.currentfspath:
Expand All @@ -203,7 +214,7 @@ def rewrite(self, line, **markup):
"""
erase = markup.pop('erase', False)
if erase:
fill_count = self.writer.fullwidth - len(line)
fill_count = self.writer.fullwidth - len(line) - 1
fill = ' ' * fill_count
else:
fill = ''
Expand Down Expand Up @@ -256,20 +267,25 @@ def pytest_runtest_logreport(self, report):
rep = report
res = self.config.hook.pytest_report_teststatus(report=rep)
cat, letter, word = res
if isinstance(word, tuple):
word, markup = word
else:
markup = None
self.stats.setdefault(cat, []).append(rep)
self._tests_ran = True
if not letter and not word:
# probably passed setup/teardown
return
running_xdist = hasattr(rep, 'node')
self._progress_items_reported += 1
if self.verbosity <= 0:
if not hasattr(rep, 'node') and self.showfspath:
if not running_xdist and self.showfspath:
self.write_fspath_result(rep.nodeid, letter)
else:
self.writer.write(letter)
self._write_progress_if_past_edge()
else:
if isinstance(word, tuple):
word, markup = word
else:
if markup is None:
if rep.passed:
markup = {'green': True}
elif rep.failed:
Expand All @@ -279,17 +295,45 @@ def pytest_runtest_logreport(self, report):
else:
markup = {}
line = self._locationline(rep.nodeid, *rep.location)
if not hasattr(rep, 'node'):
if not running_xdist:
self.write_ensure_prefix(line, word, **markup)
# self.writer.write(word, **markup)
else:
self.ensure_newline()
if hasattr(rep, 'node'):
self.writer.write("[%s] " % rep.node.gateway.id)
self.writer.write("[%s]" % rep.node.gateway.id)
if self._show_progress_info:
self.writer.write(self._get_progress_information_message() + " ", cyan=True)
else:
self.writer.write(' ')
self.writer.write(word, **markup)
self.writer.write(" " + line)
self.currentfspath = -2

def _write_progress_if_past_edge(self):
if not self._show_progress_info:
return
last_item = self._progress_items_reported == self._session.testscollected
if last_item:
self._write_progress_information_filling_space()
return

past_edge = self.writer.chars_on_current_line + self._PROGRESS_LENGTH + 1 >= self._screen_width
if past_edge:
msg = self._get_progress_information_message()
self.writer.write(msg + '\n', cyan=True)

_PROGRESS_LENGTH = len(' [100%]')

def _get_progress_information_message(self):
progress = self._progress_items_reported * 100 // self._session.testscollected
return ' [{:3d}%]'.format(progress)

def _write_progress_information_filling_space(self):
if not self._show_progress_info:
return
msg = self._get_progress_information_message()
fill = ' ' * (self.writer.fullwidth - self.writer.chars_on_current_line - len(msg) - 1)
self.write(fill + msg, cyan=True)

def pytest_collection(self):
if not self.isatty and self.config.option.verbose >= 1:
self.write("collecting ... ", bold=True)
Expand Down Expand Up @@ -332,6 +376,7 @@ def pytest_collection_modifyitems(self):

@pytest.hookimpl(trylast=True)
def pytest_sessionstart(self, session):
self._session = session
self._sessionstarttime = time.time()
if not self.showheader:
return
Expand Down
1 change: 1 addition & 0 deletions changelog/2657.feature
Original file line number Diff line number Diff line change
@@ -0,0 +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``.
19 changes: 19 additions & 0 deletions doc/en/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,22 @@ Builtin configuration file options
relative to :ref:`rootdir <rootdir>`. Additionally path may contain environment
variables, that will be expanded. For more information about cache plugin
please refer to :ref:`cache_provider`.


.. confval:: console_output_style

.. versionadded:: 3.3

Sets the console output style while running tests:

* ``classic``: classic pytest output.
* ``progress``: like classic pytest output, but with a progress indicator.

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

.. code-block:: ini

# content of pytest.ini
[pytest]
console_output_style = classic
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def has_environment_marker_support():
def main():
extras_require = {}
install_requires = [
'py>=1.4.33,<1.5',
'py>=1.5.0',
'six>=1.10.0',
'setuptools',
'attrs>=17.2.0',
Expand Down
10 changes: 5 additions & 5 deletions testing/acceptance_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,18 +630,18 @@ def join_pythonpath(*dirs):
testdir.chdir()
assert result.ret == 0
result.stdout.fnmatch_lines([
"*test_hello.py::test_hello*PASSED",
"*test_hello.py::test_other*PASSED",
"*test_world.py::test_world*PASSED",
"*test_world.py::test_other*PASSED",
"*test_hello.py::test_hello*PASSED*",
"*test_hello.py::test_other*PASSED*",
"*test_world.py::test_world*PASSED*",
"*test_world.py::test_other*PASSED*",
"*4 passed*"
])

# specify tests within a module
result = testdir.runpytest("--pyargs", "-v", "ns_pkg.world.test_world::test_other")
assert result.ret == 0
result.stdout.fnmatch_lines([
"*test_world.py::test_other*PASSED",
"*test_world.py::test_other*PASSED*",
"*1 passed*"
])

Expand Down
8 changes: 8 additions & 0 deletions testing/python/fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -2119,6 +2119,10 @@ def test_2(arg):
assert values == [1, 1, 2, 2]

def test_module_parametrized_ordering(self, testdir):
testdir.makeini("""
[pytest]
console_output_style=classic
""")
testdir.makeconftest("""
import pytest

Expand Down Expand Up @@ -2165,6 +2169,10 @@ def test_func4(marg):
""")

def test_class_ordering(self, testdir):
testdir.makeini("""
[pytest]
console_output_style=classic
""")
testdir.makeconftest("""
import pytest

Expand Down
14 changes: 9 additions & 5 deletions testing/python/metafunc.py
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,10 @@ def test_func(arg2):
])

def test_parametrize_with_ids(self, testdir):
testdir.makeini("""
[pytest]
console_output_style=classic
""")
testdir.makepyfile("""
import pytest
def pytest_generate_tests(metafunc):
Expand Down Expand Up @@ -1005,9 +1009,9 @@ def test_function(a, b):
result = testdir.runpytest("-v")
assert result.ret == 1
result.stdout.fnmatch_lines_random([
"*test_function*basic*PASSED",
"*test_function*1-1*PASSED",
"*test_function*advanced*FAILED",
"*test_function*basic*PASSED*",
"*test_function*1-1*PASSED*",
"*test_function*advanced*FAILED*",
])

def test_fixture_parametrized_empty_ids(self, testdir):
Expand Down Expand Up @@ -1062,8 +1066,8 @@ def test_function(a, b):
result = testdir.runpytest("-v")
assert result.ret == 1
result.stdout.fnmatch_lines_random([
"*test_function*a0*PASSED",
"*test_function*a1*FAILED"
"*test_function*a0*PASSED*",
"*test_function*a1*FAILED*"
])

@pytest.mark.parametrize(("scope", "length"),
Expand Down
2 changes: 1 addition & 1 deletion testing/python/setup_only.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,6 @@ def test_arg(arg):

result.stdout.fnmatch_lines([
'*SETUP F arg*',
'*test_arg (fixtures used: arg)F',
'*test_arg (fixtures used: arg)F*',
'*TEARDOWN F arg*',
])
4 changes: 2 additions & 2 deletions testing/test_assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,7 @@ def test_onefails():
""")
result = testdir.runpytest(p1, "--tb=long")
result.stdout.fnmatch_lines([
"*test_traceback_failure.py F",
"*test_traceback_failure.py F*",
"====* FAILURES *====",
"____*____",
"",
Expand All @@ -840,7 +840,7 @@ def test_onefails():

result = testdir.runpytest(p1) # "auto"
result.stdout.fnmatch_lines([
"*test_traceback_failure.py F",
"*test_traceback_failure.py F*",
"====* FAILURES *====",
"____*____",
"",
Expand Down
2 changes: 1 addition & 1 deletion testing/test_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ def test_capturing_error():
""")
result = testdir.runpytest(p1)
result.stdout.fnmatch_lines([
"*test_capturing_outerr.py .F",
"*test_capturing_outerr.py .F*",
"====* FAILURES *====",
"____*____",
"*test_capturing_outerr.py:8: ValueError",
Expand Down
Loading